CareerAI-app commited on
Commit
b7934cd
Β·
0 Parent(s):

Deploy CareerAI to HuggingFace Spaces

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Virtual environment
8
+ venv/
9
+ .venv/
10
+ ENV/
11
+
12
+ # Data (generated at runtime)
13
+ data/uploads/
14
+ data/vectordb/
15
+ careerai.db
16
+
17
+ # Environment secrets (NEVER commit!)
18
+ .env
19
+ .env.*
20
+
21
+ # Streamlit secrets (contains API keys!)
22
+ .streamlit/secrets.toml
23
+
24
+ # Legacy Streamlit app (replaced by FastAPI + frontend/)
25
+ app.py
26
+
27
+ # IDE
28
+ .vscode/
29
+ .idea/
30
+ *.swp
31
+ *.swo
32
+
33
+ # OS
34
+ .DS_Store
35
+ Thumbs.db
36
+ desktop.ini
37
+
38
+ # Test outputs
39
+ test_output.txt
40
+ test_results*.txt
41
+ _size.tmp
42
+
43
+ # Distribution
44
+ *.egg-info/
45
+ dist/
46
+ build/
.streamlit/config.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ primaryColor = "#8B5CF6"
3
+ backgroundColor = "#09090B"
4
+ secondaryBackgroundColor = "#18181B"
5
+ textColor = "#FAFAFA"
6
+ font = "sans serif"
7
+
8
+ [server]
9
+ maxUploadSize = 50
10
+ headless = true
11
+
12
+ [browser]
13
+ gatherUsageStats = false
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # System dependencies for document processing
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ build-essential \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Create non-root user (required by HF Spaces)
9
+ RUN useradd -m -u 1000 user
10
+ USER user
11
+ ENV HOME=/home/user \
12
+ PATH=/home/user/.local/bin:$PATH
13
+
14
+ WORKDIR /home/user/app
15
+
16
+ # Install Python dependencies first (cached layer)
17
+ COPY --chown=user requirements.txt .
18
+ RUN pip install --no-cache-dir --upgrade pip && \
19
+ pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy application code
22
+ COPY --chown=user . .
23
+
24
+ # Create data directories
25
+ RUN mkdir -p data/uploads data/vectordb
26
+
27
+ # HF Spaces uses port 7860 by default
28
+ EXPOSE 7860
29
+
30
+ # Start command β€” HF Spaces expects port 7860
31
+ CMD uvicorn api:app --host 0.0.0.0 --port 7860 --workers 1
README.md ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CareerAI
3
+ emoji: πŸš€
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ ---
10
+
11
+ <p align="center">
12
+ <img src="https://i.postimg.cc/2yY6ztpG/ideogram-v3-0-Logo-minimalista-y-moderno-para-Career-AI-una-app-de-asistente-IA-para-carreras-p-0-(1.png" alt="CareerAI Logo" width="420">
13
+ </p>
14
+
15
+ <h1 align="center">CareerAI</h1>
16
+
17
+ <p align="center">
18
+ <strong>🧠 AI-Powered Career Assistant | Asistente Inteligente de Carrera</strong><br>
19
+ <em>Analyze your CV Β· Generate Cover Letters Β· Simulate Interviews Β· Search Jobs</em>
20
+ </p>
21
+
22
+ <p align="center">
23
+ <img src="https://img.shields.io/badge/Python-3.10+-blue?logo=python&logoColor=white" alt="Python">
24
+ <img src="https://img.shields.io/badge/FastAPI-0.115+-green?logo=fastapi&logoColor=white" alt="FastAPI">
25
+ <img src="https://img.shields.io/badge/Groq-Llama_3.3-orange?logo=meta&logoColor=white" alt="Groq">
26
+ <img src="https://img.shields.io/badge/ChromaDB-Vector_Store-purple" alt="ChromaDB">
27
+ <img src="https://img.shields.io/badge/License-MIT-yellow" alt="License">
28
+ </p>
29
+
30
+ <p align="center">
31
+ <a href="#-english">πŸ‡ΊπŸ‡Έ English</a> Β· <a href="#-espaΓ±ol">πŸ‡¦πŸ‡· EspaΓ±ol</a>
32
+ </p>
33
+
34
+ ---
35
+
36
+ # πŸ‡ΊπŸ‡Έ English
37
+
38
+ ## 🎬 What is CareerAI?
39
+
40
+ **CareerAI** is an AI-powered web application that helps you boost your professional career. Upload your documents (CV, cover letters, certificates) and the AI assistant analyzes them using advanced Retrieval-Augmented Generation (RAG) to give you personalized recommendations, generate professional documents, and prepare you for job interviews.
41
+
42
+ ### ✨ 100% Free · No hallucinations · Based on your real documents
43
+
44
+ ---
45
+
46
+ ## πŸš€ Key Features
47
+
48
+ ### πŸ€– Custom AI Models
49
+
50
+ | Model | Engine | Description |
51
+ |-------|--------|-------------|
52
+ | 🧠 **CareerAI Pro** | Llama 3.3 70B | Maximum quality · Detailed responses |
53
+ | ⚑ **CareerAI Flash** | Llama 3.1 8B | Ultra fast · Instant responses |
54
+
55
+ ### πŸ’¬ 5 Assistant Modes
56
+
57
+ | Mode | What it does |
58
+ |------|-------------|
59
+ | πŸ’¬ **General Chat** | Ask anything about your professional career |
60
+ | 🎯 **Job Match** | Analyze your compatibility with job offers (% match) |
61
+ | βœ‰οΈ **Cover Letter** | Generate personalized cover letters using your real CV |
62
+ | πŸ“ˆ **Skills Gap** | Identify missing skills + roadmap to improve |
63
+ | 🎀 **Interview** | Simulate interviews with technical and STAR method questions |
64
+
65
+ ### πŸ“‹ Full Feature List
66
+
67
+ | Feature | Description |
68
+ |---------|-------------|
69
+ | πŸ“„ **Multi-format** | Supports PDF, DOCX, TXT, images (JPG, PNG, WebP) |
70
+ | πŸ–ΌοΈ **Vision AI** | Smart reading of scanned PDFs and document photos |
71
+ | ⚑ **Streaming** | Real-time token-by-token responses |
72
+ | πŸ“€ **Premium Export** | Export to PDF, DOCX, HTML, TXT with professional formatting |
73
+ | πŸ“Š **Dashboard** | Skills charts, professional timeline, AI insights |
74
+ | πŸ” **Full Auth** | Register, login, Google OAuth, password reset |
75
+ | πŸ’Ό **Job Search** | Integration with LinkedIn, Indeed, Glassdoor via JSearch |
76
+ | 🎨 **Premium UI** | Claude/ChatGPT-style design with dark mode |
77
+ | πŸ“± **Responsive** | Works on desktop, tablet, and mobile |
78
+ | πŸ’Ύ **Persistence** | Chat history synced to the cloud |
79
+
80
+ ---
81
+
82
+ ## 🧠 RAG Pipeline v2.0
83
+
84
+ CareerAI uses an advanced retrieval pipeline combining multiple techniques to find the most relevant information from your documents:
85
+
86
+ ```
87
+ πŸ“ User Query
88
+ β”‚
89
+ β”œβ”€β”€ 1️⃣ Vector Search (Semantic)
90
+ β”‚ └── ChromaDB + BGE-M3 (100+ languages)
91
+ β”‚
92
+ β”œβ”€β”€ 2️⃣ Keyword Search (Lexical)
93
+ β”‚ └── BM25 lexical matching
94
+ β”‚
95
+ β”œβ”€β”€ 3️⃣ Reciprocal Rank Fusion (RRF)
96
+ β”‚ └── Merges semantic + lexical results
97
+ β”‚
98
+ β”œβ”€β”€ 4️⃣ Reranking (Cross-Encoder)
99
+ β”‚ └── BGE-Reranker-v2-m3 (relevance reordering)
100
+ β”‚
101
+ └── 5️⃣ LLM with optimized context
102
+ └── Groq + Llama 3.3 70B (streaming)
103
+ ```
104
+
105
+ ### Available Embedding Models
106
+
107
+ | Model | Languages | Size | Performance |
108
+ |-------|-----------|------|-------------|
109
+ | 🌍 **BGE-M3** (Recommended) | 100+ | ~2.3 GB | ⭐⭐⭐⭐⭐ |
110
+ | πŸš€ **GTE Multilingual** | 70+ | ~580 MB | ⭐⭐⭐⭐ |
111
+ | πŸ“ **Multilingual E5** | 100+ | ~1.1 GB | ⭐⭐⭐⭐ |
112
+ | ⚑ **MiniLM v2** | English | ~90 MB | ⭐⭐⭐ |
113
+
114
+ ---
115
+
116
+ ## πŸ› οΈ Tech Stack
117
+
118
+ | Layer | Technology |
119
+ |-------|------------|
120
+ | **Backend** | FastAPI + Uvicorn |
121
+ | **Frontend** | HTML5 + CSS3 + JavaScript (Claude-style) |
122
+ | **LLM** | Groq API (Llama 3.3 70B / Llama 3.1 8B) |
123
+ | **RAG** | ChromaDB + BM25 + BGE-M3 + Reranker + RRF |
124
+ | **Database** | SQLite + SQLAlchemy |
125
+ | **Auth** | JWT + BCrypt + Google OAuth |
126
+ | **Email** | FastAPI-Mail + Gmail SMTP |
127
+ | **Vision AI** | Groq + Llama 4 Scout |
128
+ | **Embeddings** | HuggingFace (BGE-M3, GTE, E5, MiniLM) |
129
+ | **Export** | FPDF2, python-docx |
130
+ | **Job Search** | JSearch API (RapidAPI) |
131
+
132
+ ---
133
+
134
+ ## οΏ½ Installation & Setup
135
+
136
+ ### 1. Clone the repository
137
+
138
+ ```bash
139
+ git clone https://github.com/Nicola671/CareerAI.git
140
+ cd CareerAI
141
+ ```
142
+
143
+ ### 2. Create virtual environment
144
+
145
+ ```bash
146
+ python -m venv venv
147
+
148
+ # Windows
149
+ venv\Scripts\activate
150
+
151
+ # Mac/Linux
152
+ source venv/bin/activate
153
+ ```
154
+
155
+ ### 3. Install dependencies
156
+
157
+ ```bash
158
+ pip install -r requirements.txt
159
+ ```
160
+
161
+ ### 4. Configure environment variables
162
+
163
+ Create a `.env` file in the project root:
164
+
165
+ ```env
166
+ # Groq API Key (free from console.groq.com)
167
+ GROQ_API_KEY=your_api_key_here
168
+
169
+ # JWT Secret (change to something random)
170
+ SECRET_KEY=your_very_long_random_secret_key
171
+
172
+ # Email for password recovery (optional)
173
+ MAIL_USERNAME=your_email@gmail.com
174
+ MAIL_PASSWORD=your_app_password
175
+ MAIL_FROM=your_email@gmail.com
176
+
177
+ # JSearch API Key for job search (optional)
178
+ JSEARCH_API_KEY=your_jsearch_key
179
+ ```
180
+
181
+ ### 5. Get Groq API Key (FREE)
182
+
183
+ 1. Go to [console.groq.com](https://console.groq.com)
184
+ 2. Create a free account
185
+ 3. Go to "API Keys" β†’ "Create API Key"
186
+ 4. Copy your key (starts with `gsk_...`)
187
+ 5. Paste it in your `.env` file
188
+
189
+ ### 6. Run
190
+
191
+ ```bash
192
+ uvicorn api:app --reload --port 8000
193
+ ```
194
+
195
+ Open **http://localhost:8000** in your browser πŸš€
196
+
197
+ ---
198
+
199
+ ## οΏ½ API Endpoints (22 routes)
200
+
201
+ | Group | Endpoints | Description |
202
+ |-------|-----------|-------------|
203
+ | 🏠 Frontend | `GET /` | Serves the web app |
204
+ | βš™οΈ Config | `GET /api/status`, `POST /api/config` | Status & configuration |
205
+ | πŸ’¬ Chat | `POST /api/chat`, `POST /api/chat/stream` | Chat with/without streaming |
206
+ | πŸ“„ Docs | `POST /api/documents`, `GET /api/documents`, `DELETE /api/documents/{file}` | Document CRUD |
207
+ | πŸ“€ Export | `POST /api/export`, `POST /api/export/conversation` | Export to PDF/DOCX/HTML/TXT |
208
+ | πŸ’Ό Jobs | `GET /api/jobs` | Job search |
209
+ | πŸ“Š Dashboard | `GET /api/dashboard` | AI-powered profile analysis |
210
+ | πŸ” Auth | `POST /api/auth/register`, `POST /api/auth/login`, `GET /api/auth/me` | Full authentication |
211
+
212
+ Interactive API docs: **http://localhost:8000/docs** (Swagger UI)
213
+
214
+ ---
215
+
216
+ ## πŸ“Š Project Metrics
217
+
218
+ | Metric | Value |
219
+ |--------|-------|
220
+ | Lines of code | 8,400+ |
221
+ | API Endpoints | 22 |
222
+ | Frontend functions | 80+ |
223
+ | Backend functions | 60+ |
224
+ | Assistant modes | 5 |
225
+ | Export formats | 4 (PDF, DOCX, HTML, TXT) |
226
+ | Upload formats | 7 (PDF, DOCX, TXT, JPG, PNG, WEBP) |
227
+ | Embedding models | 4 |
228
+
229
+ ---
230
+
231
+ ---
232
+
233
+ # πŸ‡¦πŸ‡· EspaΓ±ol
234
+
235
+ ## 🎬 ¿Qué es CareerAI?
236
+
237
+ **CareerAI** es una aplicaciΓ³n web de inteligencia artificial que te ayuda a impulsar tu carrera profesional. SubΓ­s tus documentos (CV, cartas, certificados) y el asistente los analiza con IA avanzada (RAG) para darte recomendaciones personalizadas, generar documentos profesionales y prepararte para entrevistas.
238
+
239
+ ### ✨ Todo esto 100% gratis · Sin alucinaciones · Basado en tus documentos reales
240
+
241
+ ---
242
+
243
+ ## πŸš€ Funcionalidades Principales
244
+
245
+ ### πŸ€– Modelos de IA Personalizados
246
+
247
+ | Modelo | Motor | DescripciΓ³n |
248
+ |--------|-------|-------------|
249
+ | 🧠 **CareerAI Pro** | Llama 3.3 70B | MÑxima calidad · Respuestas detalladas |
250
+ | ⚑ **CareerAI Flash** | Llama 3.1 8B | Ultra rÑpido · Respuestas al instante |
251
+
252
+ ### πŸ’¬ 5 Modos del Asistente
253
+
254
+ | Modo | QuΓ© hace |
255
+ |------|----------|
256
+ | πŸ’¬ **Chat General** | ConsultΓ‘ lo que quieras sobre tu carrera profesional |
257
+ | 🎯 **Job Match** | AnalizÑ tu compatibilidad con ofertas de trabajo (% de match) |
258
+ | βœ‰οΈ **Cover Letter** | GenerΓ‘ cartas de presentaciΓ³n personalizadas usando tu CV real |
259
+ | πŸ“ˆ **Skills Gap** | IdentificΓ‘ habilidades faltantes + roadmap para mejorar |
260
+ | 🎀 **Entrevista** | SimulÑ entrevistas con preguntas técnicas y método STAR |
261
+
262
+ ### πŸ“‹ Lista Completa de CaracterΓ­sticas
263
+
264
+ | Feature | DescripciΓ³n |
265
+ |---------|-------------|
266
+ | πŸ“„ **Multi-formato** | Soporta PDF, DOCX, TXT, imΓ‘genes (JPG, PNG, WebP) |
267
+ | πŸ–ΌοΈ **Vision AI** | Lectura inteligente de PDFs escaneados y fotos de documentos |
268
+ | ⚑ **Streaming** | Respuestas en tiempo real token por token |
269
+ | πŸ“€ **Export Premium** | ExportΓ‘ a PDF, DOCX, HTML, TXT con formato profesional |
270
+ | πŸ“Š **Dashboard** | GrΓ‘ficos de skills, timeline profesional, insights de IA |
271
+ | πŸ” **Auth Completo** | Registro, login, Google OAuth, reset de contraseΓ±a |
272
+ | πŸ’Ό **BΓΊsqueda de Empleo** | IntegraciΓ³n con LinkedIn, Indeed, Glassdoor via JSearch |
273
+ | 🎨 **UI Premium** | Diseño tipo Claude/ChatGPT con dark mode |
274
+ | πŸ“± **Responsive** | Funciona en desktop, tablet y celular |
275
+ | πŸ’Ύ **Persistencia** | Historial de chats sincronizado en la nube |
276
+
277
+ ---
278
+
279
+ ## 🧠 Pipeline RAG v2.0
280
+
281
+ CareerAI usa un pipeline de retrieval avanzado que combina mΓΊltiples tΓ©cnicas para encontrar la informaciΓ³n mΓ‘s relevante de tus documentos:
282
+
283
+ ```
284
+ πŸ“ Query del usuario
285
+ β”‚
286
+ β”œβ”€β”€ 1️⃣ Vector Search (SemΓ‘ntico)
287
+ β”‚ └── ChromaDB + BGE-M3 (100+ idiomas)
288
+ β”‚
289
+ β”œβ”€β”€ 2️⃣ Keyword Search (LΓ©xico)
290
+ β”‚ └── BM25 lexical matching
291
+ β”‚
292
+ β”œβ”€β”€ 3️⃣ Reciprocal Rank Fusion (RRF)
293
+ β”‚ └── Combina resultados semΓ‘nticos + lΓ©xicos
294
+ β”‚
295
+ β”œβ”€β”€ 4️⃣ Reranking (Cross-Encoder)
296
+ β”‚ └── BGE-Reranker-v2-m3 (reordena por relevancia)
297
+ β”‚
298
+ └── 5️⃣ LLM con contexto optimizado
299
+ └── Groq + Llama 3.3 70B (streaming)
300
+ ```
301
+
302
+ ---
303
+
304
+ ## πŸ› οΈ Stack TecnolΓ³gico
305
+
306
+ | Capa | TecnologΓ­a |
307
+ |------|------------|
308
+ | **Backend** | FastAPI + Uvicorn |
309
+ | **Frontend** | HTML5 + CSS3 + JavaScript (estilo Claude) |
310
+ | **LLM** | Groq API (Llama 3.3 70B / Llama 3.1 8B) |
311
+ | **RAG** | ChromaDB + BM25 + BGE-M3 + Reranker + RRF |
312
+ | **Base de datos** | SQLite + SQLAlchemy |
313
+ | **Auth** | JWT + BCrypt + Google OAuth |
314
+ | **Email** | FastAPI-Mail + Gmail SMTP |
315
+ | **Vision AI** | Groq + Llama 4 Scout |
316
+ | **Embeddings** | HuggingFace (BGE-M3, GTE, E5, MiniLM) |
317
+ | **ExportaciΓ³n** | FPDF2, python-docx |
318
+ | **BΓΊsqueda** | JSearch API (RapidAPI) |
319
+
320
+ ---
321
+
322
+ ## πŸš€ InstalaciΓ³n y Setup
323
+
324
+ ### 1. Clonar el repositorio
325
+
326
+ ```bash
327
+ git clone https://github.com/Nicola671/CareerAI.git
328
+ cd CareerAI
329
+ ```
330
+
331
+ ### 2. Crear entorno virtual
332
+
333
+ ```bash
334
+ python -m venv venv
335
+
336
+ # Windows
337
+ venv\Scripts\activate
338
+
339
+ # Mac/Linux
340
+ source venv/bin/activate
341
+ ```
342
+
343
+ ### 3. Instalar dependencias
344
+
345
+ ```bash
346
+ pip install -r requirements.txt
347
+ ```
348
+
349
+ ### 4. Configurar variables de entorno
350
+
351
+ CreΓ‘ un archivo `.env` en la raΓ­z del proyecto:
352
+
353
+ ```env
354
+ # Groq API Key (gratis desde console.groq.com)
355
+ GROQ_API_KEY=tu_api_key_aqui
356
+
357
+ # JWT Secret (cambiΓ‘ por algo aleatorio)
358
+ SECRET_KEY=tu_secret_key_muy_larga_y_aleatoria
359
+
360
+ # Email para recuperaciΓ³n de contraseΓ±a (opcional)
361
+ MAIL_USERNAME=tu_email@gmail.com
362
+ MAIL_PASSWORD=tu_app_password
363
+ MAIL_FROM=tu_email@gmail.com
364
+
365
+ # JSearch API Key para bΓΊsqueda de empleos (opcional)
366
+ JSEARCH_API_KEY=tu_jsearch_key
367
+ ```
368
+
369
+ ### 5. Obtener API Key de Groq (GRATIS)
370
+
371
+ 1. AndΓ‘ a [console.groq.com](https://console.groq.com)
372
+ 2. CreΓ‘ una cuenta gratis
373
+ 3. AndΓ‘ a "API Keys" β†’ "Create API Key"
374
+ 4. CopiΓ‘ tu key (empieza con `gsk_...`)
375
+ 5. Pegala en el archivo `.env`
376
+
377
+ ### 6. Ejecutar
378
+
379
+ ```bash
380
+ uvicorn api:app --reload --port 8000
381
+ ```
382
+
383
+ AbrΓ­ **http://localhost:8000** en tu navegador πŸš€
384
+
385
+ ---
386
+
387
+ ## οΏ½ Estructura del Proyecto
388
+
389
+ ```
390
+ CareerAI/
391
+ β”œβ”€β”€ api.py # πŸš€ Backend FastAPI (22 endpoints)
392
+ β”œβ”€β”€ requirements.txt # πŸ“¦ Dependencias Python
393
+ β”œβ”€β”€ .env # πŸ” Variables de entorno (NO se sube a Git)
394
+ β”œβ”€β”€ README.md # πŸ“– Este archivo
395
+ β”‚
396
+ β”œβ”€β”€ frontend/ # 🎨 UI tipo Claude
397
+ β”‚ β”œβ”€β”€ index.html # Estructura HTML
398
+ β”‚ β”œβ”€β”€ app.js # LΓ³gica completa (1,842 lΓ­neas)
399
+ β”‚ β”œβ”€β”€ styles.css # Sistema de diseΓ±o (1,695 lΓ­neas)
400
+ β”‚ β”œβ”€β”€ icon-pro.png # 🧠 Icono CareerAI Pro
401
+ β”‚ β”œβ”€β”€ icon-flash.png # ⚑ Icono CareerAI Flash
402
+ β”‚ └── favicon.png # Favicon
403
+ β”‚
404
+ β”œβ”€β”€ src/ # 🧠 Core Engine
405
+ β”‚ β”œβ”€β”€ career_assistant.py # Motor IA con 5 modos especializados
406
+ β”‚ β”œβ”€β”€ rag_engine.py # RAG v2.0 (Hybrid + Reranking + RRF)
407
+ β”‚ β”œβ”€β”€ document_processor.py # Procesador multi-formato + Vision AI
408
+ β”‚ β”œβ”€β”€ profile_extractor.py # Extractor de perfil para dashboard
409
+ β”‚ β”œβ”€β”€ exporter.py # ExportaciΓ³n PDF/DOCX/HTML/TXT
410
+ β”‚ β”œβ”€β”€ auth.py # AutenticaciΓ³n (JWT + Google OAuth)
411
+ β”‚ └── models.py # Modelos SQLAlchemy (User, Conversation)
412
+ β”‚
413
+ └── data/ # πŸ’Ύ Datos (no se suben a Git)
414
+ β”œβ”€β”€ uploads/ # Documentos subidos
415
+ └── vectordb/ # ChromaDB persistencia
416
+ ```
417
+
418
+ ---
419
+
420
+ ## πŸ†“ ΒΏPor quΓ© es 100% Gratis? / Why is it 100% Free?
421
+
422
+ | Component | Cost |
423
+ |-----------|------|
424
+ | Groq API (Llama 3.3 70B) | βœ… Free (generous rate limits) |
425
+ | BGE-M3 Embeddings | βœ… Free (runs locally) |
426
+ | BGE-Reranker-v2-m3 | βœ… Free (runs locally) |
427
+ | BM25 Keyword Search | βœ… Free (runs locally) |
428
+ | ChromaDB Vector Store | βœ… Free (runs locally) |
429
+ | FastAPI + Frontend | βœ… Free (open source) |
430
+ | SQLite Database | βœ… Free (runs locally) |
431
+
432
+ ---
433
+
434
+ ## 🀝 Contributing
435
+
436
+ Contributions are welcome! If you want to improve CareerAI:
437
+
438
+ 1. Fork the repository
439
+ 2. Create a branch: `git checkout -b feature/new-feature`
440
+ 3. Commit: `git commit -m "Add new feature"`
441
+ 4. Push: `git push origin feature/new-feature`
442
+ 5. Open a Pull Request
443
+
444
+ ---
445
+
446
+ ## πŸ“„ License
447
+
448
+ This project is licensed under the MIT License. Feel free to use, modify, and distribute it.
449
+
450
+ ---
451
+
452
+ ## πŸ‘¨β€πŸ’» Author / Autor
453
+
454
+ <p align="center">
455
+ <strong>NicolΓ‘s Medina</strong>
456
+ </p>
457
+
458
+ <p align="center">
459
+ <a href="https://github.com/Nicola671">
460
+ <img src="https://img.shields.io/badge/GitHub-Nicola671-181717?logo=github&logoColor=white&style=for-the-badge" alt="GitHub">
461
+ </a>
462
+ &nbsp;
463
+ <a href="https://www.linkedin.com/in/nicolΓ‘s-medina-33663237a">
464
+ <img src="https://img.shields.io/badge/LinkedIn-NicolΓ‘s_Medina-0A66C2?logo=linkedin&logoColor=white&style=for-the-badge" alt="LinkedIn">
465
+ </a>
466
+ &nbsp;
467
+ <a href="mailto:nicolasmedinae06@gmail.com">
468
+ <img src="https://img.shields.io/badge/Email-nicolasmedinae06@gmail.com-EA4335?logo=gmail&logoColor=white&style=for-the-badge" alt="Email">
469
+ </a>
470
+ </p>
471
+
472
+ <br>
473
+
474
+ <p align="center">
475
+ <em>If this project helped you, consider giving it a ⭐ on GitHub!</em><br>
476
+ <em>Si este proyecto te ayudó, ‘considerÑ darle una ⭐ en GitHub!</em>
477
+ </p>
478
+
479
+ <br>
480
+
481
+ <p align="center">
482
+ <strong>CareerAI v1.0</strong> β€” FastAPI + RAG v2.0 + Groq<br>
483
+ <em>Made with ❀️ in Argentina πŸ‡¦πŸ‡·</em>
484
+ </p>
api.py ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ πŸš€ CareerAI β€” FastAPI Backend
3
+ Connects the Claude-style frontend with the existing RAG + Groq + ChromaDB engine.
4
+ Run: uvicorn api:app --reload --port 8000
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import json
10
+ import asyncio
11
+ from datetime import datetime
12
+ from typing import List, Dict, Optional
13
+ from contextlib import asynccontextmanager
14
+ from dotenv import load_dotenv
15
+
16
+ # Load .env file
17
+ load_dotenv()
18
+
19
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Depends
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.staticfiles import StaticFiles
22
+ from fastapi.responses import (
23
+ StreamingResponse,
24
+ FileResponse,
25
+ Response,
26
+ JSONResponse,
27
+ )
28
+ from pydantic import BaseModel
29
+
30
+ # Add project root to path
31
+ sys.path.insert(0, os.path.dirname(__file__))
32
+
33
+ from src.rag_engine import RAGEngine, EMBEDDING_MODELS
34
+ from src.career_assistant import CareerAssistant
35
+ from src.document_processor import DocumentProcessor
36
+ from src.exporter import (
37
+ export_to_pdf,
38
+ export_to_docx,
39
+ export_to_html,
40
+ export_to_txt,
41
+ get_smart_filename,
42
+ export_conversation_to_pdf,
43
+ export_conversation_to_docx,
44
+ export_conversation_to_html,
45
+ )
46
+ from src.profile_extractor import (
47
+ extract_profile_from_text,
48
+ generate_dashboard_insights,
49
+ skills_by_category,
50
+ skills_by_level,
51
+ experience_for_timeline,
52
+ )
53
+
54
+ # Import Auth routers
55
+ from src.auth import router as auth_router, conv_router, get_user_or_session_id
56
+
57
+
58
+ # ======================== STATE ========================
59
+ class AppState:
60
+ """Global application state (shared across requests)."""
61
+
62
+ def __init__(self):
63
+ self.rag_engine: Optional[RAGEngine] = None
64
+ self.assistant: Optional[CareerAssistant] = None
65
+ self.api_key: str = ""
66
+ self.model: str = "llama-3.3-70b-versatile"
67
+ self.api_configured: bool = False
68
+ # Embedding model: configurable via env var for production (e.g. "gte-multilingual")
69
+ self.embedding_model: str = os.environ.get("EMBEDDING_MODEL", "bge-m3")
70
+ # Reranking: disable in production to save RAM (set ENABLE_RERANKING=false)
71
+ self.enable_reranking: bool = os.environ.get("ENABLE_RERANKING", "true").lower() in ("true", "1", "yes")
72
+ self.enable_hybrid: bool = True
73
+
74
+ def get_rag(self) -> RAGEngine:
75
+ if self.rag_engine is None:
76
+ self.rag_engine = RAGEngine(
77
+ embedding_key=self.embedding_model,
78
+ enable_reranking=self.enable_reranking,
79
+ enable_hybrid=self.enable_hybrid,
80
+ )
81
+ return self.rag_engine
82
+
83
+ def reset_rag(self):
84
+ """Reset RAG engine (e.g. when embedding model changes)."""
85
+ self.rag_engine = None
86
+
87
+ def init_assistant(self, api_key: str, model: str):
88
+ self.assistant = CareerAssistant(api_key=api_key, model=model)
89
+ self.api_key = api_key
90
+ self.model = model
91
+ self.api_configured = True
92
+
93
+
94
+ state = AppState()
95
+
96
+
97
+ # ======================== AUTO-LOAD API KEY ========================
98
+ def _auto_load_api_key():
99
+ """Try to load API key from environment or secrets.toml."""
100
+ # 1. Environment variable
101
+ key = os.environ.get("GROQ_API_KEY", "")
102
+ if key:
103
+ return key
104
+
105
+ # 2. .streamlit/secrets.toml
106
+ try:
107
+ import re as _re
108
+ secrets_path = os.path.join(os.path.dirname(__file__), ".streamlit", "secrets.toml")
109
+ if os.path.exists(secrets_path):
110
+ with open(secrets_path, "r", encoding="utf-8") as f:
111
+ for line in f:
112
+ line = line.strip()
113
+ if line.startswith("GROQ_API_KEY"):
114
+ m = _re.search(r'"(.+?)"', line)
115
+ if m:
116
+ return m.group(1)
117
+ except Exception:
118
+ pass
119
+
120
+ return ""
121
+
122
+
123
+ # ======================== STARTUP ========================
124
+ @asynccontextmanager
125
+ async def lifespan(app: FastAPI):
126
+ """Initialize on startup."""
127
+ # Auto-configure API key
128
+ key = _auto_load_api_key()
129
+ if key:
130
+ try:
131
+ state.init_assistant(key, state.model)
132
+ print(f"βœ… Auto-connected with API key (model: {state.model})")
133
+ except Exception as e:
134
+ print(f"⚠️ Could not auto-connect: {e}")
135
+
136
+ # Pre-initialize RAG engine
137
+ try:
138
+ rag = state.get_rag()
139
+ stats = rag.get_stats()
140
+ print(f"βœ… RAG engine ready ({stats['total_documents']} docs, {stats['total_chunks']} chunks)")
141
+ except Exception as e:
142
+ print(f"⚠️ RAG engine init: {e}")
143
+
144
+ yield
145
+ print("πŸ”΄ CareerAI API shutting down")
146
+
147
+
148
+ # ======================== APP ========================
149
+ app = FastAPI(
150
+ title="CareerAI API",
151
+ description="Backend API for CareerAI Assistant",
152
+ version="1.0.0",
153
+ docs_url="/docs",
154
+ redoc_url=None,
155
+ lifespan=lifespan,
156
+ )
157
+
158
+ # Register specialized routers
159
+ app.include_router(auth_router)
160
+ app.include_router(conv_router)
161
+
162
+ # CORS β€” allow frontend
163
+ app.add_middleware(
164
+ CORSMiddleware,
165
+ allow_origins=["*"],
166
+ allow_credentials=True,
167
+ allow_methods=["*"],
168
+ allow_headers=["*"],
169
+ )
170
+
171
+ # Serve frontend static files
172
+ frontend_dir = os.path.join(os.path.dirname(__file__), "frontend")
173
+ if os.path.isdir(frontend_dir):
174
+ app.mount("/static", StaticFiles(directory=frontend_dir), name="static")
175
+
176
+
177
+ # ======================== MODELS ========================
178
+ class ChatRequest(BaseModel):
179
+ query: str
180
+ chat_history: List[Dict[str, str]] = []
181
+ mode: str = "auto" # "auto", "general", "job_match", "cover_letter", "skills_gap", "interview"
182
+
183
+
184
+ class ConfigRequest(BaseModel):
185
+ api_key: str
186
+ model: str = "llama-3.3-70b-versatile"
187
+
188
+
189
+ class RAGConfigRequest(BaseModel):
190
+ embedding_model: str = "bge-m3"
191
+ enable_reranking: bool = True
192
+ enable_hybrid: bool = True
193
+
194
+
195
+ class ExportRequest(BaseModel):
196
+ content: str
197
+ format: str = "pdf" # "pdf", "docx", "html", "txt"
198
+
199
+
200
+ class ConversationExportRequest(BaseModel):
201
+ messages: List[Dict[str, str]]
202
+ format: str = "pdf"
203
+
204
+
205
+ # ======================== ROUTES: FRONTEND ========================
206
+ @app.get("/")
207
+ async def serve_frontend():
208
+ """Serve the main frontend page."""
209
+ index_path = os.path.join(frontend_dir, "index.html")
210
+ if os.path.exists(index_path):
211
+ return FileResponse(index_path)
212
+ return {"message": "CareerAI API is running. Frontend not found at /frontend/"}
213
+
214
+
215
+ # ======================== ROUTES: CONFIG ========================
216
+ @app.get("/api/status")
217
+ async def get_status(user_id: str = Depends(get_user_or_session_id)):
218
+ """Get current API configuration status."""
219
+ rag = state.get_rag()
220
+ stats = rag.get_stats(user_id=user_id)
221
+ return {
222
+ "api_configured": state.api_configured,
223
+ "model": state.model,
224
+ "embedding_model": state.embedding_model,
225
+ "enable_reranking": state.enable_reranking,
226
+ "enable_hybrid": state.enable_hybrid,
227
+ "documents": stats["documents"],
228
+ "total_chunks": stats["total_chunks"],
229
+ "total_documents": stats["total_documents"],
230
+ }
231
+
232
+
233
+ # ======================== ROUTES: JOB SEARCH ========================
234
+ JSEARCH_API_KEY = os.environ.get("JSEARCH_API_KEY", "")
235
+
236
+ @app.get("/api/jobs")
237
+ async def search_jobs(
238
+ query: str = Query(..., description="Job search terms, e.g. 'Python developer remote'"),
239
+ country: str = Query("worldwide", description="Country code, e.g. 'ar', 'es', 'us'"),
240
+ date_posted: str = Query("month", description="Filter: all, today, 3days, week, month"),
241
+ employment_type: str = Query("", description="FULLTIME, PARTTIME, CONTRACTOR, INTERN (comma separated)"),
242
+ remote_only: bool = Query(False, description="Only remote jobs"),
243
+ num_pages: int = Query(1, description="Number of result pages (1 page = 10 jobs)"),
244
+ ):
245
+ """Search worldwide job listings via JSearch (LinkedIn, Indeed, Glassdoor, etc.)."""
246
+ import httpx
247
+
248
+ headers = {
249
+ "x-rapidapi-host": "jsearch.p.rapidapi.com",
250
+ "x-rapidapi-key": JSEARCH_API_KEY,
251
+ }
252
+
253
+ params = {
254
+ "query": query,
255
+ "page": "1",
256
+ "num_pages": str(num_pages),
257
+ "date_posted": date_posted,
258
+ }
259
+ if country and country != "worldwide":
260
+ params["country"] = country
261
+ if remote_only:
262
+ params["remote_jobs_only"] = "true"
263
+ if employment_type:
264
+ params["employment_types"] = employment_type
265
+
266
+ try:
267
+ async with httpx.AsyncClient(timeout=15.0) as client:
268
+ resp = await client.get(
269
+ "https://jsearch.p.rapidapi.com/search",
270
+ headers=headers,
271
+ params=params,
272
+ )
273
+ resp.raise_for_status()
274
+ data = resp.json()
275
+ except Exception as e:
276
+ raise HTTPException(status_code=502, detail=f"Error consultando JSearch: {str(e)}")
277
+
278
+ jobs = data.get("data", [])
279
+ formatted = []
280
+ for j in jobs:
281
+ salary_min = j.get("job_min_salary")
282
+ salary_max = j.get("job_max_salary")
283
+ salary_currency = j.get("job_salary_currency", "")
284
+ salary_period = j.get("job_salary_period", "")
285
+ if salary_min and salary_max:
286
+ salary_str = f"{salary_currency} {int(salary_min):,} – {int(salary_max):,} / {salary_period}"
287
+ elif salary_min:
288
+ salary_str = f"{salary_currency} {int(salary_min):,}+ / {salary_period}"
289
+ else:
290
+ salary_str = None
291
+
292
+ formatted.append({
293
+ "id": j.get("job_id", ""),
294
+ "title": j.get("job_title", ""),
295
+ "company": j.get("employer_name", ""),
296
+ "company_logo": j.get("employer_logo", ""),
297
+ "location": f"{j.get('job_city', '') or ''} {j.get('job_state', '') or ''} {j.get('job_country', '') or ''}".strip(),
298
+ "employment_type": j.get("job_employment_type", ""),
299
+ "is_remote": j.get("job_is_remote", False),
300
+ "description_snippet": (j.get("job_description", "")[:220] + "…") if j.get("job_description") else "",
301
+ "salary": salary_str,
302
+ "posted_at": j.get("job_posted_at_datetime_utc", ""),
303
+ "apply_link": j.get("job_apply_link", "#"),
304
+ "publisher": j.get("job_publisher", ""),
305
+ })
306
+
307
+ return {"total": len(formatted), "jobs": formatted}
308
+
309
+
310
+ @app.post("/api/config")
311
+ async def configure_api(config: ConfigRequest):
312
+ """Configure the Groq API key and model."""
313
+ try:
314
+ state.init_assistant(config.api_key, config.model)
315
+ return {
316
+ "success": True,
317
+ "message": f"Conectado con {config.model}",
318
+ "model": config.model,
319
+ }
320
+ except Exception as e:
321
+ raise HTTPException(status_code=400, detail=str(e))
322
+
323
+
324
+ @app.post("/api/config/rag")
325
+ async def configure_rag(config: RAGConfigRequest):
326
+ """Update RAG engine settings."""
327
+ changed = False
328
+ if config.embedding_model != state.embedding_model:
329
+ state.embedding_model = config.embedding_model
330
+ changed = True
331
+ if config.enable_reranking != state.enable_reranking:
332
+ state.enable_reranking = config.enable_reranking
333
+ changed = True
334
+ if config.enable_hybrid != state.enable_hybrid:
335
+ state.enable_hybrid = config.enable_hybrid
336
+ changed = True
337
+
338
+ if changed:
339
+ state.reset_rag()
340
+
341
+ rag = state.get_rag()
342
+ stats = rag.get_stats()
343
+ return {
344
+ "success": True,
345
+ "embedding_model": state.embedding_model,
346
+ "enable_reranking": state.enable_reranking,
347
+ "enable_hybrid": state.enable_hybrid,
348
+ "stats": stats,
349
+ }
350
+
351
+
352
+ @app.get("/api/models")
353
+ async def list_models():
354
+ """List available LLM models."""
355
+ models = {
356
+ "llama-3.3-70b-versatile": {"name": "CareerAI Pro", "description": "Recomendado Β· MΓ‘xima calidad"},
357
+ "llama-3.1-8b-instant": {"name": "CareerAI Flash", "description": "Ultra rΓ‘pido Β· Respuestas al instante"},
358
+ }
359
+ return {"models": models, "current": state.model}
360
+
361
+
362
+ @app.get("/api/embedding-models")
363
+ async def list_embedding_models():
364
+ """List available embedding models."""
365
+ result = {}
366
+ for key, info in EMBEDDING_MODELS.items():
367
+ result[key] = {
368
+ "display": info["display"],
369
+ "description": info.get("description", ""),
370
+ "size": info.get("size", ""),
371
+ "languages": info.get("languages", ""),
372
+ "performance": info.get("performance", ""),
373
+ }
374
+ return {"models": result, "current": state.embedding_model}
375
+
376
+
377
+ @app.post("/api/model")
378
+ async def change_model(model: str = Query(...)):
379
+ """Change the active LLM model."""
380
+ if not state.api_configured:
381
+ raise HTTPException(status_code=400, detail="API key not configured")
382
+ try:
383
+ state.init_assistant(state.api_key, model)
384
+ return {"success": True, "model": model}
385
+ except Exception as e:
386
+ raise HTTPException(status_code=400, detail=str(e))
387
+
388
+
389
+ # ======================== ROUTES: CHAT ========================
390
+ @app.post("/api/chat")
391
+ async def chat(request: ChatRequest, user_id: str = Depends(get_user_or_session_id)):
392
+ """Send a message and get AI response (non-streaming)."""
393
+ if not state.api_configured:
394
+ raise HTTPException(
395
+ status_code=400,
396
+ detail="API key not configured. Use POST /api/config first.",
397
+ )
398
+
399
+ # Auto-detect mode
400
+ mode = request.mode
401
+ if mode == "auto":
402
+ mode = state.assistant.detect_mode(request.query)
403
+
404
+ # Get RAG context
405
+ rag = state.get_rag()
406
+ context = rag.get_context(request.query, k=8, user_id=user_id)
407
+
408
+ # Get response
409
+ try:
410
+ response = state.assistant.chat(
411
+ query=request.query,
412
+ context=context,
413
+ chat_history=request.chat_history,
414
+ mode=mode,
415
+ )
416
+ return {
417
+ "response": response,
418
+ "mode": mode,
419
+ "model": state.model,
420
+ }
421
+ except Exception as e:
422
+ raise HTTPException(status_code=500, detail=str(e))
423
+
424
+
425
+ @app.post("/api/chat/stream")
426
+ async def chat_stream(request: ChatRequest, user_id: str = Depends(get_user_or_session_id)):
427
+ """Send a message and get AI response via Server-Sent Events (streaming)."""
428
+ if not state.api_configured:
429
+ raise HTTPException(
430
+ status_code=400,
431
+ detail="API key not configured",
432
+ )
433
+
434
+ # Auto-detect mode
435
+ mode = request.mode
436
+ if mode == "auto":
437
+ mode = state.assistant.detect_mode(request.query)
438
+
439
+ # Get RAG context
440
+ rag = state.get_rag()
441
+ context = rag.get_context(request.query, k=8, user_id=user_id)
442
+
443
+ async def event_generator():
444
+ """Stream response as SSE."""
445
+ try:
446
+ # Send mode info first
447
+ yield f"data: {json.dumps({'type': 'mode', 'mode': mode})}\n\n"
448
+
449
+ # Stream tokens
450
+ for chunk in state.assistant.stream_chat(
451
+ query=request.query,
452
+ context=context,
453
+ chat_history=request.chat_history,
454
+ mode=mode,
455
+ ):
456
+ yield f"data: {json.dumps({'type': 'token', 'content': chunk})}\n\n"
457
+
458
+ # Done signal
459
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
460
+
461
+ except Exception as e:
462
+ yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
463
+
464
+ return StreamingResponse(
465
+ event_generator(),
466
+ media_type="text/event-stream",
467
+ headers={
468
+ "Cache-Control": "no-cache",
469
+ "Connection": "keep-alive",
470
+ "X-Accel-Buffering": "no",
471
+ },
472
+ )
473
+
474
+
475
+ # ======================== ROUTES: DOCUMENTS ========================
476
+ @app.post("/api/documents/upload")
477
+ async def upload_document(
478
+ file: UploadFile = File(...),
479
+ doc_type: str = Form("cv"),
480
+ user_id: str = Depends(get_user_or_session_id)
481
+ ):
482
+ """Upload and process a document through the RAG pipeline."""
483
+ # Validate file type
484
+ valid_extensions = [".pdf", ".txt", ".docx", ".doc", ".jpg", ".jpeg", ".png", ".webp"]
485
+ ext = os.path.splitext(file.filename)[1].lower()
486
+ if ext not in valid_extensions:
487
+ raise HTTPException(
488
+ status_code=400,
489
+ detail=f"Unsupported file type: {ext}. Supported: {', '.join(valid_extensions)}",
490
+ )
491
+
492
+ # Check if already indexed
493
+ rag = state.get_rag()
494
+ existing_docs = rag.get_document_list(user_id=user_id)
495
+ if file.filename in existing_docs:
496
+ return {
497
+ "success": True,
498
+ "already_indexed": True,
499
+ "message": f"{file.filename} ya estΓ‘ indexado",
500
+ "filename": file.filename,
501
+ }
502
+
503
+ # Save file
504
+ upload_dir = os.path.join(os.path.dirname(__file__), "data", "uploads")
505
+ os.makedirs(upload_dir, exist_ok=True)
506
+ file_path = os.path.join(upload_dir, file.filename)
507
+
508
+ with open(file_path, "wb") as f:
509
+ content = await file.read()
510
+ f.write(content)
511
+
512
+ # Extract text
513
+ try:
514
+ api_key = state.api_key if state.api_configured else ""
515
+ text = DocumentProcessor.extract_text(file_path, groq_api_key=api_key)
516
+ if not text.strip():
517
+ raise ValueError("No se pudo extraer texto del documento")
518
+
519
+ # Chunk
520
+ chunks = DocumentProcessor.chunk_text(text, chunk_size=400, overlap=80)
521
+
522
+ # Key info
523
+ info = DocumentProcessor.extract_key_info(text)
524
+
525
+ # Add to RAG
526
+ metadata = {
527
+ "filename": file.filename,
528
+ "doc_type": doc_type,
529
+ "upload_date": datetime.now().isoformat(),
530
+ "word_count": str(info["word_count"]),
531
+ }
532
+ num_chunks = rag.add_document(chunks, metadata, user_id=user_id)
533
+
534
+ return {
535
+ "success": True,
536
+ "already_indexed": False,
537
+ "filename": file.filename,
538
+ "doc_type": doc_type,
539
+ "text_length": len(text),
540
+ "word_count": info["word_count"],
541
+ "num_chunks": num_chunks,
542
+ "message": f"{file.filename} procesado: {info['word_count']:,} palabras, {num_chunks} chunks",
543
+ }
544
+
545
+ except Exception as e:
546
+ raise HTTPException(status_code=500, detail=str(e))
547
+
548
+
549
+ @app.get("/api/documents")
550
+ async def list_documents(user_id: str = Depends(get_user_or_session_id)):
551
+ """List all indexed documents for user."""
552
+ rag = state.get_rag()
553
+ stats = rag.get_stats(user_id=user_id)
554
+ return {
555
+ "documents": stats["documents"],
556
+ "total_documents": stats["total_documents"],
557
+ "total_chunks": stats["total_chunks"],
558
+ }
559
+
560
+
561
+ @app.delete("/api/documents/{filename}")
562
+ async def delete_document(
563
+ filename: str,
564
+ user_id: str = Depends(get_user_or_session_id)
565
+ ):
566
+ """Delete a document from the index."""
567
+ try:
568
+ rag = state.get_rag()
569
+ rag.delete_document(filename, user_id=user_id)
570
+ return {"success": True, "message": f"{filename} eliminado"}
571
+ except Exception as e:
572
+ raise HTTPException(status_code=500, detail=str(e))
573
+
574
+
575
+ # ======================== ROUTES: EXPORT ========================
576
+ @app.post("/api/export")
577
+ async def export_content(request: ExportRequest):
578
+ """Export a single message/content to PDF, DOCX, HTML, or TXT."""
579
+ fmt = request.format.lower()
580
+ filename = get_smart_filename(request.content, fmt)
581
+
582
+ try:
583
+ if fmt == "pdf":
584
+ data = export_to_pdf(request.content)
585
+ mime = "application/pdf"
586
+ elif fmt == "docx":
587
+ data = export_to_docx(request.content)
588
+ mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
589
+ elif fmt == "html":
590
+ data = export_to_html(request.content)
591
+ mime = "text/html"
592
+ elif fmt == "txt":
593
+ data = export_to_txt(request.content)
594
+ mime = "text/plain"
595
+ else:
596
+ raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt}")
597
+
598
+ return Response(
599
+ content=data,
600
+ media_type=mime,
601
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
602
+ )
603
+ except Exception as e:
604
+ raise HTTPException(status_code=500, detail=str(e))
605
+
606
+
607
+ @app.post("/api/export/conversation")
608
+ async def export_conversation(request: ConversationExportRequest):
609
+ """Export full conversation history."""
610
+ fmt = request.format.lower()
611
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M")
612
+ filename = f"CareerAI_Chat_{timestamp}.{fmt}"
613
+
614
+ try:
615
+ if fmt == "pdf":
616
+ data = export_conversation_to_pdf(request.messages)
617
+ mime = "application/pdf"
618
+ elif fmt == "docx":
619
+ data = export_conversation_to_docx(request.messages)
620
+ mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
621
+ elif fmt == "html":
622
+ data = export_conversation_to_html(request.messages)
623
+ mime = "text/html"
624
+ else:
625
+ raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt}")
626
+
627
+ return Response(
628
+ content=data,
629
+ media_type=mime,
630
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
631
+ )
632
+ except Exception as e:
633
+ raise HTTPException(status_code=500, detail=str(e))
634
+
635
+
636
+ # ======================== ROUTES: DETECT MODE ========================
637
+ @app.get("/api/detect-mode")
638
+ async def detect_mode(query: str = Query(...)):
639
+ """Auto-detect the best assistant mode for a query."""
640
+ if not state.api_configured:
641
+ return {"mode": "general"}
642
+ mode = state.assistant.detect_mode(query)
643
+ return {"mode": mode}
644
+
645
+
646
+ # ======================== ROUTES: DASHBOARD ========================
647
+ @app.get("/api/dashboard")
648
+ async def dashboard_data(user_id: str = Depends(get_user_or_session_id)):
649
+ """Extract profile data from documents for dashboard charts and insights."""
650
+ if not state.api_configured:
651
+ return {
652
+ "has_data": False,
653
+ "error": "API not configured",
654
+ }
655
+
656
+ rag = state.get_rag()
657
+ all_text = rag.get_all_text(user_id=user_id)
658
+ if not all_text.strip():
659
+ return {
660
+ "has_data": False,
661
+ "error": "No documents indexed",
662
+ }
663
+
664
+ try:
665
+ # Extract profile from documents
666
+ profile = extract_profile_from_text(all_text, state.assistant.llm)
667
+
668
+ skills = profile.get("skills", [])
669
+ experience = profile.get("experience", [])
670
+ summary = profile.get("summary", {})
671
+
672
+ # Build chart data
673
+ cat_data = skills_by_category(skills)
674
+ level_data = skills_by_level(skills)
675
+ timeline = experience_for_timeline(experience)
676
+
677
+ # Generate insights
678
+ insights = generate_dashboard_insights(profile, state.assistant.llm)
679
+
680
+ return {
681
+ "has_data": True,
682
+ "summary": summary,
683
+ "skills": skills,
684
+ "skills_by_category": cat_data,
685
+ "skills_by_level": level_data,
686
+ "experience_timeline": timeline,
687
+ "insights": insights,
688
+ "total_skills": len(skills),
689
+ "total_experience": len(experience),
690
+ }
691
+
692
+ except Exception as e:
693
+ return {
694
+ "has_data": False,
695
+ "error": str(e),
696
+ }
697
+
698
+
699
+ # ======================== HEALTH ========================
700
+ @app.get("/api/health")
701
+ async def health():
702
+ return {
703
+ "status": "ok",
704
+ "timestamp": datetime.now().isoformat(),
705
+ "api_configured": state.api_configured,
706
+ "model": state.model,
707
+ }
708
+
709
+
710
+ # ======================== RUN ========================
711
+ if __name__ == "__main__":
712
+ import uvicorn
713
+ uvicorn.run("api:app", host="0.0.0.0", port=8000, reload=True)
frontend/app.js ADDED
@@ -0,0 +1,1841 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ο»Ώ/**
2
+ * CareerAI β€” Claude-Style Frontend
3
+ * Full implementation connected to FastAPI backend
4
+ */
5
+
6
+ // ===== CONFIG =====
7
+ const API_BASE = window.location.origin; // Same origin (served by FastAPI)
8
+
9
+ // ===== STATE =====
10
+ const state = {
11
+ sidebarOpen: true,
12
+ currentModel: 'llama-3.3-70b-versatile',
13
+ currentModelDisplay: 'CareerAI Pro',
14
+ messages: [],
15
+ conversations: JSON.parse(localStorage.getItem('careerai_conversations') || '[]'),
16
+ currentConversationId: null,
17
+ documents: [],
18
+ documentId: null,
19
+ apiConfigured: false,
20
+ apiKey: '',
21
+ currentUser: null,
22
+ authToken: localStorage.getItem('careerai_token') || null,
23
+ authMode: 'login' // 'login', 'register', 'forgot', 'reset'
24
+ };
25
+
26
+ // ===== ELEMENTS =====
27
+ const $ = (sel) => document.querySelector(sel);
28
+ const $$ = (sel) => document.querySelectorAll(sel);
29
+
30
+ const els = {
31
+ sidebar: $('#sidebar'),
32
+ toggleSidebar: $('#toggleSidebar'),
33
+ mobileSidebarToggle: $('#mobileSidebarToggle'),
34
+ newChatBtn: $('#newChatBtn'),
35
+ searchInput: $('#searchInput'),
36
+ conversationList: $('#conversationList'),
37
+ documentList: $('#documentList'),
38
+
39
+ mainContent: $('#mainContent'),
40
+ welcomeScreen: $('#welcomeScreen'),
41
+ chatScreen: $('#chatScreen'),
42
+ chatMessages: $('#chatMessages'),
43
+
44
+ welcomeInput: $('#welcomeInput'),
45
+ chatInput: $('#chatInput'),
46
+ sendBtn: $('#sendBtn'),
47
+ chatSendBtn: $('#chatSendBtn'),
48
+
49
+ attachBtn: $('#attachBtn'),
50
+ chatAttachBtn: $('#chatAttachBtn'),
51
+
52
+ modelSelector: $('#modelSelector'),
53
+ chatModelSelector: $('#chatModelSelector'),
54
+ modelDropdown: $('#modelDropdown'),
55
+
56
+ uploadModal: $('#uploadModal'),
57
+ uploadBackdrop: $('#uploadBackdrop'),
58
+ uploadClose: $('#uploadClose'),
59
+ uploadDropzone: $('#uploadDropzone'),
60
+ fileInput: $('#fileInput'),
61
+
62
+ notificationBar: $('#notificationBar'),
63
+ };
64
+
65
+ // ===== INIT =====
66
+ document.addEventListener('DOMContentLoaded', init);
67
+
68
+ async function init() {
69
+ setupSidebar();
70
+ setupNavigation();
71
+ setupInput();
72
+ setupModelSelector();
73
+ setupUpload();
74
+ setupChips();
75
+ autoResizeTextarea(els.welcomeInput);
76
+ autoResizeTextarea(els.chatInput);
77
+
78
+ // Load API stats and Auth
79
+ await checkApiStatus();
80
+ await checkAuthSession();
81
+
82
+ renderConversations();
83
+ updateSidebarUser();
84
+
85
+ // Auto-collapse sidebar on mobile devices
86
+ if (window.innerWidth <= 768) {
87
+ els.sidebar.classList.add('collapsed');
88
+ }
89
+ }
90
+
91
+ // ===== API HELPERS =====
92
+ if (!localStorage.getItem('careerai_session')) {
93
+ localStorage.setItem('careerai_session', 'session_' + Math.random().toString(36).substr(2, 9));
94
+ }
95
+ state.sessionId = localStorage.getItem('careerai_session');
96
+
97
+ async function apiGet(path) {
98
+ const headers = { 'X-Session-ID': state.sessionId };
99
+ if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`;
100
+
101
+ const res = await fetch(`${API_BASE}${path}`, { headers });
102
+ if (!res.ok) {
103
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
104
+ if (res.status === 401) handleLogout();
105
+ throw new Error(err.detail || 'API Error');
106
+ }
107
+ return res.json();
108
+ }
109
+
110
+ async function apiPost(path, body, useUrlEncoded = false) {
111
+ const headers = { 'X-Session-ID': state.sessionId };
112
+ if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`;
113
+ if (!useUrlEncoded) headers['Content-Type'] = 'application/json';
114
+ else headers['Content-Type'] = 'application/x-www-form-urlencoded';
115
+
116
+ const res = await fetch(`${API_BASE}${path}`, {
117
+ method: 'POST',
118
+ headers,
119
+ body: useUrlEncoded ? body : JSON.stringify(body),
120
+ });
121
+ if (!res.ok) {
122
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
123
+ if (res.status === 401) handleLogout();
124
+ throw new Error(err.detail || 'API Error');
125
+ }
126
+ return res.json();
127
+ }
128
+
129
+ async function apiDelete(path) {
130
+ const headers = { 'X-Session-ID': state.sessionId };
131
+ if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`;
132
+
133
+ const res = await fetch(`${API_BASE}${path}`, { method: 'DELETE', headers });
134
+ if (!res.ok) {
135
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
136
+ if (res.status === 401) handleLogout();
137
+ throw new Error(err.detail || 'API Error');
138
+ }
139
+ return res.json();
140
+ }
141
+
142
+ // ===== STATUS CHECK =====
143
+ async function checkApiStatus() {
144
+ try {
145
+ const status = await apiGet('/api/status');
146
+ state.apiConfigured = status.api_configured;
147
+ state.currentModel = status.model || state.currentModel;
148
+ state.documents = status.documents || [];
149
+
150
+ // Update model display name
151
+ const modelNames = {
152
+ 'llama-3.3-70b-versatile': 'CareerAI Pro',
153
+ 'llama-3.1-8b-instant': 'CareerAI Flash',
154
+ };
155
+ state.currentModelDisplay = modelNames[state.currentModel] || state.currentModel;
156
+ $$('.model-name').forEach(n => n.textContent = state.currentModelDisplay);
157
+
158
+ // Update notification bar
159
+ if (state.apiConfigured) {
160
+ els.notificationBar.innerHTML = `
161
+ <span style="color: #16a34a;">● Conectado</span>
162
+ <span class="notification-separator">Β·</span>
163
+ <span>${state.currentModelDisplay}</span>
164
+ <span class="notification-separator">Β·</span>
165
+ <span>${status.total_documents} docs Β· ${status.total_chunks} chunks</span>
166
+ `;
167
+ } else {
168
+ els.notificationBar.innerHTML = `
169
+ <span style="color: #dc2626;">● Sin configurar</span>
170
+ <span class="notification-separator">Β·</span>
171
+ <a href="#" class="notification-link" onclick="showApiConfig(); return false;">Configurar API Key</a>
172
+ `;
173
+ }
174
+
175
+ // Update model selector active state
176
+ $$('.model-option').forEach(opt => {
177
+ opt.classList.toggle('active', opt.dataset.model === state.currentModel);
178
+ });
179
+
180
+ // Render documents
181
+ renderDocumentsFromList(state.documents);
182
+ } catch (e) {
183
+ console.warn('Could not check API status:', e.message);
184
+ els.notificationBar.innerHTML = `
185
+ <span style="color: #dc2626;">● Backend no disponible</span>
186
+ <span class="notification-separator">Β·</span>
187
+ <span>AsegΓΊrate de ejecutar: uvicorn api:app --port 8000</span>
188
+ `;
189
+ }
190
+ }
191
+
192
+ // ===== API CONFIG =====
193
+ function showApiConfig() {
194
+ // Create inline config modal
195
+ const existing = document.getElementById('apiConfigModal');
196
+ if (existing) existing.remove();
197
+
198
+ const modal = document.createElement('div');
199
+ modal.id = 'apiConfigModal';
200
+ modal.className = 'upload-modal';
201
+ modal.innerHTML = `
202
+ <div class="upload-modal-backdrop" onclick="document.getElementById('apiConfigModal').remove()"></div>
203
+ <div class="upload-modal-content" style="max-width: 480px;">
204
+ <div class="upload-modal-header">
205
+ <h3>πŸ”‘ Configurar API Key</h3>
206
+ <button class="upload-close" onclick="document.getElementById('apiConfigModal').remove()">&times;</button>
207
+ </div>
208
+ <div class="upload-modal-body">
209
+ <p style="color: var(--text-secondary); font-size: 0.88rem; margin-bottom: 16px;">
210
+ ObtΓ©n tu API key gratis en <a href="https://console.groq.com" target="_blank" style="color: var(--accent-primary);">console.groq.com</a>
211
+ </p>
212
+ <div class="config-input-group">
213
+ <input type="password" class="config-input" id="apiKeyInput" placeholder="gsk_..." value="${state.apiKey}">
214
+ <button class="config-btn" onclick="saveApiConfig()">Conectar</button>
215
+ </div>
216
+ <div id="apiConfigStatus"></div>
217
+ </div>
218
+ </div>
219
+ `;
220
+ document.body.appendChild(modal);
221
+ document.getElementById('apiKeyInput').focus();
222
+ }
223
+
224
+ window.showApiConfig = showApiConfig;
225
+
226
+ async function saveApiConfig() {
227
+ const input = document.getElementById('apiKeyInput');
228
+ const statusEl = document.getElementById('apiConfigStatus');
229
+ const apiKey = input.value.trim();
230
+
231
+ if (!apiKey) {
232
+ statusEl.innerHTML = '<div class="config-status disconnected"><span class="status-dot"></span> Ingresa un API key</div>';
233
+ return;
234
+ }
235
+
236
+ statusEl.innerHTML = '<div class="upload-processing"><div class="spinner"></div><span>Conectando...</span></div>';
237
+
238
+ try {
239
+ const result = await apiPost('/api/config', {
240
+ api_key: apiKey,
241
+ model: state.currentModel,
242
+ });
243
+
244
+ state.apiConfigured = true;
245
+ state.apiKey = apiKey;
246
+ statusEl.innerHTML = '<div class="config-status connected"><span class="status-dot"></span> Β‘Conectado exitosamente!</div>';
247
+
248
+ setTimeout(() => {
249
+ document.getElementById('apiConfigModal')?.remove();
250
+ checkApiStatus();
251
+ }, 1000);
252
+ } catch (e) {
253
+ statusEl.innerHTML = `<div class="config-status disconnected"><span class="status-dot"></span> Error: ${e.message}</div>`;
254
+ }
255
+ }
256
+
257
+ window.saveApiConfig = saveApiConfig;
258
+
259
+ // ===== SIDEBAR =====
260
+ function setupSidebar() {
261
+ els.toggleSidebar.addEventListener('click', toggleSidebar);
262
+ els.mobileSidebarToggle.addEventListener('click', () => {
263
+ els.sidebar.classList.remove('collapsed');
264
+ });
265
+
266
+ document.addEventListener('click', (e) => {
267
+ if (window.innerWidth <= 768) {
268
+ if (!els.sidebar.contains(e.target) && !els.mobileSidebarToggle.contains(e.target)) {
269
+ els.sidebar.classList.add('collapsed');
270
+ }
271
+ }
272
+ });
273
+
274
+ els.newChatBtn.addEventListener('click', newChat);
275
+
276
+ // Login & Profile logic bindings
277
+ const userMenu = document.getElementById('userMenu');
278
+ const loginModal = document.getElementById('loginModal');
279
+ const loginClose = document.getElementById('loginClose');
280
+ const loginBackdrop = document.getElementById('loginBackdrop');
281
+
282
+ const profileModal = document.getElementById('profileModal');
283
+ const profileClose = document.getElementById('profileClose');
284
+ const profileBackdrop = document.getElementById('profileBackdrop');
285
+
286
+ if (userMenu) {
287
+ userMenu.addEventListener('click', () => {
288
+ if (!state.currentUser) {
289
+ loginModal.classList.remove('hidden');
290
+ } else {
291
+ // Open Profile Modal
292
+ document.getElementById('profileName').value = state.currentUser.name;
293
+ document.getElementById('profileEmail').value = state.currentUser.email;
294
+ document.getElementById('profilePreview').src = state.currentUser.picture;
295
+ profileModal.classList.remove('hidden');
296
+ }
297
+ });
298
+ }
299
+
300
+ if (loginClose) loginClose.addEventListener('click', () => { loginModal.classList.add('hidden'); setAuthMode('login'); });
301
+ if (loginBackdrop) loginBackdrop.addEventListener('click', () => { loginModal.classList.add('hidden'); setAuthMode('login'); });
302
+
303
+ if (profileClose) profileClose.addEventListener('click', () => profileModal.classList.add('hidden'));
304
+ if (profileBackdrop) profileBackdrop.addEventListener('click', () => profileModal.classList.add('hidden'));
305
+ }
306
+
307
+ async function checkAuthSession() {
308
+ if (state.authToken) {
309
+ try {
310
+ const user = await apiGet('/api/auth/me');
311
+ state.currentUser = user;
312
+ updateSidebarUser();
313
+
314
+ // Sync conversations
315
+ const cloudConvs = await apiGet('/api/conversations');
316
+ if (cloudConvs && cloudConvs.length > 0) {
317
+ state.conversations = cloudConvs;
318
+ saveConversations(); // Sync strictly to local cache initially
319
+ renderConversations();
320
+ }
321
+ } catch (e) {
322
+ handleLogout();
323
+ }
324
+ }
325
+ }
326
+
327
+ window.handleAuthSubmit = async function (event) {
328
+ event.preventDefault();
329
+ const btn = document.getElementById('authSubmitBtn');
330
+
331
+ const email = document.getElementById('authEmail').value.trim();
332
+ const password = document.getElementById('authPassword').value;
333
+ const name = document.getElementById('authName').value.trim();
334
+ const resetCode = document.getElementById('authResetCode')?.value.trim();
335
+ const mode = state.authMode;
336
+
337
+ try {
338
+ btn.innerHTML = '<span class="spinner" style="width:16px;height:16px;margin:auto;"></span>';
339
+ btn.style.pointerEvents = 'none';
340
+
341
+ let result;
342
+ if (mode === 'register') {
343
+ result = await apiPost('/api/auth/register', { name, email, password });
344
+ } else if (mode === 'login') {
345
+ result = await apiPost('/api/auth/login', `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`, true);
346
+ } else if (mode === 'reset') {
347
+ result = await apiPost('/api/auth/reset-password', { email, code: resetCode, new_password: password });
348
+ showToast('βœ… ' + result.message);
349
+ setAuthMode('login');
350
+ return;
351
+ }
352
+
353
+ state.authToken = result.access_token;
354
+ localStorage.setItem('careerai_token', result.access_token);
355
+
356
+ document.getElementById('loginModal').classList.add('hidden');
357
+ showToast('βœ… SesiΓ³n iniciada con Γ©xito');
358
+
359
+ await checkAuthSession();
360
+ } catch (err) {
361
+ showToast('❌ Error: ' + err.message);
362
+ } finally {
363
+ btn.innerHTML = mode === 'register' ? 'Registrarme' : (mode === 'reset' ? 'Actualizar ContraseΓ±a' : 'Iniciar SesiΓ³n');
364
+ btn.style.pointerEvents = 'auto';
365
+ }
366
+ };
367
+
368
+ window.setAuthMode = function (mode) {
369
+ state.authMode = mode;
370
+
371
+ const registerFields = document.getElementById('registerFields');
372
+ const resetCodeFields = document.getElementById('resetCodeFields');
373
+ const passwordFieldsGroup = document.getElementById('passwordFieldsGroup');
374
+ const forgotPassContainer = document.getElementById('forgotPassContainer');
375
+ const authToggleContainer = document.getElementById('authToggleContainer');
376
+ const backToLoginContainer = document.getElementById('backToLoginContainer');
377
+ const loginTitle = document.getElementById('loginTitle');
378
+ const authSubmitBtn = document.getElementById('authSubmitBtn');
379
+ const authSendCodeBtn = document.getElementById('authSendCodeBtn');
380
+
381
+ // Hide all uniquely conditional elements initially
382
+ registerFields.style.display = 'none';
383
+ resetCodeFields.style.display = 'none';
384
+ passwordFieldsGroup.style.display = 'none';
385
+ forgotPassContainer.style.display = 'none';
386
+ authToggleContainer.style.display = 'none';
387
+ backToLoginContainer.style.display = 'none';
388
+ authSendCodeBtn.style.display = 'none';
389
+ authSubmitBtn.style.display = 'flex';
390
+
391
+ document.getElementById('authPassword').required = false;
392
+
393
+ if (mode === 'login') {
394
+ passwordFieldsGroup.style.display = 'block';
395
+ forgotPassContainer.style.display = 'block';
396
+ authToggleContainer.style.display = 'block';
397
+ document.getElementById('authPassword').required = true;
398
+
399
+ loginTitle.innerText = 'Acceso a CareerAI';
400
+ authSubmitBtn.innerText = 'Iniciar SesiΓ³n';
401
+ document.getElementById('authToggleText').innerHTML = 'ΒΏNo tienes cuenta? <a href="#" onclick="event.preventDefault(); setAuthMode(\'register\')" style="color: var(--accent-primary); text-decoration: none;">RegΓ­strate</a>';
402
+
403
+ } else if (mode === 'register') {
404
+ registerFields.style.display = 'block';
405
+ passwordFieldsGroup.style.display = 'block';
406
+ authToggleContainer.style.display = 'block';
407
+ document.getElementById('authPassword').required = true;
408
+
409
+ loginTitle.innerText = 'Crear cuenta';
410
+ authSubmitBtn.innerText = 'Registrarme';
411
+ document.getElementById('authToggleText').innerHTML = 'ΒΏYa tienes cuenta? <a href="#" onclick="event.preventDefault(); setAuthMode(\'login\')" style="color: var(--accent-primary); text-decoration: none;">Inicia sesiΓ³n</a>';
412
+
413
+ } else if (mode === 'forgot') {
414
+ backToLoginContainer.style.display = 'block';
415
+ authSubmitBtn.style.display = 'none';
416
+ authSendCodeBtn.style.display = 'flex';
417
+
418
+ loginTitle.innerText = 'Recuperar contraseΓ±a';
419
+
420
+ } else if (mode === 'reset') {
421
+ resetCodeFields.style.display = 'block';
422
+ passwordFieldsGroup.style.display = 'block';
423
+ backToLoginContainer.style.display = 'block';
424
+ document.getElementById('authPassword').required = true;
425
+
426
+ loginTitle.innerText = 'Nueva contraseΓ±a';
427
+ authSubmitBtn.innerText = 'Actualizar ContraseΓ±a';
428
+
429
+ // Minor QOL: focus the code input directly
430
+ setTimeout(() => document.getElementById('authResetCode')?.focus(), 100);
431
+ }
432
+ };
433
+
434
+ window.handleSendResetCode = async function (event) {
435
+ event.preventDefault();
436
+ const email = document.getElementById('authEmail').value.trim();
437
+ if (!email) {
438
+ showToast('⚠️ Ingresa tu correo electrónico', 'warning');
439
+ return;
440
+ }
441
+
442
+ const btn = document.getElementById('authSendCodeBtn');
443
+ try {
444
+ btn.innerHTML = '<span class="spinner" style="width:16px;height:16px;margin:auto;"></span>';
445
+ btn.style.pointerEvents = 'none';
446
+
447
+ const result = await apiPost('/api/auth/forgot-password', { email });
448
+ showToast('βœ… ' + result.message);
449
+
450
+ // Move to phase 2
451
+ setAuthMode('reset');
452
+ } catch (err) {
453
+ showToast('❌ Error: ' + err.message);
454
+ } finally {
455
+ btn.innerHTML = 'Enviar cΓ³digo a mi correo';
456
+ btn.style.pointerEvents = 'auto';
457
+ }
458
+ };
459
+
460
+ window.handleProfilePictureSelect = function (event) {
461
+ const file = event.target.files[0];
462
+ if (file) {
463
+ const reader = new FileReader();
464
+ reader.onload = (e) => {
465
+ // For now we set it as local base64 until submission
466
+ document.getElementById('profilePreview').src = e.target.result;
467
+ };
468
+ reader.readAsDataURL(file);
469
+ }
470
+ };
471
+
472
+ window.handleProfileSubmit = async function (event) {
473
+ event.preventDefault();
474
+ const btn = document.getElementById('profileSubmitBtn');
475
+ const name = document.getElementById('profileName').value.trim();
476
+ // In our implementation we can send base64 image or just accept name for now
477
+
478
+ // Check if user changed picture logic
479
+ const imgEl = document.getElementById('profilePreview');
480
+ const pictureStr = imgEl.src.startsWith('data:image') ? imgEl.src : state.currentUser.picture;
481
+
482
+ try {
483
+ btn.innerHTML = '<span class="spinner" style="width:16px;height:16px;margin:auto;"></span>';
484
+ btn.style.pointerEvents = 'none';
485
+
486
+ const result = await apiPost('/api/auth/me', { name: name, picture: pictureStr });
487
+
488
+ state.currentUser.name = result.name;
489
+ state.currentUser.picture = result.picture;
490
+ updateSidebarUser();
491
+
492
+ document.getElementById('profileModal').classList.add('hidden');
493
+ showToast('βœ… Perfil actualizado exitosamente');
494
+
495
+ } catch (err) {
496
+ showToast('❌ Error al actualizar perfil: ' + err.message);
497
+ } finally {
498
+ btn.innerHTML = 'Guardar Cambios';
499
+ btn.style.pointerEvents = 'auto';
500
+ }
501
+ };
502
+
503
+ function handleLogout() {
504
+ state.currentUser = null;
505
+ state.authToken = null;
506
+ localStorage.removeItem('careerai_token');
507
+
508
+ // Clear user localized states safely
509
+ state.conversations = [];
510
+ state.currentConversationId = null;
511
+ state.messages = [];
512
+ state.documents = [];
513
+ localStorage.removeItem('careerai_conversations');
514
+
515
+ // Generate a new session ID for the guest
516
+ const newSession = 'session_' + Math.random().toString(36).substr(2, 9);
517
+ localStorage.setItem('careerai_session', newSession);
518
+ state.sessionId = newSession;
519
+
520
+ updateSidebarUser();
521
+ renderConversations();
522
+ renderDocumentsFromList([]);
523
+ showWelcome();
524
+ document.getElementById('profileModal')?.classList.add('hidden');
525
+ showToast('πŸ‘‹ SesiΓ³n cerrada');
526
+
527
+ // Refresh status with new session
528
+ checkApiStatus();
529
+ }
530
+
531
+ function toggleSidebar() {
532
+ els.sidebar.classList.toggle('collapsed');
533
+ }
534
+
535
+ function updateSidebarUser() {
536
+ const userMenu = document.getElementById('userMenu');
537
+ if (!userMenu) return;
538
+
539
+ if (state.currentUser) {
540
+ userMenu.innerHTML = `
541
+ <img src="${state.currentUser.picture}" class="user-avatar" style="border: none; padding: 0; background: transparent;">
542
+ <span class="user-name">${state.currentUser.name}</span>
543
+ `;
544
+ } else {
545
+ userMenu.innerHTML = `
546
+ <div class="user-avatar" style="background: var(--bg-hover); color: var(--text-secondary);">
547
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
548
+ </div>
549
+ <span class="user-name">Iniciar sesiΓ³n</span>
550
+ `;
551
+ }
552
+ }
553
+
554
+ // ===== NAVIGATION =====
555
+ function setupNavigation() {
556
+ $$('.nav-item').forEach(item => {
557
+ item.addEventListener('click', (e) => {
558
+ e.preventDefault();
559
+ const page = item.dataset.page;
560
+ $$('.nav-item').forEach(n => n.classList.remove('active'));
561
+ item.classList.add('active');
562
+
563
+ if (page === 'chat') {
564
+ hideDashboardPage();
565
+ if (state.messages.length === 0) showWelcome();
566
+ else showChat();
567
+ } else if (page === 'documents') {
568
+ els.uploadModal.classList.remove('hidden');
569
+ } else if (page === 'dashboard') {
570
+ showDashboardPage();
571
+ } else if (page === 'settings') {
572
+ showApiConfig();
573
+ }
574
+
575
+ // Close sidebar on mobile after clicking
576
+ if (window.innerWidth <= 768) {
577
+ els.sidebar.classList.add('collapsed');
578
+ }
579
+ });
580
+ });
581
+ }
582
+
583
+ // ===== DASHBOARD PAGE =====
584
+ function showDashboardPage() {
585
+ els.welcomeScreen.classList.add('hidden');
586
+ els.welcomeScreen.style.display = 'none';
587
+ els.chatScreen.classList.add('hidden');
588
+ els.chatScreen.style.display = 'none';
589
+
590
+ let dashPage = document.getElementById('dashboardPage');
591
+ if (dashPage) dashPage.remove();
592
+
593
+ dashPage = document.createElement('div');
594
+ dashPage.id = 'dashboardPage';
595
+ dashPage.style.cssText = 'flex:1; overflow-y:auto; padding:40px 24px; animation: fadeIn 0.4s ease-out;';
596
+ dashPage.innerHTML = `
597
+ <div style="max-width:900px; margin:0 auto;">
598
+ <h2 style="font-family: var(--font-serif); font-size:1.8rem; font-weight:400; margin-bottom:8px; color: var(--text-primary);">πŸ“Š Dashboard Profesional</h2>
599
+ <p style="color: var(--text-secondary); font-size:0.9rem; margin-bottom:28px;">AnΓ‘lisis inteligente de tus documentos β€” perfil, skills y experiencia.</p>
600
+
601
+ <div style="display:grid; grid-template-columns: repeat(4, 1fr); gap:12px; margin-bottom:28px;" id="dashKpis">
602
+ <div class="dash-kpi"><div class="dash-kpi-value" id="kpiDocs">β€”</div><div class="dash-kpi-label">Documentos</div></div>
603
+ <div class="dash-kpi"><div class="dash-kpi-value" id="kpiChunks">β€”</div><div class="dash-kpi-label">Chunks</div></div>
604
+ <div class="dash-kpi"><div class="dash-kpi-value" id="kpiSkills">β€”</div><div class="dash-kpi-label">Skills</div></div>
605
+ <div class="dash-kpi"><div class="dash-kpi-value" id="kpiExp">β€”</div><div class="dash-kpi-label">Experiencias</div></div>
606
+ </div>
607
+
608
+ <div class="dash-card" id="dashSummaryCard" style="display:none;">
609
+ <h3 class="dash-card-title">πŸ‘€ Resumen del Perfil</h3>
610
+ <div id="dashSummaryContent"></div>
611
+ </div>
612
+
613
+ <div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-bottom:16px;">
614
+ <div class="dash-card">
615
+ <h3 class="dash-card-title">πŸ“Š Skills por CategorΓ­a</h3>
616
+ <div style="position:relative; height:280px;" id="chartCategoryWrap"><canvas id="chartCategory"></canvas></div>
617
+ </div>
618
+ <div class="dash-card">
619
+ <h3 class="dash-card-title">🎯 Skills por Nivel</h3>
620
+ <div style="position:relative; height:280px;" id="chartLevelWrap"><canvas id="chartLevel"></canvas></div>
621
+ </div>
622
+ </div>
623
+
624
+ <div class="dash-card" id="dashSkillsCard" style="display:none;">
625
+ <h3 class="dash-card-title">πŸ› οΈ Skills Detectadas</h3>
626
+ <div id="dashSkillsTable"></div>
627
+ </div>
628
+
629
+ <div class="dash-card" id="dashTimelineCard" style="display:none;">
630
+ <h3 class="dash-card-title">πŸ“… Trayectoria Profesional</h3>
631
+ <div id="dashTimeline"></div>
632
+ </div>
633
+
634
+ <div class="dash-card" id="dashInsightsCard" style="display:none;">
635
+ <h3 class="dash-card-title">🧠 Insights de la IA</h3>
636
+ <div id="dashInsights"></div>
637
+ </div>
638
+
639
+ <div id="dashLoading" style="text-align:center; padding:60px 0;">
640
+ <div class="spinner" style="margin:0 auto 16px;"></div>
641
+ <p style="color:var(--text-secondary); font-size:0.9rem;">Analizando tus documentos con IA...</p>
642
+ <p style="color:var(--text-tertiary); font-size:0.8rem;">Esto puede tomar 10-20 segundos</p>
643
+ </div>
644
+
645
+ <div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:20px;">
646
+ <button onclick="document.querySelector('[data-page=documents]').click()" class="dash-action-btn">πŸ“„ Subir Documentos</button>
647
+ <button onclick="clearAllConversations()" class="dash-action-btn">πŸ—‘οΈ Limpiar Historial</button>
648
+ </div>
649
+ </div>
650
+ `;
651
+
652
+ els.mainContent.appendChild(dashPage);
653
+ addDashboardStyles();
654
+ loadDashboardData();
655
+ }
656
+
657
+ function addDashboardStyles() {
658
+ if (document.getElementById('dashStyles')) return;
659
+ const s = document.createElement('style');
660
+ s.id = 'dashStyles';
661
+ s.textContent = `
662
+ .dash-kpi { background:var(--bg-input); border:1px solid var(--border-light); border-radius:14px; padding:20px; text-align:center; transition:transform 0.2s,box-shadow 0.2s; }
663
+ .dash-kpi:hover { transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,0.06); }
664
+ .dash-kpi-value { font-size:2rem; font-weight:700; color:var(--accent-primary); line-height:1.2; }
665
+ .dash-kpi-label { font-size:0.75rem; color:var(--text-tertiary); font-weight:600; text-transform:uppercase; letter-spacing:0.06em; margin-top:4px; }
666
+ .dash-card { background:var(--bg-input); border:1px solid var(--border-light); border-radius:14px; padding:24px; margin-bottom:16px; }
667
+ .dash-card-title { font-size:1rem; font-weight:600; margin-bottom:16px; color:var(--text-primary); }
668
+ .dash-action-btn { padding:10px 20px; border:1px solid var(--border-light); border-radius:10px; background:var(--bg-input); color:var(--text-primary); font-size:0.88rem; font-weight:500; cursor:pointer; font-family:var(--font-family); transition:all 0.2s; }
669
+ .dash-action-btn:hover { background:var(--bg-secondary); border-color:var(--accent-primary); color:var(--accent-primary); }
670
+ .skill-badge { display:inline-flex; align-items:center; gap:4px; padding:4px 10px; border-radius:6px; font-size:0.8rem; font-weight:500; margin:3px; }
671
+ .skill-badge.advanced { background:#dcfce7; color:#166534; }
672
+ .skill-badge.intermediate { background:#dbeafe; color:#1e40af; }
673
+ .skill-badge.basic { background:#fef3c7; color:#92400e; }
674
+ .timeline-item { position:relative; padding:16px 0 16px 28px; border-left:2px solid var(--border-light); }
675
+ .timeline-item:before { content:''; position:absolute; left:-5px; top:20px; width:8px; height:8px; border-radius:50%; background:var(--accent-primary); border:2px solid var(--bg-primary); }
676
+ .timeline-item.current:before { background:#16a34a; box-shadow:0 0 0 3px rgba(22,163,74,0.2); }
677
+ .timeline-role { font-weight:600; font-size:0.95rem; }
678
+ .timeline-company { color:var(--accent-primary); font-size:0.88rem; }
679
+ .timeline-dates { color:var(--text-tertiary); font-size:0.8rem; margin-top:2px; }
680
+ .timeline-desc { color:var(--text-secondary); font-size:0.84rem; margin-top:4px; }
681
+ .insight-section { margin-bottom:16px; }
682
+ .insight-section h4 { font-size:0.88rem; font-weight:600; margin-bottom:8px; }
683
+ .insight-item { padding:6px 0; font-size:0.86rem; color:var(--text-secondary); border-bottom:1px solid var(--border-light); }
684
+ .insight-item:last-child { border-bottom:none; }
685
+ @media (max-width:768px) { #dashKpis { grid-template-columns:repeat(2,1fr) !important; } }
686
+ `;
687
+ document.head.appendChild(s);
688
+ }
689
+
690
+ async function loadDashboardData() {
691
+ const loading = document.getElementById('dashLoading');
692
+ try {
693
+ const status = await apiGet('/api/status');
694
+ const el = (id) => document.getElementById(id);
695
+ if (el('kpiDocs')) el('kpiDocs').textContent = status.total_documents;
696
+ if (el('kpiChunks')) el('kpiChunks').textContent = status.total_chunks;
697
+ } catch (e) { /* ignore */ }
698
+
699
+ try {
700
+ const data = await apiGet('/api/dashboard');
701
+ if (loading) loading.style.display = 'none';
702
+
703
+ if (!data.has_data) {
704
+ if (loading) {
705
+ loading.style.display = 'block';
706
+ loading.innerHTML = `<div style="padding:40px; text-align:center;"><p style="font-size:3rem; margin-bottom:12px;">πŸ“­</p><p style="color:var(--text-secondary); font-size:1rem; font-weight:500;">No hay datos para analizar</p><p style="color:var(--text-tertiary); font-size:0.88rem; margin-top:8px;">${data.error || 'Sube documentos o configura tu API key.'}</p><button onclick="document.querySelector('[data-page=documents]').click()" class="dash-action-btn" style="margin-top:20px;">πŸ“„ Subir mi CV</button></div>`;
707
+ }
708
+ return;
709
+ }
710
+
711
+ const el = (id) => document.getElementById(id);
712
+ if (el('kpiSkills')) el('kpiSkills').textContent = data.total_skills || 0;
713
+ if (el('kpiExp')) el('kpiExp').textContent = data.total_experience || 0;
714
+
715
+ // Profile Summary
716
+ if (data.summary && (data.summary.headline || data.summary.estimated_seniority)) {
717
+ const sc = el('dashSummaryCard'); if (sc) sc.style.display = 'block';
718
+ const s = data.summary;
719
+ el('dashSummaryContent').innerHTML = `<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px;"><div><div style="font-size:0.75rem; color:var(--text-tertiary); font-weight:600; text-transform:uppercase;">Headline</div><div style="font-size:0.92rem; font-weight:500; margin-top:4px;">${s.headline || 'β€”'}</div></div><div><div style="font-size:0.75rem; color:var(--text-tertiary); font-weight:600; text-transform:uppercase;">Seniority</div><div style="font-size:0.92rem; font-weight:500; margin-top:4px; text-transform:capitalize;">${s.estimated_seniority || 'β€”'}</div></div><div><div style="font-size:0.75rem; color:var(--text-tertiary); font-weight:600; text-transform:uppercase;">AΓ±os Experiencia</div><div style="font-size:0.92rem; font-weight:500; margin-top:4px;">${s.total_years_experience || 'β€”'} aΓ±os</div></div></div>`;
720
+ }
721
+
722
+ // Charts
723
+ renderCategoryChart(data.skills_by_category || {});
724
+ renderLevelChart(data.skills_by_level || {});
725
+
726
+ // Skills Table
727
+ if (data.skills && data.skills.length > 0) {
728
+ el('dashSkillsCard').style.display = 'block';
729
+ const grouped = {};
730
+ data.skills.forEach(sk => { const c = sk.category || 'other'; if (!grouped[c]) grouped[c] = []; grouped[c].push(sk); });
731
+ const catL = { technical: 'πŸ’» TΓ©cnicas', soft: '🀝 Soft Skills', tools: 'πŸ”§ Herramientas', language: '🌍 Idiomas', other: 'πŸ“Œ Otras' };
732
+ let h = '';
733
+ for (const [cat, skills] of Object.entries(grouped)) {
734
+ h += `<div style="margin-bottom:12px;"><strong style="font-size:0.82rem; color:var(--text-tertiary);">${catL[cat] || cat}</strong><div style="margin-top:6px;">`;
735
+ skills.forEach(sk => { h += `<span class="skill-badge ${sk.level || 'intermediate'}">${sk.name}</span>`; });
736
+ h += '</div></div>';
737
+ }
738
+ el('dashSkillsTable').innerHTML = h;
739
+ }
740
+
741
+ // Timeline
742
+ if (data.experience_timeline && data.experience_timeline.length > 0) {
743
+ el('dashTimelineCard').style.display = 'block';
744
+ el('dashTimeline').innerHTML = data.experience_timeline.map(exp => `
745
+ <div class="timeline-item ${exp.current ? 'current' : ''}">
746
+ <div class="timeline-role">${exp.role}</div>
747
+ <div class="timeline-company">${exp.company}</div>
748
+ <div class="timeline-dates">${exp.start_date} β†’ ${exp.end_date}${exp.current ? ' (Actual)' : ''}</div>
749
+ ${exp.description ? `<div class="timeline-desc">${exp.description}</div>` : ''}
750
+ </div>
751
+ `).join('');
752
+ }
753
+
754
+ // Insights
755
+ if (data.insights) {
756
+ const ins = data.insights;
757
+ if (ins.strengths?.length || ins.potential_gaps?.length || ins.role_suggestions?.length || ins.next_actions?.length) {
758
+ el('dashInsightsCard').style.display = 'block';
759
+ let h = '';
760
+ if (ins.strengths?.length) h += `<div class="insight-section"><h4>πŸ’ͺ Fortalezas</h4>${ins.strengths.map(s => `<div class="insight-item">βœ… ${s}</div>`).join('')}</div>`;
761
+ if (ins.potential_gaps?.length) h += `<div class="insight-section"><h4>πŸ“‰ Áreas de mejora</h4>${ins.potential_gaps.map(s => `<div class="insight-item">⚠️ ${s}</div>`).join('')}</div>`;
762
+ if (ins.role_suggestions?.length) h += `<div class="insight-section"><h4>🎯 Roles sugeridos</h4>${ins.role_suggestions.map(s => `<div class="insight-item">🏒 ${s}</div>`).join('')}</div>`;
763
+ if (ins.next_actions?.length) h += `<div class="insight-section"><h4>πŸš€ PrΓ³ximos pasos</h4>${ins.next_actions.map(s => `<div class="insight-item">β†’ ${s}</div>`).join('')}</div>`;
764
+ el('dashInsights').innerHTML = h;
765
+ }
766
+ }
767
+ } catch (e) {
768
+ console.error('Dashboard error:', e);
769
+ if (loading) loading.innerHTML = `<div style="padding:40px; text-align:center;"><p style="font-size:3rem; margin-bottom:12px;">⚠️</p><p style="color:var(--text-secondary);">Error al cargar el dashboard</p><p style="color:var(--text-tertiary); font-size:0.85rem; margin-top:8px;">${e.message}</p></div>`;
770
+ }
771
+ }
772
+
773
+ function renderCategoryChart(data) {
774
+ const canvas = document.getElementById('chartCategory');
775
+ if (!canvas || !window.Chart) return;
776
+ const labels = Object.keys(data), values = Object.values(data);
777
+ if (!labels.length) { document.getElementById('chartCategoryWrap').innerHTML = '<p style="color:var(--text-tertiary); text-align:center; padding:80px 0;">Sin datos</p>'; return; }
778
+ const catN = { technical: 'TΓ©cnicas', soft: 'Soft Skills', tools: 'Herramientas', language: 'Idiomas', other: 'Otras' };
779
+ const catC = { technical: '#c97c3e', soft: '#6366f1', tools: '#10b981', language: '#f59e0b', other: '#8b5cf6' };
780
+ new Chart(canvas, { type: 'bar', data: { labels: labels.map(l => catN[l] || l), datasets: [{ data: values, backgroundColor: labels.map(l => catC[l] || '#94a3b8'), borderRadius: 8, borderSkipped: false }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { ticks: { font: { size: 11 } }, grid: { display: false } } } } });
781
+ }
782
+
783
+ function renderLevelChart(data) {
784
+ const canvas = document.getElementById('chartLevel');
785
+ if (!canvas || !window.Chart) return;
786
+ const labels = Object.keys(data), values = Object.values(data);
787
+ if (values.every(v => v === 0)) { document.getElementById('chartLevelWrap').innerHTML = '<p style="color:var(--text-tertiary); text-align:center; padding:80px 0;">Sin datos</p>'; return; }
788
+ const levelN = { basic: 'BΓ‘sico', intermediate: 'Intermedio', advanced: 'Avanzado' };
789
+ new Chart(canvas, { type: 'doughnut', data: { labels: labels.map(l => levelN[l] || l), datasets: [{ data: values, backgroundColor: ['#fbbf24', '#3b82f6', '#22c55e'], borderWidth: 0, hoverOffset: 6 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '60%', plugins: { legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyleWidth: 8, font: { size: 12 } } } } } });
790
+ }
791
+
792
+ function hideDashboardPage() {
793
+ const dashPage = document.getElementById('dashboardPage');
794
+ if (dashPage) dashPage.remove();
795
+ }
796
+
797
+ function clearAllConversations() {
798
+ if (confirm('ΒΏEstΓ‘s seguro? Se borrarΓ‘n todas las conversaciones guardadas.')) {
799
+ state.conversations = [];
800
+ state.messages = [];
801
+ state.currentConversationId = null;
802
+ saveConversations();
803
+ renderConversations();
804
+ showWelcome();
805
+ hideDashboardPage();
806
+ showToast('πŸ—‘οΈ Historial limpiado');
807
+ }
808
+ }
809
+
810
+ window.clearAllConversations = clearAllConversations;
811
+
812
+
813
+ // ===== INPUT =====
814
+ function setupInput() {
815
+ // Welcome input
816
+ els.welcomeInput.addEventListener('input', () => {
817
+ autoResizeTextarea(els.welcomeInput);
818
+ els.sendBtn.disabled = !els.welcomeInput.value.trim();
819
+ });
820
+
821
+ els.welcomeInput.addEventListener('keydown', (e) => {
822
+ if (e.key === 'Enter' && !e.shiftKey) {
823
+ e.preventDefault();
824
+ if (els.welcomeInput.value.trim() && !state.isStreaming) {
825
+ sendMessage(els.welcomeInput.value.trim());
826
+ els.welcomeInput.value = '';
827
+ autoResizeTextarea(els.welcomeInput);
828
+ els.sendBtn.disabled = true;
829
+ }
830
+ }
831
+ });
832
+
833
+ els.sendBtn.addEventListener('click', () => {
834
+ if (els.welcomeInput.value.trim() && !state.isStreaming) {
835
+ sendMessage(els.welcomeInput.value.trim());
836
+ els.welcomeInput.value = '';
837
+ autoResizeTextarea(els.welcomeInput);
838
+ els.sendBtn.disabled = true;
839
+ }
840
+ });
841
+
842
+ // Chat input
843
+ els.chatInput.addEventListener('input', () => {
844
+ autoResizeTextarea(els.chatInput);
845
+ els.chatSendBtn.disabled = !els.chatInput.value.trim();
846
+ });
847
+
848
+ els.chatInput.addEventListener('keydown', (e) => {
849
+ if (e.key === 'Enter' && !e.shiftKey) {
850
+ e.preventDefault();
851
+ if (els.chatInput.value.trim() && !state.isStreaming) {
852
+ sendMessage(els.chatInput.value.trim());
853
+ els.chatInput.value = '';
854
+ autoResizeTextarea(els.chatInput);
855
+ els.chatSendBtn.disabled = true;
856
+ }
857
+ }
858
+ });
859
+
860
+ els.chatSendBtn.addEventListener('click', () => {
861
+ if (els.chatInput.value.trim() && !state.isStreaming) {
862
+ sendMessage(els.chatInput.value.trim());
863
+ els.chatInput.value = '';
864
+ autoResizeTextarea(els.chatInput);
865
+ els.chatSendBtn.disabled = true;
866
+ }
867
+ });
868
+ }
869
+
870
+ function autoResizeTextarea(textarea) {
871
+ textarea.style.height = 'auto';
872
+ textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
873
+ }
874
+
875
+ // ===== MODEL SELECTOR =====
876
+ function setupModelSelector() {
877
+ const selectors = [els.modelSelector, els.chatModelSelector];
878
+
879
+ selectors.forEach(sel => {
880
+ sel.addEventListener('click', (e) => {
881
+ e.stopPropagation();
882
+ const rect = sel.getBoundingClientRect();
883
+ const dropdown = els.modelDropdown;
884
+
885
+ if (!dropdown.classList.contains('hidden')) {
886
+ dropdown.classList.add('hidden');
887
+ return;
888
+ }
889
+
890
+ dropdown.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
891
+ dropdown.style.left = rect.left + 'px';
892
+ dropdown.classList.remove('hidden');
893
+ });
894
+ });
895
+
896
+ $$('.model-option').forEach(opt => {
897
+ opt.addEventListener('click', async () => {
898
+ const model = opt.dataset.model;
899
+ const display = opt.dataset.display;
900
+
901
+ state.currentModel = model;
902
+ state.currentModelDisplay = display;
903
+
904
+ $$('.model-name').forEach(n => n.textContent = display);
905
+ $$('.model-option').forEach(o => o.classList.remove('active'));
906
+ opt.classList.add('active');
907
+ els.modelDropdown.classList.add('hidden');
908
+
909
+ // Update on backend
910
+ if (state.apiConfigured) {
911
+ try {
912
+ await fetch(`${API_BASE}/api/model?model=${model}`, { method: 'POST' });
913
+ showToast(`Modelo cambiado a ${display}`);
914
+ } catch (e) {
915
+ showToast(`Error al cambiar modelo: ${e.message}`);
916
+ }
917
+ } else {
918
+ showToast(`Modelo: ${display} (conecta API key para usar)`);
919
+ }
920
+ });
921
+ });
922
+
923
+ document.addEventListener('click', (e) => {
924
+ if (!els.modelDropdown.contains(e.target)) {
925
+ els.modelDropdown.classList.add('hidden');
926
+ }
927
+ });
928
+ }
929
+
930
+ // ===== UPLOAD =====
931
+ function setupUpload() {
932
+ [els.attachBtn, els.chatAttachBtn].forEach(btn => {
933
+ btn.addEventListener('click', () => {
934
+ els.uploadModal.classList.remove('hidden');
935
+ });
936
+ });
937
+
938
+ els.uploadClose.addEventListener('click', closeUploadModal);
939
+ els.uploadBackdrop.addEventListener('click', closeUploadModal);
940
+
941
+ els.uploadDropzone.addEventListener('click', () => els.fileInput.click());
942
+
943
+ els.fileInput.addEventListener('change', (e) => {
944
+ if (e.target.files.length > 0) handleFileUpload(e.target.files[0]);
945
+ });
946
+
947
+ els.uploadDropzone.addEventListener('dragover', (e) => {
948
+ e.preventDefault();
949
+ els.uploadDropzone.classList.add('drag-over');
950
+ });
951
+
952
+ els.uploadDropzone.addEventListener('dragleave', () => {
953
+ els.uploadDropzone.classList.remove('drag-over');
954
+ });
955
+
956
+ els.uploadDropzone.addEventListener('drop', (e) => {
957
+ e.preventDefault();
958
+ els.uploadDropzone.classList.remove('drag-over');
959
+ if (e.dataTransfer.files.length > 0) handleFileUpload(e.dataTransfer.files[0]);
960
+ });
961
+
962
+ $$('.upload-type').forEach(type => {
963
+ type.addEventListener('click', () => {
964
+ $$('.upload-type').forEach(t => t.classList.remove('active'));
965
+ type.classList.add('active');
966
+ state.selectedDocType = type.dataset.type;
967
+ });
968
+ });
969
+ }
970
+
971
+ function closeUploadModal() {
972
+ els.uploadModal.classList.add('hidden');
973
+ }
974
+
975
+ async function handleFileUpload(file) {
976
+ const validExts = ['pdf', 'txt', 'docx', 'jpg', 'jpeg', 'png', 'webp'];
977
+ const ext = file.name.split('.').pop().toLowerCase();
978
+
979
+ if (!validExts.includes(ext)) {
980
+ showToast('❌ Formato no soportado');
981
+ return;
982
+ }
983
+
984
+ const dropzone = els.uploadDropzone;
985
+ const originalContent = dropzone.innerHTML;
986
+
987
+ dropzone.innerHTML = `
988
+ <div class="upload-processing">
989
+ <div class="spinner"></div>
990
+ <span>Procesando ${file.name}...</span>
991
+ </div>
992
+ `;
993
+
994
+ try {
995
+ const formData = new FormData();
996
+ formData.append('file', file);
997
+ formData.append('doc_type', state.selectedDocType);
998
+
999
+ const headers = { 'X-Session-ID': state.sessionId };
1000
+ if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`;
1001
+
1002
+ const res = await fetch(`${API_BASE}/api/documents/upload`, {
1003
+ method: 'POST',
1004
+ headers: headers,
1005
+ body: formData,
1006
+ });
1007
+
1008
+ if (!res.ok) {
1009
+ const err = await res.json().catch(() => ({ detail: 'Upload failed' }));
1010
+ throw new Error(err.detail);
1011
+ }
1012
+
1013
+ const result = await res.json();
1014
+
1015
+ dropzone.innerHTML = `
1016
+ <div class="upload-success">
1017
+ βœ… <strong>${file.name}</strong> β€” ${result.message}
1018
+ </div>
1019
+ `;
1020
+
1021
+ // Refresh documents list
1022
+ await refreshDocuments();
1023
+
1024
+ setTimeout(() => {
1025
+ dropzone.innerHTML = originalContent;
1026
+ closeUploadModal();
1027
+ showToast(`πŸ“„ ${file.name} indexado correctamente`);
1028
+ }, 1800);
1029
+
1030
+ } catch (e) {
1031
+ dropzone.innerHTML = `
1032
+ <div style="color: #dc2626; padding: 16px; text-align: center;">
1033
+ ❌ Error: ${e.message}
1034
+ </div>
1035
+ `;
1036
+ setTimeout(() => {
1037
+ dropzone.innerHTML = originalContent;
1038
+ }, 3000);
1039
+ }
1040
+
1041
+ els.fileInput.value = '';
1042
+ }
1043
+
1044
+ async function refreshDocuments() {
1045
+ try {
1046
+ const data = await apiGet('/api/documents');
1047
+ state.documents = data.documents || [];
1048
+ renderDocumentsFromList(state.documents);
1049
+ checkApiStatus(); // Also refresh status bar
1050
+ } catch (e) {
1051
+ console.warn('Could not refresh documents:', e);
1052
+ }
1053
+ }
1054
+
1055
+ function renderDocumentsFromList(docs) {
1056
+ if (!docs || docs.length === 0) {
1057
+ els.documentList.innerHTML = `
1058
+ <div class="empty-docs">
1059
+ <span class="empty-docs-icon">πŸ“­</span>
1060
+ <span>Sin documentos aΓΊn</span>
1061
+ </div>
1062
+ `;
1063
+ return;
1064
+ }
1065
+
1066
+ const docIcons = { cv: 'πŸ“‹', job_offer: 'πŸ’Ό', linkedin: 'πŸ‘€', other: 'πŸ“„' };
1067
+
1068
+ els.documentList.innerHTML = docs.map(doc => {
1069
+ const icon = 'πŸ“„'; // Simple icon for filenames from backend
1070
+ return `
1071
+ <div class="doc-item">
1072
+ <span class="doc-icon">${icon}</span>
1073
+ <span class="doc-name">${doc}</span>
1074
+ <button class="doc-remove" onclick="removeDocument('${doc}')" title="Eliminar">πŸ—‘οΈ</button>
1075
+ </div>
1076
+ `;
1077
+ }).join('');
1078
+ }
1079
+
1080
+ async function removeDocument(filename) {
1081
+ try {
1082
+ await apiDelete(`/api/documents/${encodeURIComponent(filename)}`);
1083
+ showToast(`πŸ—‘οΈ ${filename} eliminado`);
1084
+ await refreshDocuments();
1085
+ } catch (e) {
1086
+ showToast(`❌ Error: ${e.message} `);
1087
+ }
1088
+ }
1089
+
1090
+ window.removeDocument = removeDocument;
1091
+
1092
+ // ===== CHIPS =====
1093
+ function setupChips() {
1094
+ $$('.chip').forEach(chip => {
1095
+ chip.addEventListener('click', () => {
1096
+ const query = chip.dataset.query;
1097
+ if (query && !state.isStreaming) sendMessage(query);
1098
+ });
1099
+ });
1100
+ }
1101
+
1102
+ // ===== MESSAGES =====
1103
+ async function sendMessage(text) {
1104
+ if (state.isStreaming) return;
1105
+
1106
+ // API is pre-configured, no need to check
1107
+
1108
+ // Create conversation if needed
1109
+ if (!state.currentConversationId) {
1110
+ state.currentConversationId = Date.now().toString();
1111
+ state.conversations.unshift({
1112
+ id: state.currentConversationId,
1113
+ title: text.substring(0, 60) + (text.length > 60 ? '...' : ''),
1114
+ date: new Date().toISOString(),
1115
+ messages: [],
1116
+ });
1117
+ saveConversations();
1118
+ renderConversations();
1119
+ }
1120
+
1121
+ // Add user message
1122
+ const userMsg = { role: 'user', content: text };
1123
+ state.messages.push(userMsg);
1124
+
1125
+ showChat();
1126
+ renderMessages();
1127
+ scrollToBottom();
1128
+
1129
+ // Show typing indicator
1130
+ showTypingIndicator();
1131
+ state.isStreaming = true;
1132
+
1133
+ try {
1134
+ const headers = {
1135
+ 'Content-Type': 'application/json',
1136
+ 'X-Session-ID': state.sessionId
1137
+ };
1138
+ if (state.authToken) headers['Authorization'] = `Bearer ${state.authToken}`;
1139
+
1140
+ // Call streaming API
1141
+ const response = await fetch(`${API_BASE}/api/chat/stream`, {
1142
+ method: 'POST',
1143
+ headers: headers,
1144
+ body: JSON.stringify({
1145
+ query: text,
1146
+ chat_history: state.messages.slice(0, -1), // Exclude last message (the current query)
1147
+ mode: 'auto',
1148
+ }),
1149
+ });
1150
+
1151
+ if (!response.ok) {
1152
+ const err = await response.json().catch(() => ({ detail: 'Error de comunicaciΓ³n' }));
1153
+ throw new Error(err.detail);
1154
+ }
1155
+
1156
+ // Parse SSE stream
1157
+ const reader = response.body.getReader();
1158
+ const decoder = new TextDecoder();
1159
+ let fullResponse = '';
1160
+ let detectedMode = 'general';
1161
+
1162
+ hideTypingIndicator();
1163
+
1164
+ // Add placeholder AI message
1165
+ const aiMsg = { role: 'assistant', content: '' };
1166
+ state.messages.push(aiMsg);
1167
+ renderMessages();
1168
+
1169
+ while (true) {
1170
+ const { done, value } = await reader.read();
1171
+ if (done) break;
1172
+
1173
+ const text = decoder.decode(value, { stream: true });
1174
+ const lines = text.split('\n');
1175
+
1176
+ for (const line of lines) {
1177
+ if (line.startsWith('data: ')) {
1178
+ try {
1179
+ const data = JSON.parse(line.substring(6));
1180
+
1181
+ if (data.type === 'mode') {
1182
+ detectedMode = data.mode;
1183
+ } else if (data.type === 'token') {
1184
+ fullResponse += data.content;
1185
+ aiMsg.content = fullResponse;
1186
+ updateLastMessage(fullResponse);
1187
+ scrollToBottom();
1188
+ } else if (data.type === 'done') {
1189
+ // Streaming complete
1190
+ } else if (data.type === 'error') {
1191
+ throw new Error(data.error);
1192
+ }
1193
+ } catch (parseError) {
1194
+ // Skip malformed SSE lines
1195
+ if (parseError.message !== 'Unexpected end of JSON input') {
1196
+ console.warn('SSE parse error:', parseError);
1197
+ }
1198
+ }
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ // Final render with full markdown
1204
+ aiMsg.content = fullResponse;
1205
+ renderMessages();
1206
+ scrollToBottom();
1207
+
1208
+ // Save to conversation
1209
+ saveCurrentConversation();
1210
+
1211
+ } catch (e) {
1212
+ hideTypingIndicator();
1213
+ const errorMsg = { role: 'assistant', content: `❌ **Error:** ${e.message}\n\nVerifica tu API key y conexión.` };
1214
+ state.messages.push(errorMsg);
1215
+ renderMessages();
1216
+ scrollToBottom();
1217
+ } finally {
1218
+ state.isStreaming = false;
1219
+ }
1220
+ }
1221
+
1222
+ function updateLastMessage(content) {
1223
+ const messages = els.chatMessages.querySelectorAll('.message.ai');
1224
+ const lastMsg = messages[messages.length - 1];
1225
+ if (lastMsg) {
1226
+ const contentEl = lastMsg.querySelector('.message-content');
1227
+ if (contentEl) {
1228
+ contentEl.innerHTML = formatMarkdown(content) + '<span class="cursor-blink">β–Œ</span>';
1229
+ }
1230
+ }
1231
+ }
1232
+
1233
+ function renderMessages() {
1234
+ els.chatMessages.innerHTML = state.messages.map((msg, i) => {
1235
+ if (msg.role === 'user') {
1236
+ const hasPic = state.currentUser && state.currentUser.picture;
1237
+ const avatarContent = hasPic
1238
+ ? `<img src="${state.currentUser.picture}" style="width:100%; height:100%; border-radius:50%; object-fit:cover;">`
1239
+ : `πŸ§‘β€πŸ’»`;
1240
+
1241
+ const avatarStyle = hasPic
1242
+ ? 'padding:0; overflow:hidden; background:transparent; border:none; border-radius:50%;'
1243
+ : 'padding:0; overflow:hidden;';
1244
+
1245
+ return `
1246
+ <div class="message user" data-index="${i}">
1247
+ <div class="message-inner">
1248
+ <div class="message-avatar user" style="${avatarStyle}">${avatarContent}</div>
1249
+ <div class="message-body">
1250
+ <div class="message-author">${state.currentUser?.name || 'TΓΊ'}</div>
1251
+ <div class="message-content">${escapeHtml(msg.content)}</div>
1252
+ </div>
1253
+ </div>
1254
+ </div>
1255
+ `;
1256
+ } else {
1257
+ const modelIcon = state.currentModel === 'llama-3.1-8b-instant' ? '/static/icon-flash.png' : 'https://i.postimg.cc/tJ32Jnph/image.png';
1258
+ const modelLabel = state.currentModel === 'llama-3.1-8b-instant' ? 'CareerAI Flash' : 'CareerAI Pro';
1259
+ return `
1260
+ <div class="message ai" data-index="${i}">
1261
+ <div class="message-inner">
1262
+ <div class="message-avatar ai" style="background:transparent; border:none; padding:0;">
1263
+ <img src="${modelIcon}" alt="${modelLabel}" style="width:24px;height:24px;max-width:24px;max-height:24px;object-fit:contain;">
1264
+ </div>
1265
+ <div class="message-body">
1266
+ <div class="message-author">${modelLabel}</div>
1267
+ <div class="message-content">${formatMarkdown(msg.content)}</div>
1268
+ <div class="message-actions">
1269
+ <button class="action-btn" onclick="copyMessage(${i})" title="Copiar">
1270
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1271
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
1272
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
1273
+ </svg>
1274
+ </button>
1275
+ <button class="action-btn" onclick="exportMessage(${i}, 'pdf')" title="Descargar PDF">
1276
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1277
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1278
+ <polyline points="7 10 12 15 17 10" />
1279
+ <line x1="12" y1="15" x2="12" y2="3" />
1280
+ </svg>
1281
+ </button>
1282
+ <button class="action-btn" onclick="likeMessage(${i})" title="Me gusta">
1283
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1284
+ <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
1285
+ </svg>
1286
+ </button>
1287
+ <button class="action-btn" onclick="dislikeMessage(${i})" title="No me gusta">
1288
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1289
+ <path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" />
1290
+ </svg>
1291
+ </button>
1292
+ </div>
1293
+ </div>
1294
+ </div>
1295
+ </div>
1296
+ `;
1297
+ }
1298
+ }).join('');
1299
+ }
1300
+
1301
+ function showTypingIndicator() {
1302
+ const indicator = document.createElement('div');
1303
+ indicator.id = 'typingIndicator';
1304
+ indicator.className = 'message ai';
1305
+ const modelIcon = state.currentModel === 'llama-3.1-8b-instant' ? '/static/icon-flash.png' : 'https://i.postimg.cc/tJ32Jnph/image.png';
1306
+ const modelLabel = state.currentModel === 'llama-3.1-8b-instant' ? 'CareerAI Flash' : 'CareerAI Pro';
1307
+ indicator.innerHTML = `
1308
+ <div class="message-inner">
1309
+ <div class="message-avatar ai" style="background:transparent; border:none; padding:0;">
1310
+ <img src="${modelIcon}" alt="${modelLabel}" style="width:24px;height:24px;max-width:24px;max-height:24px;object-fit:contain;">
1311
+ </div>
1312
+ <div class="message-body">
1313
+ <div class="message-author">${modelLabel}</div>
1314
+ <div class="typing-indicator">
1315
+ <div class="typing-dot"></div>
1316
+ <div class="typing-dot"></div>
1317
+ <div class="typing-dot"></div>
1318
+ </div>
1319
+ </div>
1320
+ </div>
1321
+ `;
1322
+ els.chatMessages.appendChild(indicator);
1323
+ scrollToBottom();
1324
+ }
1325
+
1326
+ function hideTypingIndicator() {
1327
+ const indicator = document.getElementById('typingIndicator');
1328
+ if (indicator) indicator.remove();
1329
+ }
1330
+
1331
+ function scrollToBottom() {
1332
+ requestAnimationFrame(() => {
1333
+ els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
1334
+ });
1335
+ }
1336
+
1337
+ // ===== MESSAGE ACTIONS =====
1338
+ function copyMessage(index) {
1339
+ const msg = state.messages[index];
1340
+ if (msg) {
1341
+ navigator.clipboard.writeText(msg.content).then(() => {
1342
+ showToast('βœ… Copiado al portapapeles');
1343
+ });
1344
+ }
1345
+ }
1346
+
1347
+ async function exportMessage(index, format) {
1348
+ const msg = state.messages[index];
1349
+ if (!msg) return;
1350
+
1351
+ showToast(`πŸ“„ Exportando ${format.toUpperCase()}...`);
1352
+
1353
+ try {
1354
+ const res = await fetch(`${API_BASE}/api/export`, {
1355
+ method: 'POST',
1356
+ headers: { 'Content-Type': 'application/json' },
1357
+ body: JSON.stringify({ content: msg.content, format }),
1358
+ });
1359
+
1360
+ if (!res.ok) throw new Error('Export failed');
1361
+
1362
+ const blob = await res.blob();
1363
+ const disposition = res.headers.get('Content-Disposition') || '';
1364
+ const filenameMatch = disposition.match(/filename="?(.+?)"?$/);
1365
+ const filename = filenameMatch ? filenameMatch[1] : `CareerAI_Export.${format} `;
1366
+
1367
+ // Download
1368
+ const url = URL.createObjectURL(blob);
1369
+ const a = document.createElement('a');
1370
+ a.href = url;
1371
+ a.download = filename;
1372
+ document.body.appendChild(a);
1373
+ a.click();
1374
+ a.remove();
1375
+ URL.revokeObjectURL(url);
1376
+
1377
+ showToast(`βœ… ${filename} descargado`);
1378
+ } catch (e) {
1379
+ showToast(`❌ Error al exportar: ${e.message} `);
1380
+ }
1381
+ }
1382
+
1383
+ function likeMessage(index) {
1384
+ showToast('πŸ‘ Β‘Gracias por tu feedback!');
1385
+ }
1386
+
1387
+ function dislikeMessage(index) {
1388
+ showToast('πŸ‘Ž Feedback registrado');
1389
+ }
1390
+
1391
+ window.copyMessage = copyMessage;
1392
+ window.exportMessage = exportMessage;
1393
+ window.likeMessage = likeMessage;
1394
+ window.dislikeMessage = dislikeMessage;
1395
+
1396
+ // ===== CONVERSATIONS (Backend & Local fallback) =====
1397
+ async function saveConversations() {
1398
+ // Save to local storage as fallback
1399
+ localStorage.setItem('careerai_conversations', JSON.stringify(state.conversations.slice(0, 50)));
1400
+ }
1401
+
1402
+ async function saveCurrentConversation() {
1403
+ if (!state.currentConversationId) return;
1404
+ const convIndex = state.conversations.findIndex(c => c.id === state.currentConversationId);
1405
+
1406
+ if (convIndex !== -1) {
1407
+ state.conversations[convIndex].messages = [...state.messages];
1408
+ state.conversations[convIndex].date = new Date().toISOString();
1409
+ saveConversations();
1410
+
1411
+ // Save to backend if logged in
1412
+ if (state.authToken) {
1413
+ try {
1414
+ await apiPost('/api/conversations', {
1415
+ id: state.currentConversationId,
1416
+ title: state.conversations[convIndex].title,
1417
+ messages: state.messages
1418
+ });
1419
+ } catch (e) {
1420
+ console.error("Failed to save to cloud:", e);
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ function renderConversations() {
1427
+ if (state.conversations.length === 0) {
1428
+ els.conversationList.innerHTML = '<div class="empty-docs"><span>Sin conversaciones</span></div>';
1429
+ return;
1430
+ }
1431
+
1432
+ els.conversationList.innerHTML = state.conversations.slice(0, 20).map(conv => `
1433
+ <div class="conversation-item ${conv.id === state.currentConversationId ? 'active' : ''}"
1434
+ onclick="loadConversation('${conv.id}')"
1435
+ data-id="${conv.id}">
1436
+ <span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
1437
+ ${escapeHtml(conv.title)}
1438
+ </span>
1439
+ <button class="conversation-delete" onclick="deleteConversation(event, '${conv.id}')" title="Eliminar">
1440
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1441
+ <polyline points="3 6 5 6 21 6"></polyline>
1442
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
1443
+ </svg>
1444
+ </button>
1445
+ </div>
1446
+ `).join('');
1447
+ }
1448
+
1449
+ function loadConversation(id) {
1450
+ const conv = state.conversations.find(c => c.id === id);
1451
+ if (conv) {
1452
+ state.currentConversationId = id;
1453
+ state.messages = conv.messages || [];
1454
+ renderConversations();
1455
+ if (state.messages.length > 0) {
1456
+ showChat();
1457
+ renderMessages();
1458
+ scrollToBottom();
1459
+ } else {
1460
+ showWelcome();
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ window.loadConversation = loadConversation;
1466
+
1467
+ function newChat() {
1468
+ state.messages = [];
1469
+ state.currentConversationId = null;
1470
+ showWelcome();
1471
+ renderConversations();
1472
+ }
1473
+
1474
+ async function deleteConversation(event, id) {
1475
+ event.stopPropagation();
1476
+ if (confirm('ΒΏEstΓ‘s seguro de que deseas eliminar esta conversaciΓ³n?')) {
1477
+ state.conversations = state.conversations.filter(c => c.id !== id);
1478
+ if (state.currentConversationId === id) {
1479
+ state.currentConversationId = null;
1480
+ state.messages = [];
1481
+ showWelcome();
1482
+ }
1483
+ saveConversations();
1484
+ renderConversations();
1485
+
1486
+ // Delete from backend if logged in
1487
+ if (state.authToken) {
1488
+ try {
1489
+ await apiDelete(`/api/conversations/${id}`);
1490
+ } catch (e) {
1491
+ console.error("Failed to delete from cloud:", e);
1492
+ }
1493
+ }
1494
+
1495
+ showToast('πŸ—‘οΈ ConversaciΓ³n eliminada');
1496
+ }
1497
+ }
1498
+ window.deleteConversation = deleteConversation;
1499
+
1500
+ // ===== VIEW TOGGLE =====
1501
+ function showWelcome() {
1502
+ hideDashboardPage();
1503
+ els.welcomeScreen.classList.remove('hidden');
1504
+ els.welcomeScreen.style.display = '';
1505
+ els.chatScreen.classList.add('hidden');
1506
+ els.chatScreen.style.display = 'none';
1507
+ els.welcomeInput.focus();
1508
+ }
1509
+
1510
+ function showChat() {
1511
+ hideDashboardPage();
1512
+ els.welcomeScreen.classList.add('hidden');
1513
+ els.welcomeScreen.style.display = 'none';
1514
+ els.chatScreen.classList.remove('hidden');
1515
+ els.chatScreen.style.display = '';
1516
+ els.chatInput.focus();
1517
+ }
1518
+
1519
+ // ===== UTILITIES =====
1520
+ function escapeHtml(text) {
1521
+ const div = document.createElement('div');
1522
+ div.textContent = text;
1523
+ return div.innerHTML;
1524
+ }
1525
+
1526
+ function formatMarkdown(text) {
1527
+ let html = escapeHtml(text);
1528
+
1529
+ // Headers
1530
+ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
1531
+ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
1532
+ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
1533
+
1534
+ // Bold
1535
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1536
+
1537
+ // Italic
1538
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
1539
+
1540
+ // Inline code
1541
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
1542
+
1543
+ // Code blocks
1544
+ html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
1545
+
1546
+ // Blockquotes
1547
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
1548
+
1549
+ // Horizontal rule
1550
+ html = html.replace(/^---$/gm, '<hr>');
1551
+
1552
+ // Tables
1553
+ const lines = html.split('\n');
1554
+ let inTable = false;
1555
+ let tableHtml = '';
1556
+ const result = [];
1557
+
1558
+ for (let i = 0; i < lines.length; i++) {
1559
+ const line = lines[i].trim();
1560
+ if (line.startsWith('|') && line.endsWith('|')) {
1561
+ if (!inTable) {
1562
+ inTable = true;
1563
+ tableHtml = '<table>';
1564
+ }
1565
+ if (line.match(/^\|[\s\-|]+\|$/)) continue;
1566
+
1567
+ const cells = line.split('|').filter(c => c.trim());
1568
+ const isHeader = i < lines.length - 1 && lines[i + 1] && lines[i + 1].trim().match(/^\|[\s\-|]+\|$/);
1569
+ const tag = isHeader ? 'th' : 'td';
1570
+ tableHtml += '<tr>' + cells.map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>';
1571
+ } else {
1572
+ if (inTable) {
1573
+ inTable = false;
1574
+ tableHtml += '</table>';
1575
+ result.push(tableHtml);
1576
+ tableHtml = '';
1577
+ }
1578
+ result.push(line);
1579
+ }
1580
+ }
1581
+ if (inTable) {
1582
+ tableHtml += '</table>';
1583
+ result.push(tableHtml);
1584
+ }
1585
+
1586
+ html = result.join('\n');
1587
+
1588
+ // Unordered lists
1589
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
1590
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
1591
+
1592
+ // Ordered lists
1593
+ html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
1594
+
1595
+ // Paragraphs
1596
+ html = html.replace(/^(?!<[hupoltb]|<\/|<li|<bl|<hr|$)(.+)$/gm, '<p>$1</p>');
1597
+
1598
+ // Clean up
1599
+ html = html.replace(/\n{2,}/g, '');
1600
+ html = html.replace(/\n/g, '');
1601
+
1602
+ return html;
1603
+ }
1604
+
1605
+ // ===== TOAST =====
1606
+ function showToast(message) {
1607
+ let toast = document.querySelector('.toast');
1608
+ if (!toast) {
1609
+ toast = document.createElement('div');
1610
+ toast.className = 'toast';
1611
+ document.body.appendChild(toast);
1612
+ }
1613
+
1614
+ toast.textContent = message;
1615
+ toast.classList.add('show');
1616
+
1617
+ clearTimeout(toast._timeout);
1618
+ toast._timeout = setTimeout(() => {
1619
+ toast.classList.remove('show');
1620
+ }, 2800);
1621
+ }
1622
+
1623
+ // ===== KEYBOARD SHORTCUTS =====
1624
+ document.addEventListener('keydown', (e) => {
1625
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
1626
+ e.preventDefault();
1627
+ els.searchInput.focus();
1628
+ }
1629
+
1630
+ if (e.key === 'Escape') {
1631
+ els.modelDropdown.classList.add('hidden');
1632
+ closeUploadModal();
1633
+ document.getElementById('apiConfigModal')?.remove();
1634
+ }
1635
+ });
1636
+
1637
+ // ===== CSS for cursor blink =====
1638
+ const style = document.createElement('style');
1639
+ style.textContent = `
1640
+ .cursor-blink {
1641
+ animation: cursorBlink 1s step-end infinite;
1642
+ color: var(--accent-primary);
1643
+ font-weight: 300;
1644
+ }
1645
+ @keyframes cursorBlink {
1646
+ 0%, 100% { opacity: 1; }
1647
+ 50% { opacity: 0; }
1648
+ }
1649
+ `;
1650
+ document.head.appendChild(style);
1651
+
1652
+ // ===== JOBS PANEL =====
1653
+
1654
+ // Custom dropdown helpers
1655
+ window.toggleJobsDropdown = function (id) {
1656
+ const el = document.getElementById(id);
1657
+ if (!el) return;
1658
+ const isOpen = el.classList.contains('open');
1659
+ // Close all first
1660
+ document.querySelectorAll('.jobs-custom-select.open').forEach(d => d.classList.remove('open'));
1661
+ if (!isOpen) el.classList.add('open');
1662
+ };
1663
+
1664
+ window.selectJobsOption = function (dropdownId, selectId, value, label) {
1665
+ // Update hidden select value
1666
+ const sel = document.getElementById(selectId);
1667
+ if (sel) sel.value = value;
1668
+ // Update visible label
1669
+ const labelEl = document.getElementById(dropdownId + 'Label');
1670
+ if (labelEl) labelEl.textContent = label;
1671
+ // Mark active option
1672
+ const menu = document.getElementById(dropdownId + 'Menu');
1673
+ if (menu) {
1674
+ menu.querySelectorAll('.jobs-select-option').forEach(o => o.classList.remove('active'));
1675
+ event?.target?.classList.add('active');
1676
+ }
1677
+ // Close
1678
+ document.getElementById(dropdownId)?.classList.remove('open');
1679
+ };
1680
+
1681
+ // Close dropdowns when clicking outside
1682
+ document.addEventListener('click', (e) => {
1683
+ if (!e.target.closest('.jobs-custom-select')) {
1684
+ document.querySelectorAll('.jobs-custom-select.open').forEach(d => d.classList.remove('open'));
1685
+ }
1686
+ });
1687
+ window.openJobsPanel = function () {
1688
+ const panel = document.getElementById('jobsPanel');
1689
+ const overlay = document.getElementById('jobsPanelOverlay');
1690
+ if (!panel) return;
1691
+ panel.style.display = 'flex';
1692
+ overlay.style.display = 'block';
1693
+ // Slide in animation
1694
+ panel.style.transform = 'translateX(100%)';
1695
+ panel.style.transition = 'transform 0.3s cubic-bezier(0.4,0,0.2,1)';
1696
+ requestAnimationFrame(() => { panel.style.transform = 'translateX(0)'; });
1697
+
1698
+ // Bind Enter on search input
1699
+ const inp = document.getElementById('jobsSearchInput');
1700
+ if (inp && !inp._jobsBound) {
1701
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') loadJobs(); });
1702
+ inp._jobsBound = true;
1703
+ }
1704
+ };
1705
+
1706
+ window.closeJobsPanel = function () {
1707
+ const panel = document.getElementById('jobsPanel');
1708
+ const overlay = document.getElementById('jobsPanelOverlay');
1709
+ if (!panel) return;
1710
+ panel.style.transform = 'translateX(100%)';
1711
+ setTimeout(() => {
1712
+ panel.style.display = 'none';
1713
+ overlay.style.display = 'none';
1714
+ }, 300);
1715
+ };
1716
+
1717
+ window.autoFillJobSearch = async function () {
1718
+ // Try to pull CV text from the RAG document list
1719
+ const docs = state.documents;
1720
+ if (!docs || docs.length === 0) {
1721
+ showToast('⚠️ Primero sube tu CV en el panel de Documentos', 'warning');
1722
+ return;
1723
+ }
1724
+ // Use the first loaded document name as a query hint
1725
+ // Then ask the AI to extract job title keywords
1726
+ showToast('πŸ€– Extrayendo perfil del CV...', 'info');
1727
+ try {
1728
+ const res = await apiPost('/api/chat', {
1729
+ query: 'BasΓ‘ndote en mi CV, responde SOLO con el tΓ­tulo de puesto mΓ‘s especΓ­fico y relevante para buscar empleo, en mΓ‘ximo 4 palabras. Por ejemplo: "Desarrollador Full Stack" o "DiseΓ±ador UX Senior". Sin explicaciones, sin puntos, sin listas. Solo el tΓ­tulo.',
1730
+ chat_history: [],
1731
+ mode: 'general'
1732
+ });
1733
+ const keywords = res.response?.trim().replace(/\n/g, ' ').replace(/["'*]/g, '').slice(0, 60) || '';
1734
+ if (keywords) {
1735
+ document.getElementById('jobsSearchInput').value = keywords;
1736
+ showToast('βœ… Puesto detectado: ' + keywords);
1737
+ loadJobs();
1738
+ }
1739
+ } catch (e) {
1740
+ showToast('❌ No se pudo extraer el perfil: ' + e.message);
1741
+ }
1742
+ };
1743
+
1744
+ window.loadJobs = async function () {
1745
+ const query = document.getElementById('jobsSearchInput').value.trim();
1746
+ if (!query) {
1747
+ showToast('⚠️ Escribe qué empleo quieres buscar', 'warning');
1748
+ return;
1749
+ }
1750
+
1751
+ const country = document.getElementById('jobsCountry').value;
1752
+ const datePosted = document.getElementById('jobsDatePosted').value;
1753
+ const remoteOnly = document.getElementById('jobsRemoteOnly').checked;
1754
+
1755
+ const btn = document.getElementById('jobsSearchBtn');
1756
+ const resultsEl = document.getElementById('jobsResults');
1757
+ const footerEl = document.getElementById('jobsFooter');
1758
+
1759
+ // Show skeletons
1760
+ btn.innerHTML = '<span class="spinner" style="width:14px;height:14px;margin:auto;"></span>';
1761
+ btn.style.pointerEvents = 'none';
1762
+ resultsEl.innerHTML = Array(5).fill(`
1763
+ <div style="border:1px solid var(--border-medium); border-radius:12px; padding:16px; animation: pulse 1.5s ease-in-out infinite; background:var(--bg-hover);">
1764
+ <div style="height:14px; background:var(--border-medium); border-radius:6px; width:60%; margin-bottom:10px;"></div>
1765
+ <div style="height:11px; background:var(--border-medium); border-radius:6px; width:40%; margin-bottom:8px;"></div>
1766
+ <div style="height:11px; background:var(--border-medium); border-radius:6px; width:80%;"></div>
1767
+ </div>
1768
+ `).join('');
1769
+ footerEl.style.display = 'none';
1770
+
1771
+ try {
1772
+ let url = `/api/jobs?query=${encodeURIComponent(query)}&date_posted=${datePosted}&num_pages=1`;
1773
+ if (country) url += `&country=${country}`;
1774
+ if (remoteOnly) url += `&remote_only=true`;
1775
+
1776
+ const data = await apiGet(url);
1777
+ const jobs = data.jobs || [];
1778
+
1779
+ if (jobs.length === 0) {
1780
+ resultsEl.innerHTML = `
1781
+ <div style="text-align:center; padding:50px 20px; color:var(--text-tertiary);">
1782
+ <div style="font-size:2.5rem; margin-bottom:12px;">πŸ˜”</div>
1783
+ <p style="font-weight:600; color:var(--text-secondary);">Sin resultados</p>
1784
+ <p style="font-size:0.85rem;">Prueba con otros tΓ©rminos o cambia los filtros.</p>
1785
+ </div>`;
1786
+ } else {
1787
+ resultsEl.innerHTML = jobs.map(j => renderJobCard(j)).join('');
1788
+ footerEl.style.display = 'block';
1789
+ footerEl.textContent = `Mostrando ${jobs.length} ofertas Β· LinkedIn Β· Indeed Β· Glassdoor Β· mΓ‘s`;
1790
+ }
1791
+ } catch (err) {
1792
+ resultsEl.innerHTML = `<div style="text-align:center; padding:40px; color:#ef4444;">❌ Error: ${err.message}</div>`;
1793
+ } finally {
1794
+ btn.innerHTML = 'Buscar';
1795
+ btn.style.pointerEvents = 'auto';
1796
+ }
1797
+ };
1798
+
1799
+ function renderJobCard(j) {
1800
+ const remoteTag = j.is_remote
1801
+ ? `<span style="background:rgba(16,185,129,0.15); color:#10b981; font-size:0.72rem; padding:2px 8px; border-radius:20px; font-weight:600;">🏠 Remoto</span>`
1802
+ : '';
1803
+ const typeTag = j.employment_type
1804
+ ? `<span style="background:var(--bg-hover); color:var(--text-secondary); font-size:0.72rem; padding:2px 8px; border-radius:20px; border:1px solid var(--border-medium);">${j.employment_type}</span>`
1805
+ : '';
1806
+ const salaryTag = j.salary
1807
+ ? `<div style="font-size:0.8rem; color:#10b981; font-weight:600; margin-top:6px;">πŸ’° ${j.salary}</div>`
1808
+ : '';
1809
+ const posted = j.posted_at ? new Date(j.posted_at).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' }) : '';
1810
+ const logo = j.company_logo
1811
+ ? `<img src="${j.company_logo}" style="width:36px;height:36px;object-fit:contain;border-radius:6px;background:white;padding:2px;" onerror="this.style.display='none'">`
1812
+ : `<div style="width:36px;height:36px;border-radius:6px;background:var(--bg-hover);display:flex;align-items:center;justify-content:center;font-size:1.1rem;">🏒</div>`;
1813
+
1814
+ return `
1815
+ <div style="border:1px solid var(--border-medium); border-radius:12px; padding:16px; background:var(--bg-primary); transition:border-color 0.2s, box-shadow 0.2s;"
1816
+ onmouseover="this.style.borderColor='var(--accent-primary)';this.style.boxShadow='0 2px 16px rgba(139,92,246,0.12)'"
1817
+ onmouseout="this.style.borderColor='var(--border-medium)';this.style.boxShadow='none'">
1818
+ <div style="display:flex; gap:12px; align-items:flex-start;">
1819
+ ${logo}
1820
+ <div style="flex:1; min-width:0;">
1821
+ <div style="font-size:0.95rem; font-weight:700; color:var(--text-primary); margin-bottom:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${j.title}</div>
1822
+ <div style="font-size:0.82rem; color:var(--text-secondary); margin-bottom:6px;">${j.company} Β· ${j.location || 'Sin ubicaciΓ³n'}</div>
1823
+ <div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center; margin-bottom:8px;">
1824
+ ${remoteTag}${typeTag}
1825
+ ${posted ? `<span style="font-size:0.72rem; color:var(--text-tertiary); margin-left:auto;">${posted}</span>` : ''}
1826
+ </div>
1827
+ ${j.description_snippet ? `<p style="font-size:0.8rem; color:var(--text-tertiary); margin:0 0 8px; line-height:1.5; display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${j.description_snippet}</p>` : ''}
1828
+ ${salaryTag}
1829
+ </div>
1830
+ </div>
1831
+ <div style="margin-top:12px; text-align:right;">
1832
+ <a href="${j.apply_link}" target="_blank" rel="noopener"
1833
+ style="display:inline-flex; align-items:center; gap:6px; background:var(--accent-primary); color:white; font-size:0.82rem; font-weight:600; padding:7px 16px; border-radius:8px; text-decoration:none; transition:opacity 0.2s;"
1834
+ onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">
1835
+ Aplicar
1836
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
1837
+ </a>
1838
+ </div>
1839
+ </div>`;
1840
+ }
1841
+
frontend/favicon.png ADDED

Git LFS Details

  • SHA256: aebfe6a4e2c6306e4f6d25cfb23eea593a7169159cf557c082d063f907c66623
  • Pointer size: 131 Bytes
  • Size of remote file: 212 kB
frontend/icon-flash.png ADDED

Git LFS Details

  • SHA256: a5fbccf34e4824c3497fd36216239c91695a291447f8d5cd595907af1964384b
  • Pointer size: 130 Bytes
  • Size of remote file: 25.9 kB
frontend/icon-pro.png ADDED

Git LFS Details

  • SHA256: 9d67607d8de4f441dfd12cbc7891df78305c6bf1bba013cb9fde4f92ad358626
  • Pointer size: 130 Bytes
  • Size of remote file: 61 kB
frontend/index.html ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>CareerAI β€” Tu Asistente Inteligente de Carrera</title>
8
+ <meta name="description"
9
+ content="Analiza tu carrera con inteligencia artificial. Sube tu CV, ofertas o perfil de LinkedIn y conversa con un asistente AI entrenado sobre tu trayectoria profesional.">
10
+ <link rel="icon" type="image/png" href="/static/favicon.png">
11
+ <link rel="apple-touch-icon" href="/static/favicon.png">
12
+ <link rel="preconnect" href="https://fonts.googleapis.com">
13
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
14
+ <link
15
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Styrene+A:wght@400;500;700&display=swap"
16
+ rel="stylesheet">
17
+ <link rel="stylesheet" href="/static/styles.css?v=2">
18
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
19
+ </head>
20
+
21
+ <body>
22
+ <!-- ===== SIDEBAR ===== -->
23
+ <aside class="sidebar" id="sidebar">
24
+ <div class="sidebar-inner">
25
+ <!-- Top actions -->
26
+ <div class="sidebar-top">
27
+ <button class="sidebar-icon-btn" id="toggleSidebar" title="Cerrar sidebar">
28
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
29
+ stroke-linecap="round" stroke-linejoin="round">
30
+ <rect x="3" y="3" width="18" height="18" rx="2" />
31
+ <line x1="9" y1="3" x2="9" y2="21" />
32
+ </svg>
33
+ </button>
34
+ <button class="sidebar-icon-btn" id="newChatBtn" title="Nueva conversaciΓ³n">
35
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
36
+ stroke-linecap="round" stroke-linejoin="round">
37
+ <path d="M12 20h9" />
38
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
39
+ </svg>
40
+ </button>
41
+ </div>
42
+
43
+ <!-- Search -->
44
+ <div class="sidebar-search">
45
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
46
+ stroke-linecap="round" stroke-linejoin="round">
47
+ <circle cx="11" cy="11" r="8" />
48
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
49
+ </svg>
50
+ <input type="text" placeholder="Buscar conversaciones..." id="searchInput">
51
+ </div>
52
+
53
+ <!-- Nav items -->
54
+ <nav class="sidebar-nav">
55
+ <a href="#" class="nav-item active" data-page="chat">
56
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
57
+ stroke-linecap="round" stroke-linejoin="round">
58
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
59
+ </svg>
60
+ <span>Chat</span>
61
+ </a>
62
+ <a href="#" class="nav-item" data-page="documents">
63
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
64
+ stroke-linecap="round" stroke-linejoin="round">
65
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
66
+ <polyline points="14 2 14 8 20 8" />
67
+ </svg>
68
+ <span>Documentos</span>
69
+ </a>
70
+ <a href="#" class="nav-item" data-page="dashboard">
71
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
72
+ stroke-linecap="round" stroke-linejoin="round">
73
+ <line x1="18" y1="20" x2="18" y2="10" />
74
+ <line x1="12" y1="20" x2="12" y2="4" />
75
+ <line x1="6" y1="20" x2="6" y2="14" />
76
+ </svg>
77
+ <span>Dashboard</span>
78
+ </a>
79
+ <a href="#" class="nav-item" data-page="settings" style="display:none;">
80
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
81
+ stroke-linecap="round" stroke-linejoin="round">
82
+ <circle cx="12" cy="12" r="3" />
83
+ <path
84
+ d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
85
+ </svg>
86
+ <span>ConfiguraciΓ³n</span>
87
+ </a>
88
+ <a href="#" class="nav-item" id="jobsNavBtn" onclick="event.preventDefault(); openJobsPanel()">
89
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
90
+ stroke-linecap="round" stroke-linejoin="round">
91
+ <rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
92
+ <path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
93
+ </svg>
94
+ <span>Empleos</span>
95
+ </a>
96
+ </nav>
97
+
98
+ <!-- Recent conversations -->
99
+ <div class="sidebar-section">
100
+ <div class="sidebar-section-label">Recientes</div>
101
+ <div class="conversation-list" id="conversationList">
102
+ <!-- Populated by JS -->
103
+ </div>
104
+ </div>
105
+
106
+ <!-- Documents section -->
107
+ <div class="sidebar-section">
108
+ <div class="sidebar-section-label">πŸ“„ Documentos cargados</div>
109
+ <div class="document-list" id="documentList">
110
+ <div class="empty-docs">
111
+ <span class="empty-docs-icon">πŸ“­</span>
112
+ <span>Sin documentos aΓΊn</span>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ <!-- Footer -->
118
+ <div class="sidebar-footer">
119
+ <div class="sidebar-plan">
120
+ <span>Plan Gratuito</span>
121
+ <span class="plan-separator">Β·</span>
122
+ <a href="#" class="plan-upgrade">Actualizar</a>
123
+ </div>
124
+ <div class="sidebar-user" id="userMenu">
125
+ <div class="user-avatar">MY</div>
126
+ <span class="user-name">Mi Cuenta</span>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </aside>
131
+
132
+ <!-- Mobile sidebar toggle -->
133
+ <button class="mobile-sidebar-toggle" id="mobileSidebarToggle" aria-label="Abrir menΓΊ">
134
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
135
+ stroke-linecap="round" stroke-linejoin="round">
136
+ <rect x="3" y="3" width="18" height="18" rx="2" />
137
+ <line x1="9" y1="3" x2="9" y2="21" />
138
+ </svg>
139
+ </button>
140
+
141
+ <!-- ===== MAIN CONTENT ===== -->
142
+ <main class="main-content" id="mainContent">
143
+ <!-- Notification bar -->
144
+ <div class="notification-bar" id="notificationBar">
145
+ <span>Plan gratuito</span>
146
+ <span class="notification-separator">Β·</span>
147
+ <a href="#" class="notification-link">Actualizar</a>
148
+ </div>
149
+
150
+ <!-- ===== WELCOME SCREEN ===== -->
151
+ <div class="welcome-screen" id="welcomeScreen">
152
+ <!-- Logo -->
153
+ <div class="welcome-logo"
154
+ style="display:flex; align-items:center; justify-content:center; gap: 14px; margin-bottom:12px;">
155
+ <svg width="56" height="56" viewBox="0 0 40 40" fill="none">
156
+ <!-- Brain -->
157
+ <path
158
+ d="M 21 30 C 21 30 20 31 16 31 C 11 31 10 27 10 24 C 10 22 11 20 13 18 C 11 15 13 11 17 11 C 19 11 20 12 21 14"
159
+ stroke="var(--accent-secondary)" stroke-width="2.5" stroke-linecap="round"
160
+ stroke-linejoin="round" />
161
+ <path
162
+ d="M 21 14 C 22 12 23 11 25 11 C 29 11 31 15 29 18 C 31 20 32 22 32 24 C 32 27 31 31 26 31 C 22 31 21 30 21 30 V 14 Z"
163
+ stroke="var(--accent-secondary)" stroke-width="2.5" stroke-linecap="round"
164
+ stroke-linejoin="round" />
165
+ <path d="M 14 24 H 17 M 15 20 H 18 M 28 24 H 25 M 27 20 H 24 M 21 18 V 26"
166
+ stroke="var(--accent-secondary)" stroke-width="2.5" stroke-linecap="round"
167
+ stroke-linejoin="round" />
168
+ <!-- Green arrow -->
169
+ <path d="M 10 24 L 25 9" stroke="var(--accent-primary)" stroke-width="3" stroke-linecap="round"
170
+ stroke-linejoin="round" />
171
+ <polyline points="17 9 25 9 25 17" stroke="var(--accent-primary)" stroke-width="3"
172
+ stroke-linecap="round" stroke-linejoin="round" />
173
+ </svg>
174
+ <div
175
+ style="font-size:3.5rem; font-weight:600; letter-spacing:-0.03em; color:var(--text-primary); line-height:1;">
176
+ Career<span style="color:var(--text-primary);">a</span><span
177
+ style="color:var(--accent-primary);">i</span>
178
+ </div>
179
+ </div>
180
+
181
+ <!-- Welcome heading -->
182
+ <h1 class="welcome-heading"
183
+ style="font-size:1.4rem; color:var(--text-secondary); margin-bottom:36px; font-weight:400; font-family:var(--font-family);">
184
+ Tu asistente inteligente de carrera</h1>
185
+
186
+ <!-- Input box -->
187
+ <div class="welcome-input-container">
188
+ <div class="welcome-input-wrapper">
189
+ <textarea class="welcome-input" id="welcomeInput" placeholder="ΒΏCΓ³mo puedo ayudarte hoy?"
190
+ rows="1"></textarea>
191
+ <div class="welcome-input-actions">
192
+ <button class="input-action-btn" id="attachBtn" title="Adjuntar archivo">
193
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
194
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
195
+ <line x1="12" y1="5" x2="12" y2="19" />
196
+ <line x1="5" y1="12" x2="19" y2="12" />
197
+ </svg>
198
+ </button>
199
+ <div class="input-right-actions">
200
+ <div class="model-selector" id="modelSelector">
201
+ <span class="model-name">CareerAI Pro</span>
202
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
203
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
204
+ <polyline points="6 9 12 15 18 9" />
205
+ </svg>
206
+ </div>
207
+ <button class="send-btn" id="sendBtn" title="Enviar" disabled>
208
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
209
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
210
+ <line x1="22" y1="2" x2="11" y2="13" />
211
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
212
+ </svg>
213
+ </button>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <!-- Suggestion chips -->
220
+ <div class="suggestion-chips">
221
+ <button class="chip" data-query="Analiza mi CV y dame un resumen profesional">
222
+ <span class="chip-icon">&lt;/&gt;</span>
223
+ <span>Analizar CV</span>
224
+ </button>
225
+ <button class="chip" data-query="Genera una carta de presentaciΓ³n para la oferta subida">
226
+ <span class="chip-icon">βœ‰οΈ</span>
227
+ <span>Cover Letter</span>
228
+ </button>
229
+ <button class="chip" data-query="ΒΏQuΓ© skills me faltan para crecer profesionalmente?">
230
+ <span class="chip-icon">πŸ“ˆ</span>
231
+ <span>Skills Gap</span>
232
+ </button>
233
+ <button class="chip" data-query="Simula una entrevista tΓ©cnica para mi perfil">
234
+ <span class="chip-icon">🎀</span>
235
+ <span>Entrevista</span>
236
+ </button>
237
+ <button class="chip" data-query="ΒΏQuΓ© roles de trabajo me convienen mΓ‘s segΓΊn mi perfil?">
238
+ <span class="chip-icon">🎯</span>
239
+ <span>Job Match</span>
240
+ </button>
241
+ </div>
242
+ </div>
243
+
244
+ <!-- ===== CHAT SCREEN ===== -->
245
+ <div class="chat-screen hidden" id="chatScreen">
246
+ <div class="chat-messages" id="chatMessages">
247
+ <!-- Messages populated by JS -->
248
+ </div>
249
+
250
+ <!-- Chat input (bottom) -->
251
+ <div class="chat-input-container">
252
+ <div class="chat-input-wrapper">
253
+ <textarea class="chat-input" id="chatInput"
254
+ placeholder="Escribe tu pregunta sobre tu carrera profesional..." rows="1"></textarea>
255
+ <div class="chat-input-actions">
256
+ <button class="input-action-btn" id="chatAttachBtn" title="Adjuntar archivo">
257
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
258
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
259
+ <line x1="12" y1="5" x2="12" y2="19" />
260
+ <line x1="5" y1="12" x2="19" y2="12" />
261
+ </svg>
262
+ </button>
263
+ <div class="input-right-actions">
264
+ <div class="model-selector" id="chatModelSelector">
265
+ <span class="model-name">CareerAI Pro</span>
266
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
267
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
268
+ <polyline points="6 9 12 15 18 9" />
269
+ </svg>
270
+ </div>
271
+ <button class="send-btn chat-send" id="chatSendBtn" title="Enviar" disabled>
272
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
273
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
274
+ <line x1="22" y1="2" x2="11" y2="13" />
275
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
276
+ </svg>
277
+ </button>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </main>
284
+
285
+ <!-- ===== MODEL DROPDOWN ===== -->
286
+ <div class="model-dropdown hidden" id="modelDropdown">
287
+ <div class="model-dropdown-header">Selecciona un modelo</div>
288
+ <div class="model-option active" data-model="llama-3.3-70b-versatile" data-display="CareerAI Pro">
289
+ <img src="/static/icon-pro.png" alt="CareerAI Pro" class="model-option-icon" width="26" height="26"
290
+ style="width:26px;height:26px;max-width:26px;max-height:26px;">
291
+ <div class="model-option-info">
292
+ <span class="model-option-name">CareerAI Pro</span>
293
+ <span class="model-option-desc">Recomendado Β· MΓ‘xima calidad</span>
294
+ </div>
295
+ <svg class="model-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
296
+ stroke-width="2.5">
297
+ <polyline points="20 6 9 17 4 12" />
298
+ </svg>
299
+ </div>
300
+ <div class="model-option" data-model="llama-3.1-8b-instant" data-display="CareerAI Flash">
301
+ <img src="/static/icon-flash.png" alt="CareerAI Flash" class="model-option-icon" width="26" height="26"
302
+ style="width:26px;height:26px;max-width:26px;max-height:26px;">
303
+ <div class="model-option-info">
304
+ <span class="model-option-name">CareerAI Flash</span>
305
+ <span class="model-option-desc">Ultra rΓ‘pido Β· Respuestas al instante</span>
306
+ </div>
307
+ <svg class="model-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
308
+ stroke-width="2.5">
309
+ <polyline points="20 6 9 17 4 12" />
310
+ </svg>
311
+ </div>
312
+ </div>
313
+
314
+ <!-- ===== FILE UPLOAD MODAL ===== -->
315
+ <div class="upload-modal hidden" id="uploadModal">
316
+ <div class="upload-modal-backdrop" id="uploadBackdrop"></div>
317
+ <div class="upload-modal-content">
318
+ <div class="upload-modal-header">
319
+ <h3>πŸ“„ Subir documento</h3>
320
+ <button class="upload-close" id="uploadClose">&times;</button>
321
+ </div>
322
+ <div class="upload-modal-body">
323
+ <div class="upload-type-selector">
324
+ <label class="upload-type active" data-type="cv">
325
+ <span>πŸ“‹</span> CV / Resume
326
+ </label>
327
+ <label class="upload-type" data-type="job_offer">
328
+ <span>πŸ’Ό</span> Oferta de Trabajo
329
+ </label>
330
+ <label class="upload-type" data-type="linkedin">
331
+ <span>πŸ‘€</span> Perfil LinkedIn
332
+ </label>
333
+ <label class="upload-type" data-type="other">
334
+ <span>πŸ“</span> Otro
335
+ </label>
336
+ </div>
337
+ <div class="upload-dropzone" id="uploadDropzone">
338
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
339
+ stroke-linecap="round" stroke-linejoin="round">
340
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
341
+ <polyline points="17 8 12 3 7 8" />
342
+ <line x1="12" y1="3" x2="12" y2="15" />
343
+ </svg>
344
+ <p>Arrastra archivos aquΓ­ o <strong>haz clic para seleccionar</strong></p>
345
+ <span class="upload-formats">PDF, DOCX, TXT, JPG, PNG, WEBP</span>
346
+ <input type="file" id="fileInput" accept=".pdf,.txt,.docx,.jpg,.jpeg,.png,.webp" hidden>
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+
352
+ <!-- ===== JOBS PANEL (slide-out drawer) ===== -->
353
+ <div id="jobsPanelOverlay" onclick="closeJobsPanel()"
354
+ style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:1100; backdrop-filter:blur(2px);">
355
+ </div>
356
+ <div id="jobsPanel"
357
+ style="display:none; position:fixed; top:0; right:0; width:min(480px,100vw); height:100vh; background:var(--bg-secondary); border-left:1px solid var(--border-medium); z-index:1101; flex-direction:column; overflow:hidden; box-shadow:-8px 0 40px rgba(0,0,0,0.3);">
358
+ <!-- Header -->
359
+ <div
360
+ style="padding:20px 20px 0; border-bottom:1px solid var(--border-medium); padding-bottom:16px; flex-shrink:0;">
361
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:14px;">
362
+ <div>
363
+ <h2 style="font-size:1.15rem; font-weight:700; margin:0;">πŸ’Ό Ofertas de Trabajo</h2>
364
+ <p style="font-size:0.78rem; color:var(--text-tertiary); margin:2px 0 0;">VΓ­a LinkedIn Β· Indeed Β·
365
+ Glassdoor Β· mΓ‘s</p>
366
+ </div>
367
+ <button onclick="closeJobsPanel()"
368
+ style="background:none; border:none; cursor:pointer; color:var(--text-secondary); font-size:1.4rem; line-height:1; padding:4px 8px;">&times;</button>
369
+ </div>
370
+ <!-- Search bar -->
371
+ <div style="display:flex; gap:8px; margin-bottom:12px;">
372
+ <input id="jobsSearchInput" type="text" class="welcome-input"
373
+ placeholder="Ej: Python developer, diseΓ±ador UX..."
374
+ style="flex:1; border:1px solid var(--border-medium); border-radius:8px; padding:9px 12px; min-height:0; font-size:0.88rem;">
375
+ <button onclick="loadJobs()" id="jobsSearchBtn" class="config-btn"
376
+ style="padding:9px 16px; white-space:nowrap;">Buscar</button>
377
+ </div>
378
+ <!-- Filters row - Custom Dropdowns -->
379
+ <div style="display:flex; gap:8px; flex-wrap:wrap; font-size:0.8rem;">
380
+ <!-- Hidden real selects for value access -->
381
+ <select id="jobsCountry" style="display:none;">
382
+ <option value="">🌍 Todo el mundo</option>
383
+ <option value="ar">πŸ‡¦πŸ‡· Argentina</option>
384
+ <option value="es">πŸ‡ͺπŸ‡Έ EspaΓ±a</option>
385
+ <option value="mx">πŸ‡²πŸ‡½ MΓ©xico</option>
386
+ <option value="co">πŸ‡¨πŸ‡΄ Colombia</option>
387
+ <option value="cl">πŸ‡¨πŸ‡± Chile</option>
388
+ <option value="pe">πŸ‡΅πŸ‡ͺ PerΓΊ</option>
389
+ <option value="us">πŸ‡ΊπŸ‡Έ USA</option>
390
+ <option value="gb">πŸ‡¬πŸ‡§ UK</option>
391
+ <option value="de">πŸ‡©πŸ‡ͺ Alemania</option>
392
+ </select>
393
+ <select id="jobsDatePosted" style="display:none;">
394
+ <option value="month">πŸ“… Último mes</option>
395
+ <option value="week">πŸ“… Última semana</option>
396
+ <option value="3days">πŸ“… Últimos 3 dΓ­as</option>
397
+ <option value="today">πŸ“… Hoy</option>
398
+ <option value="all">πŸ“… Todas</option>
399
+ </select>
400
+
401
+ <!-- Custom dropdown: Country -->
402
+ <div class="jobs-custom-select" id="countryDropdown"
403
+ style="flex:1; min-width:120px; position:relative;">
404
+ <div class="jobs-select-btn" onclick="toggleJobsDropdown('countryDropdown')">
405
+ <span id="countryDropdownLabel">🌍 Todo el mundo</span>
406
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
407
+ stroke-width="2">
408
+ <polyline points="6 9 12 15 18 9"></polyline>
409
+ </svg>
410
+ </div>
411
+ <div class="jobs-select-menu" id="countryDropdownMenu">
412
+ <div class="jobs-select-option active"
413
+ onclick="selectJobsOption('countryDropdown','jobsCountry','','🌍 Todo el mundo')">🌍 Todo el
414
+ mundo</div>
415
+ <div class="jobs-select-option"
416
+ onclick="selectJobsOption('countryDropdown','jobsCountry','ar','πŸ‡¦πŸ‡· Argentina')">πŸ‡¦πŸ‡·
417
+ Argentina</div>
418
+ <div class="jobs-select-option"
419
+ onclick="selectJobsOption('countryDropdown','jobsCountry','es','πŸ‡ͺπŸ‡Έ EspaΓ±a')">πŸ‡ͺπŸ‡Έ EspaΓ±a
420
+ </div>
421
+ <div class="jobs-select-option"
422
+ onclick="selectJobsOption('countryDropdown','jobsCountry','mx','πŸ‡²πŸ‡½ MΓ©xico')">πŸ‡²πŸ‡½ MΓ©xico
423
+ </div>
424
+ <div class="jobs-select-option"
425
+ onclick="selectJobsOption('countryDropdown','jobsCountry','co','πŸ‡¨πŸ‡΄ Colombia')">πŸ‡¨πŸ‡΄
426
+ Colombia</div>
427
+ <div class="jobs-select-option"
428
+ onclick="selectJobsOption('countryDropdown','jobsCountry','cl','πŸ‡¨πŸ‡± Chile')">πŸ‡¨πŸ‡± Chile
429
+ </div>
430
+ <div class="jobs-select-option"
431
+ onclick="selectJobsOption('countryDropdown','jobsCountry','pe','πŸ‡΅πŸ‡ͺ PerΓΊ')">πŸ‡΅πŸ‡ͺ PerΓΊ</div>
432
+ <div class="jobs-select-option"
433
+ onclick="selectJobsOption('countryDropdown','jobsCountry','us','πŸ‡ΊπŸ‡Έ USA')">πŸ‡ΊπŸ‡Έ USA</div>
434
+ <div class="jobs-select-option"
435
+ onclick="selectJobsOption('countryDropdown','jobsCountry','gb','πŸ‡¬πŸ‡§ UK')">πŸ‡¬πŸ‡§ UK</div>
436
+ <div class="jobs-select-option"
437
+ onclick="selectJobsOption('countryDropdown','jobsCountry','de','πŸ‡©πŸ‡ͺ Alemania')">πŸ‡©πŸ‡ͺ
438
+ Alemania</div>
439
+ </div>
440
+ </div>
441
+
442
+ <!-- Custom dropdown: Date -->
443
+ <div class="jobs-custom-select" id="dateDropdown" style="flex:1; min-width:130px; position:relative;">
444
+ <div class="jobs-select-btn" onclick="toggleJobsDropdown('dateDropdown')">
445
+ <span id="dateDropdownLabel">πŸ“… Último mes</span>
446
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
447
+ stroke-width="2">
448
+ <polyline points="6 9 12 15 18 9"></polyline>
449
+ </svg>
450
+ </div>
451
+ <div class="jobs-select-menu" id="dateDropdownMenu">
452
+ <div class="jobs-select-option active"
453
+ onclick="selectJobsOption('dateDropdown','jobsDatePosted','month','πŸ“… Último mes')">πŸ“…
454
+ Último mes</div>
455
+ <div class="jobs-select-option"
456
+ onclick="selectJobsOption('dateDropdown','jobsDatePosted','week','πŸ“… Última semana')">πŸ“…
457
+ Última semana</div>
458
+ <div class="jobs-select-option"
459
+ onclick="selectJobsOption('dateDropdown','jobsDatePosted','3days','πŸ“… Últimos 3 dΓ­as')">πŸ“…
460
+ Últimos 3 días</div>
461
+ <div class="jobs-select-option"
462
+ onclick="selectJobsOption('dateDropdown','jobsDatePosted','today','πŸ“… Hoy')">πŸ“… Hoy</div>
463
+ <div class="jobs-select-option"
464
+ onclick="selectJobsOption('dateDropdown','jobsDatePosted','all','πŸ“… Todas')">πŸ“… Todas</div>
465
+ </div>
466
+ </div>
467
+
468
+ <label
469
+ style="display:flex; align-items:center; gap:5px; color:var(--text-secondary); cursor:pointer; border:1px solid var(--border-medium); border-radius:6px; padding:6px 10px; white-space:nowrap; background:var(--bg-hover);">
470
+ <input type="checkbox" id="jobsRemoteOnly" style="accent-color:var(--accent-primary);"> 🏠 Remoto
471
+ </label>
472
+ </div>
473
+ </div>
474
+ <!-- Results area -->
475
+ <div id="jobsResults"
476
+ style="flex:1; overflow-y:auto; padding:16px 20px; display:flex; flex-direction:column; gap:12px;">
477
+ <div id="jobsEmptyState" style="text-align:center; padding:60px 20px; color:var(--text-tertiary);">
478
+ <div style="font-size:3rem; margin-bottom:12px;">πŸ”</div>
479
+ <p style="font-size:1rem; font-weight:600; margin-bottom:6px; color:var(--text-secondary);">Busca
480
+ ofertas de empleo</p>
481
+ <p style="font-size:0.85rem;">Escribe un puesto o habilidad arriba.<br>Si tienes un CV cargado, <a
482
+ href="#" onclick="event.preventDefault(); autoFillJobSearch()"
483
+ style="color:var(--accent-primary);">auto-completar desde mi CV</a>.</p>
484
+ </div>
485
+ </div>
486
+ <!-- Footer count -->
487
+ <div id="jobsFooter"
488
+ style="display:none; padding:12px 20px; border-top:1px solid var(--border-medium); font-size:0.8rem; color:var(--text-tertiary); text-align:center; flex-shrink:0;">
489
+ </div>
490
+ </div>
491
+
492
+ </div>
493
+
494
+ <!-- ===== LOGIN MODAL ===== -->
495
+ <div class="upload-modal hidden" id="loginModal">
496
+ <div class="upload-modal-backdrop" id="loginBackdrop"></div>
497
+ <div class="upload-modal-content" style="max-width: 360px;">
498
+ <div class="upload-modal-header" style="justify-content: center; position: relative; border-bottom: none;">
499
+ <h3 style="font-size: 1.25rem;" id="loginTitle">Acceso a CareerAI</h3>
500
+ <button class="upload-close" id="loginClose" style="position: absolute; right: 20px;">&times;</button>
501
+ </div>
502
+ <div class="upload-modal-body" style="padding-top: 5px;">
503
+ <p style="text-align: center; color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 24px;">
504
+ Inicia sesiΓ³n para guardar tu historial y documentos en la nube.</p>
505
+
506
+ <form id="authForm" onsubmit="handleAuthSubmit(event)">
507
+ <div id="registerFields" style="display: none; margin-bottom: 12px;">
508
+ <input type="text" id="authName" class="welcome-input"
509
+ style="border: 1px solid var(--border-medium); border-radius: 8px; padding: 10px 14px; min-height: 40px; margin-bottom: 0;"
510
+ placeholder="Tu Nombre">
511
+ </div>
512
+
513
+ <div id="emailFieldGroup" style="margin-bottom: 12px;">
514
+ <input type="email" id="authEmail" class="welcome-input"
515
+ style="border: 1px solid var(--border-medium); border-radius: 8px; padding: 10px 14px; min-height: 40px; margin-bottom: 0;"
516
+ placeholder="Correo electrΓ³nico" required>
517
+ </div>
518
+
519
+ <div id="resetCodeFields" style="display: none; margin-bottom: 12px;">
520
+ <input type="text" id="authResetCode" class="welcome-input"
521
+ style="border: 1px solid var(--border-medium); border-radius: 8px; padding: 10px 14px; min-height: 40px; margin-bottom: 0;"
522
+ placeholder="CΓ³digo de 6 dΓ­gitos">
523
+ </div>
524
+
525
+ <div id="passwordFieldsGroup" style="margin-bottom: 16px;">
526
+ <input type="password" id="authPassword" class="welcome-input"
527
+ style="border: 1px solid var(--border-medium); border-radius: 8px; padding: 10px 14px; min-height: 40px; margin-bottom: 0;"
528
+ placeholder="ContraseΓ±a" required>
529
+ <div style="text-align: right; margin-top: 6px;" id="forgotPassContainer">
530
+ <a href="#" onclick="event.preventDefault(); setAuthMode('forgot')"
531
+ style="font-size: 0.8rem; color: var(--accent-primary); text-decoration: none;">ΒΏOlvidaste
532
+ tu contraseΓ±a?</a>
533
+ </div>
534
+ </div>
535
+
536
+ <button type="submit" id="authSubmitBtn" class="config-btn"
537
+ style="width: 100%; display: flex; justify-content: center; margin-bottom: 16px;">
538
+ Iniciar SesiΓ³n
539
+ </button>
540
+
541
+ <!-- Auxiliary button for Forgot Password Step 1 (Send Code) -->
542
+ <button type="button" id="authSendCodeBtn" onclick="handleSendResetCode(event)" class="config-btn"
543
+ style="display: none; width: 100%; justify-content: center; margin-bottom: 16px; background: var(--bg-hover); color: var(--text-primary); border: 1px solid var(--border-medium);">
544
+ Enviar cΓ³digo a mi correo
545
+ </button>
546
+ </form>
547
+
548
+ <div id="authToggleContainer"
549
+ style="text-align: center; font-size: 0.85rem; color: var(--text-tertiary); margin-bottom: 20px;">
550
+ <span id="authToggleText">ΒΏNo tienes cuenta? <a href="#"
551
+ onclick="event.preventDefault(); setAuthMode('register')"
552
+ style="color: var(--accent-primary); text-decoration: none;">RegΓ­strate</a></span>
553
+ </div>
554
+
555
+ <div id="backToLoginContainer"
556
+ style="display: none; text-align: center; font-size: 0.85rem; margin-bottom: 20px;">
557
+ <a href="#" onclick="event.preventDefault(); setAuthMode('login')"
558
+ style="color: var(--text-secondary); text-decoration: underline;">Volver al inicio de sesiΓ³n</a>
559
+ </div>
560
+ </div>
561
+ </div>
562
+ </div>
563
+
564
+ <!-- ===== PROFILE MODAL ===== -->
565
+ <div class="upload-modal hidden" id="profileModal">
566
+ <div class="upload-modal-backdrop" id="profileBackdrop"></div>
567
+ <div class="upload-modal-content" style="max-width: 380px; text-align: center;">
568
+ <div class="upload-modal-header" style="justify-content: center; position: relative; border-bottom: none;">
569
+ <h3 style="font-size: 1.25rem;">Mi Perfil</h3>
570
+ <button class="upload-close" id="profileClose" style="position: absolute; right: 20px;">&times;</button>
571
+ </div>
572
+ <div class="upload-modal-body" style="padding-top: 5px;">
573
+ <form id="profileForm" onsubmit="handleProfileSubmit(event)">
574
+
575
+ <div style="position: relative; width: 80px; height: 80px; margin: 0 auto 16px; border-radius: 50%; border: 2px solid var(--accent-primary); overflow: hidden; background: var(--bg-hover); cursor: pointer;"
576
+ onclick="document.getElementById('profilePictureInput').click()">
577
+ <img id="profilePreview" src="" style="width: 100%; height: 100%; object-fit: cover;">
578
+ <div
579
+ style="position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.5); font-size: 0.7rem; color: white; padding: 4px 0;">
580
+ Editar</div>
581
+ </div>
582
+ <input type="file" id="profilePictureInput" accept=".jpg,.jpeg,.png,.webp" hidden
583
+ onchange="handleProfilePictureSelect(event)">
584
+
585
+ <div style="margin-bottom: 12px; text-align: left;">
586
+ <label
587
+ style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px; display: block;">Nombre</label>
588
+ <input type="text" id="profileName" class="welcome-input"
589
+ style="border: 1px solid var(--border-medium); border-radius: 8px; padding: 10px 14px; min-height: 40px; margin-bottom: 0;"
590
+ required>
591
+ </div>
592
+
593
+ <div style="margin-bottom: 20px; text-align: left;">
594
+ <label
595
+ style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px; display: block;">Correo
596
+ de la cuenta</label>
597
+ <input type="email" id="profileEmail" class="welcome-input"
598
+ style="background: var(--bg-hover); border: 1px solid var(--border-medium); border-radius: 8px; padding: 10px 14px; min-height: 40px; margin-bottom: 0; color: var(--text-tertiary);"
599
+ disabled>
600
+ </div>
601
+
602
+ <button type="submit" id="profileSubmitBtn" class="config-btn"
603
+ style="width: 100%; margin-bottom: 16px; display: flex; justify-content: center;">
604
+ Guardar Cambios
605
+ </button>
606
+
607
+ <div style="border-top: 1px solid var(--border-medium); padding-top: 16px; margin-bottom: 8px;">
608
+ <button type="button" onclick="handleLogout()" class="config-btn"
609
+ style="width: 100%; background: transparent; border: 1px solid #dc2626; color: #dc2626; display: flex; justify-content: center; align-items: center; gap: 8px;">
610
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
611
+ stroke-width="2">
612
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
613
+ <polyline points="16 17 21 12 16 7"></polyline>
614
+ <line x1="21" y1="12" x2="9" y2="12"></line>
615
+ </svg>
616
+ Cerrar SesiΓ³n
617
+ </button>
618
+ </div>
619
+ </form>
620
+ </div>
621
+ </div>
622
+ </div>
623
+
624
+ <script src="/static/app.js?v=2"></script>
625
+ </body>
626
+
627
+ </html>
frontend/styles.css ADDED
@@ -0,0 +1,1695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ========================================================
2
+ CareerAI β€” Claude-Style Interface
3
+ Premium, clean, warm design system
4
+ ======================================================== */
5
+
6
+ /* ===== RESET & BASE ===== */
7
+ *,
8
+ *::before,
9
+ *::after {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ :root {
16
+ /* Dark Theme Palette */
17
+ --bg-primary: #111822;
18
+ --bg-secondary: #0d131b;
19
+ --bg-sidebar: #0f151e;
20
+ --bg-hover: #1e293b;
21
+ --bg-input: #1a2332;
22
+ --bg-message-ai: transparent;
23
+ --bg-message-user: transparent;
24
+
25
+ /* Text */
26
+ --text-primary: #ffffff;
27
+ --text-secondary: #94a3b8;
28
+ --text-tertiary: #64748b;
29
+ --text-placeholder: #475569;
30
+ --text-link: #55c970;
31
+
32
+ /* Accent (Green and Blue from logo) */
33
+ --accent-primary: #55c970;
34
+ --accent-secondary: #5584c0;
35
+ --accent-bg: rgba(85, 201, 112, 0.1);
36
+
37
+ /* Borders */
38
+ --border-light: #263346;
39
+ --border-medium: #334155;
40
+ --border-input: #334155;
41
+ --border-focus: #55c970;
42
+
43
+ /* Shadows */
44
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
45
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
46
+ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
47
+ --shadow-input: 0 1px 3px rgba(0, 0, 0, 0.2), 0 0 0 1px var(--border-input);
48
+ --shadow-input-focus: 0 0 0 2px rgba(85, 201, 112, 0.25), 0 1px 3px rgba(0, 0, 0, 0.2);
49
+
50
+ /* Sidebar */
51
+ --sidebar-width: 260px;
52
+
53
+ /* Transitions */
54
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
55
+ --transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
56
+ --transition-smooth: 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
57
+
58
+ /* Typography */
59
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
60
+ --font-serif: 'Georgia', 'Times New Roman', serif;
61
+ }
62
+
63
+ html {
64
+ height: 100%;
65
+ -webkit-font-smoothing: antialiased;
66
+ -moz-osx-font-smoothing: grayscale;
67
+ text-rendering: optimizeLegibility;
68
+ }
69
+
70
+ body {
71
+ font-family: var(--font-family);
72
+ background: var(--bg-primary);
73
+ color: var(--text-primary);
74
+ height: 100%;
75
+ display: flex;
76
+ overflow: hidden;
77
+ line-height: 1.5;
78
+ font-size: 15px;
79
+ }
80
+
81
+ /* ===== SCROLLBAR ===== */
82
+ ::-webkit-scrollbar {
83
+ width: 6px;
84
+ }
85
+
86
+ ::-webkit-scrollbar-track {
87
+ background: transparent;
88
+ }
89
+
90
+ ::-webkit-scrollbar-thumb {
91
+ background: var(--border-light);
92
+ border-radius: 100px;
93
+ }
94
+
95
+ ::-webkit-scrollbar-thumb:hover {
96
+ background: var(--border-medium);
97
+ }
98
+
99
+ /* ===== SIDEBAR ===== */
100
+ .sidebar {
101
+ width: var(--sidebar-width);
102
+ min-width: var(--sidebar-width);
103
+ height: 100vh;
104
+ background: var(--bg-sidebar);
105
+ border-right: 1px solid var(--border-light);
106
+ display: flex;
107
+ flex-direction: column;
108
+ transition: transform var(--transition-smooth), width var(--transition-smooth), min-width var(--transition-smooth);
109
+ z-index: 100;
110
+ position: relative;
111
+ }
112
+
113
+ .sidebar.collapsed {
114
+ width: 0;
115
+ min-width: 0;
116
+ transform: translateX(-100%);
117
+ border-right: none;
118
+ }
119
+
120
+ .sidebar-inner {
121
+ display: flex;
122
+ flex-direction: column;
123
+ height: 100%;
124
+ overflow: hidden;
125
+ width: var(--sidebar-width);
126
+ }
127
+
128
+ /* Sidebar Top */
129
+ .sidebar-top {
130
+ display: flex;
131
+ align-items: center;
132
+ justify-content: space-between;
133
+ padding: 12px 12px 8px;
134
+ }
135
+
136
+ .sidebar-icon-btn {
137
+ width: 32px;
138
+ height: 32px;
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ border: none;
143
+ background: transparent;
144
+ color: var(--text-secondary);
145
+ border-radius: 8px;
146
+ cursor: pointer;
147
+ transition: all var(--transition-fast);
148
+ }
149
+
150
+ .sidebar-icon-btn:hover {
151
+ background: var(--bg-hover);
152
+ color: var(--text-primary);
153
+ }
154
+
155
+ #toggleSidebar {
156
+ display: none;
157
+ }
158
+
159
+ #newChatBtn {
160
+ margin-left: auto;
161
+ }
162
+
163
+ /* Search */
164
+ .sidebar-search {
165
+ padding: 4px 12px 12px;
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 8px;
169
+ background: var(--bg-hover);
170
+ margin: 0 12px 8px;
171
+ border-radius: 8px;
172
+ padding: 8px 10px;
173
+ color: var(--text-tertiary);
174
+ }
175
+
176
+ .sidebar-search input {
177
+ border: none;
178
+ background: transparent;
179
+ font-size: 0.82rem;
180
+ color: var(--text-primary);
181
+ outline: none;
182
+ width: 100%;
183
+ font-family: var(--font-family);
184
+ }
185
+
186
+ .sidebar-search input::placeholder {
187
+ color: var(--text-placeholder);
188
+ }
189
+
190
+ /* Nav */
191
+ .sidebar-nav {
192
+ padding: 4px 8px;
193
+ display: flex;
194
+ flex-direction: column;
195
+ gap: 1px;
196
+ }
197
+
198
+ .nav-item {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 10px;
202
+ padding: 8px 12px;
203
+ border-radius: 8px;
204
+ color: var(--text-secondary);
205
+ text-decoration: none;
206
+ font-size: 0.88rem;
207
+ font-weight: 500;
208
+ transition: all var(--transition-fast);
209
+ cursor: pointer;
210
+ }
211
+
212
+ .nav-item:hover {
213
+ background: var(--bg-hover);
214
+ color: var(--text-primary);
215
+ }
216
+
217
+ .nav-item.active {
218
+ background: var(--bg-hover);
219
+ color: var(--text-primary);
220
+ font-weight: 600;
221
+ }
222
+
223
+ /* Sidebar sections */
224
+ .sidebar-section {
225
+ padding: 12px 12px 4px;
226
+ flex: 1;
227
+ overflow-y: auto;
228
+ min-height: 0;
229
+ }
230
+
231
+ .sidebar-section-label {
232
+ font-size: 0.72rem;
233
+ font-weight: 600;
234
+ color: var(--text-tertiary);
235
+ text-transform: uppercase;
236
+ letter-spacing: 0.08em;
237
+ padding: 0 4px 8px;
238
+ }
239
+
240
+ /* Conversation list */
241
+ .conversation-list {
242
+ display: flex;
243
+ flex-direction: column;
244
+ gap: 1px;
245
+ }
246
+
247
+ .conversation-item {
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: space-between;
251
+ padding: 8px 12px;
252
+ border-radius: 8px;
253
+ font-size: 0.84rem;
254
+ color: var(--text-secondary);
255
+ cursor: pointer;
256
+ transition: all var(--transition-fast);
257
+ }
258
+
259
+ .conversation-item:hover {
260
+ background: var(--bg-hover);
261
+ color: var(--text-primary);
262
+ }
263
+
264
+ .conversation-item.active {
265
+ background: var(--bg-hover);
266
+ color: var(--text-primary);
267
+ font-weight: 500;
268
+ }
269
+
270
+ .conversation-delete {
271
+ background: transparent;
272
+ border: none;
273
+ color: var(--text-tertiary);
274
+ cursor: pointer;
275
+ opacity: 0;
276
+ transition: all var(--transition-fast);
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: center;
280
+ padding: 4px;
281
+ border-radius: 4px;
282
+ margin-left: 6px;
283
+ }
284
+
285
+ .conversation-item:hover .conversation-delete {
286
+ opacity: 1;
287
+ }
288
+
289
+ .conversation-delete:hover {
290
+ color: #ef4444;
291
+ /* subtle red */
292
+ background: rgba(239, 68, 68, 0.1);
293
+ }
294
+
295
+ /* Document list */
296
+ .document-list {
297
+ display: flex;
298
+ flex-direction: column;
299
+ gap: 4px;
300
+ }
301
+
302
+ .doc-item {
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 8px;
306
+ padding: 6px 10px;
307
+ border-radius: 8px;
308
+ font-size: 0.8rem;
309
+ color: var(--text-secondary);
310
+ transition: all var(--transition-fast);
311
+ }
312
+
313
+ .doc-item:hover {
314
+ background: var(--bg-hover);
315
+ }
316
+
317
+ .doc-item .doc-icon {
318
+ font-size: 0.9rem;
319
+ }
320
+
321
+ .doc-item .doc-name {
322
+ flex: 1;
323
+ overflow: hidden;
324
+ text-overflow: ellipsis;
325
+ white-space: nowrap;
326
+ }
327
+
328
+ .doc-item .doc-remove {
329
+ opacity: 0;
330
+ cursor: pointer;
331
+ color: var(--text-tertiary);
332
+ border: none;
333
+ background: none;
334
+ font-size: 0.75rem;
335
+ transition: opacity var(--transition-fast);
336
+ }
337
+
338
+ .doc-item:hover .doc-remove {
339
+ opacity: 1;
340
+ }
341
+
342
+ .empty-docs {
343
+ display: flex;
344
+ align-items: center;
345
+ gap: 6px;
346
+ padding: 8px;
347
+ font-size: 0.8rem;
348
+ color: var(--text-tertiary);
349
+ }
350
+
351
+ .empty-docs-icon {
352
+ font-size: 1rem;
353
+ }
354
+
355
+ /* Footer */
356
+ .sidebar-footer {
357
+ padding: 12px;
358
+ border-top: 1px solid var(--border-light);
359
+ margin-top: auto;
360
+ }
361
+
362
+ .sidebar-plan {
363
+ text-align: center;
364
+ font-size: 0.78rem;
365
+ color: var(--text-tertiary);
366
+ margin-bottom: 10px;
367
+ }
368
+
369
+ .plan-separator {
370
+ margin: 0 4px;
371
+ }
372
+
373
+ .plan-upgrade {
374
+ color: var(--text-link);
375
+ text-decoration: underline;
376
+ text-underline-offset: 2px;
377
+ font-weight: 500;
378
+ }
379
+
380
+ .plan-upgrade:hover {
381
+ color: var(--accent-secondary);
382
+ }
383
+
384
+ .sidebar-user {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 10px;
388
+ padding: 8px;
389
+ border-radius: 10px;
390
+ cursor: pointer;
391
+ transition: all var(--transition-fast);
392
+ }
393
+
394
+ .sidebar-user:hover {
395
+ background: var(--bg-hover);
396
+ }
397
+
398
+ .user-avatar {
399
+ width: 28px;
400
+ height: 28px;
401
+ background: linear-gradient(135deg, var(--accent-secondary), var(--accent-primary));
402
+ border-radius: 6px;
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ font-size: 0.68rem;
407
+ font-weight: 700;
408
+ color: white;
409
+ letter-spacing: 0.02em;
410
+ }
411
+
412
+ .user-name {
413
+ font-size: 0.84rem;
414
+ font-weight: 500;
415
+ color: var(--text-primary);
416
+ }
417
+
418
+ /* ===== MOBILE SIDEBAR TOGGLE ===== */
419
+ .mobile-sidebar-toggle {
420
+ display: none;
421
+ position: fixed;
422
+ top: 12px;
423
+ left: 12px;
424
+ z-index: 200;
425
+ width: 36px;
426
+ height: 36px;
427
+ align-items: center;
428
+ justify-content: center;
429
+ background: var(--bg-primary);
430
+ border: 1px solid var(--border-light);
431
+ border-radius: 8px;
432
+ cursor: pointer;
433
+ color: var(--text-secondary);
434
+ box-shadow: var(--shadow-sm);
435
+ transition: all var(--transition-fast);
436
+ }
437
+
438
+ .mobile-sidebar-toggle:hover {
439
+ background: var(--bg-hover);
440
+ color: var(--text-primary);
441
+ }
442
+
443
+ /* ===== MAIN CONTENT ===== */
444
+ .main-content {
445
+ flex: 1;
446
+ display: flex;
447
+ flex-direction: column;
448
+ height: 100vh;
449
+ overflow: hidden;
450
+ transition: margin-left var(--transition-smooth);
451
+ }
452
+
453
+ /* Notification bar */
454
+ .notification-bar {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: center;
458
+ padding: 6px 16px;
459
+ font-size: 0.78rem;
460
+ color: var(--text-tertiary);
461
+ background: var(--bg-primary);
462
+ border-bottom: 1px solid var(--border-light);
463
+ gap: 4px;
464
+ }
465
+
466
+ .notification-separator {
467
+ color: var(--text-placeholder);
468
+ }
469
+
470
+ .notification-link {
471
+ color: var(--text-primary);
472
+ text-decoration: underline;
473
+ text-underline-offset: 2px;
474
+ font-weight: 500;
475
+ transition: color var(--transition-fast);
476
+ }
477
+
478
+ .notification-link:hover {
479
+ color: var(--accent-primary);
480
+ }
481
+
482
+ /* ===== WELCOME SCREEN ===== */
483
+ .welcome-screen {
484
+ flex: 1;
485
+ display: flex;
486
+ flex-direction: column;
487
+ align-items: center;
488
+ justify-content: center;
489
+ padding: 40px 24px;
490
+ gap: 0;
491
+ animation: fadeIn 0.5s ease-out;
492
+ overflow-y: auto;
493
+ }
494
+
495
+ @keyframes fadeIn {
496
+ from {
497
+ opacity: 0;
498
+ transform: translateY(8px);
499
+ }
500
+
501
+ to {
502
+ opacity: 1;
503
+ transform: translateY(0);
504
+ }
505
+ }
506
+
507
+ /* Logo */
508
+ .welcome-logo {
509
+ margin-bottom: 24px;
510
+ }
511
+
512
+ /* Heading */
513
+ .welcome-heading {
514
+ font-family: var(--font-serif);
515
+ font-size: clamp(1.8rem, 4vw, 2.6rem);
516
+ font-weight: 400;
517
+ color: var(--text-primary);
518
+ text-align: center;
519
+ line-height: 1.25;
520
+ margin-bottom: 36px;
521
+ letter-spacing: -0.02em;
522
+ }
523
+
524
+ /* Welcome input */
525
+ .welcome-input-container {
526
+ width: 100%;
527
+ max-width: 620px;
528
+ margin-bottom: 20px;
529
+ }
530
+
531
+ .welcome-input-wrapper {
532
+ background: var(--bg-input);
533
+ border: 1px solid var(--border-input);
534
+ border-radius: 16px;
535
+ box-shadow: var(--shadow-input);
536
+ overflow: hidden;
537
+ transition: all var(--transition-normal);
538
+ }
539
+
540
+ .welcome-input-wrapper:focus-within {
541
+ border-color: var(--border-focus);
542
+ box-shadow: var(--shadow-input-focus);
543
+ }
544
+
545
+ .welcome-input {
546
+ width: 100%;
547
+ padding: 16px 18px 8px;
548
+ border: none;
549
+ outline: none;
550
+ font-size: 0.95rem;
551
+ font-family: var(--font-family);
552
+ color: var(--text-primary);
553
+ resize: none;
554
+ line-height: 1.5;
555
+ background: transparent;
556
+ min-height: 48px;
557
+ max-height: 200px;
558
+ }
559
+
560
+ .welcome-input::placeholder {
561
+ color: var(--text-placeholder);
562
+ }
563
+
564
+ .welcome-input-actions {
565
+ display: flex;
566
+ align-items: center;
567
+ justify-content: space-between;
568
+ padding: 8px 12px;
569
+ }
570
+
571
+ .input-action-btn {
572
+ width: 32px;
573
+ height: 32px;
574
+ display: flex;
575
+ align-items: center;
576
+ justify-content: center;
577
+ border: none;
578
+ background: transparent;
579
+ color: var(--text-tertiary);
580
+ border-radius: 8px;
581
+ cursor: pointer;
582
+ transition: all var(--transition-fast);
583
+ }
584
+
585
+ .input-action-btn:hover {
586
+ background: var(--bg-hover);
587
+ color: var(--text-primary);
588
+ }
589
+
590
+ .input-right-actions {
591
+ display: flex;
592
+ align-items: center;
593
+ gap: 8px;
594
+ }
595
+
596
+ /* Model selector */
597
+ .model-selector {
598
+ display: flex;
599
+ align-items: center;
600
+ gap: 4px;
601
+ padding: 4px 10px;
602
+ border-radius: 8px;
603
+ font-size: 0.82rem;
604
+ color: var(--text-tertiary);
605
+ cursor: pointer;
606
+ transition: all var(--transition-fast);
607
+ user-select: none;
608
+ }
609
+
610
+ .model-selector:hover {
611
+ background: var(--bg-hover);
612
+ color: var(--text-secondary);
613
+ }
614
+
615
+ .model-name {
616
+ font-weight: 500;
617
+ }
618
+
619
+ /* Send button */
620
+ .send-btn {
621
+ width: 32px;
622
+ height: 32px;
623
+ display: flex;
624
+ align-items: center;
625
+ justify-content: center;
626
+ border: none;
627
+ background: var(--text-primary);
628
+ color: var(--bg-primary);
629
+ border-radius: 10px;
630
+ cursor: pointer;
631
+ transition: all var(--transition-fast);
632
+ opacity: 0.3;
633
+ }
634
+
635
+ .send-btn:not(:disabled) {
636
+ opacity: 1;
637
+ }
638
+
639
+ .send-btn:not(:disabled):hover {
640
+ background: var(--accent-primary);
641
+ transform: scale(1.05);
642
+ }
643
+
644
+ .send-btn:disabled {
645
+ cursor: not-allowed;
646
+ }
647
+
648
+ /* Suggestion chips */
649
+ .suggestion-chips {
650
+ display: flex;
651
+ flex-wrap: wrap;
652
+ gap: 8px;
653
+ justify-content: center;
654
+ max-width: 700px;
655
+ margin-top: 4px;
656
+ }
657
+
658
+ .chip {
659
+ display: inline-flex;
660
+ align-items: center;
661
+ gap: 6px;
662
+ padding: 8px 16px;
663
+ border: 1px solid var(--border-light);
664
+ border-radius: 999px;
665
+ background: var(--bg-input);
666
+ color: var(--text-secondary);
667
+ font-size: 0.84rem;
668
+ font-family: var(--font-family);
669
+ font-weight: 500;
670
+ cursor: pointer;
671
+ transition: all var(--transition-fast);
672
+ white-space: nowrap;
673
+ }
674
+
675
+ .chip:hover {
676
+ border-color: var(--border-medium);
677
+ background: var(--bg-hover);
678
+ color: var(--text-primary);
679
+ transform: translateY(-1px);
680
+ box-shadow: var(--shadow-sm);
681
+ }
682
+
683
+ .chip:active {
684
+ transform: translateY(0);
685
+ }
686
+
687
+ .chip-icon {
688
+ font-size: 0.82rem;
689
+ }
690
+
691
+ /* ===== CHAT SCREEN ===== */
692
+ .chat-screen {
693
+ flex: 1;
694
+ display: flex;
695
+ flex-direction: column;
696
+ overflow: hidden;
697
+ }
698
+
699
+ .chat-screen.hidden {
700
+ display: none;
701
+ }
702
+
703
+ /* Chat messages */
704
+ .chat-messages {
705
+ flex: 1;
706
+ overflow-y: auto;
707
+ padding: 20px 0;
708
+ }
709
+
710
+ .message {
711
+ padding: 24px 0;
712
+ animation: messageIn 0.35s ease-out;
713
+ }
714
+
715
+ @keyframes messageIn {
716
+ from {
717
+ opacity: 0;
718
+ transform: translateY(6px);
719
+ }
720
+
721
+ to {
722
+ opacity: 1;
723
+ transform: translateY(0);
724
+ }
725
+ }
726
+
727
+ .message-inner {
728
+ max-width: 768px;
729
+ margin: 0 auto;
730
+ padding: 0 24px;
731
+ display: flex;
732
+ gap: 16px;
733
+ }
734
+
735
+ .message-avatar {
736
+ width: 28px;
737
+ height: 28px;
738
+ min-width: 28px;
739
+ border-radius: 8px;
740
+ display: flex;
741
+ align-items: center;
742
+ justify-content: center;
743
+ font-size: 0.95rem;
744
+ margin-top: 2px;
745
+ }
746
+
747
+ .message-avatar.user {
748
+ background: var(--bg-hover);
749
+ border: 1px solid var(--border-light);
750
+ }
751
+
752
+ .message-avatar.ai {
753
+ background: transparent;
754
+ color: white;
755
+ font-size: 0.75rem;
756
+ }
757
+
758
+ .message-avatar.ai svg {
759
+ width: 24px;
760
+ height: 24px;
761
+ }
762
+
763
+ .message-body {
764
+ flex: 1;
765
+ min-width: 0;
766
+ }
767
+
768
+ .message-author {
769
+ font-size: 0.84rem;
770
+ font-weight: 600;
771
+ color: var(--text-primary);
772
+ margin-bottom: 6px;
773
+ }
774
+
775
+ .message-content {
776
+ font-size: 0.94rem;
777
+ line-height: 1.65;
778
+ color: var(--text-primary);
779
+ word-break: break-word;
780
+ }
781
+
782
+ .message-content p {
783
+ margin-bottom: 12px;
784
+ }
785
+
786
+ .message-content p:last-child {
787
+ margin-bottom: 0;
788
+ }
789
+
790
+ .message-content strong {
791
+ font-weight: 600;
792
+ }
793
+
794
+ .message-content code {
795
+ background: var(--bg-hover);
796
+ padding: 2px 6px;
797
+ border-radius: 4px;
798
+ font-size: 0.88em;
799
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
800
+ }
801
+
802
+ .message-content pre {
803
+ background: #1a1915;
804
+ color: #e4e4e7;
805
+ padding: 16px;
806
+ border-radius: 10px;
807
+ overflow-x: auto;
808
+ margin: 12px 0;
809
+ font-size: 0.85rem;
810
+ line-height: 1.5;
811
+ }
812
+
813
+ .message-content pre code {
814
+ background: none;
815
+ padding: 0;
816
+ color: inherit;
817
+ }
818
+
819
+ .message-content ul,
820
+ .message-content ol {
821
+ padding-left: 20px;
822
+ margin: 8px 0;
823
+ }
824
+
825
+ .message-content li {
826
+ margin-bottom: 4px;
827
+ }
828
+
829
+ /* Message actions */
830
+ .message-actions {
831
+ display: flex;
832
+ gap: 4px;
833
+ margin-top: 12px;
834
+ opacity: 0;
835
+ transition: opacity var(--transition-fast);
836
+ }
837
+
838
+ .message:hover .message-actions {
839
+ opacity: 1;
840
+ }
841
+
842
+ .action-btn {
843
+ width: 30px;
844
+ height: 30px;
845
+ display: flex;
846
+ align-items: center;
847
+ justify-content: center;
848
+ border: none;
849
+ background: transparent;
850
+ color: var(--text-tertiary);
851
+ border-radius: 6px;
852
+ cursor: pointer;
853
+ transition: all var(--transition-fast);
854
+ }
855
+
856
+ .action-btn:hover {
857
+ background: var(--bg-hover);
858
+ color: var(--text-secondary);
859
+ }
860
+
861
+ .action-btn.copied {
862
+ color: #16a34a;
863
+ }
864
+
865
+ /* AI message specific */
866
+ .message.ai {
867
+ background: var(--bg-message-ai);
868
+ }
869
+
870
+ /* Typing indicator */
871
+ .typing-indicator {
872
+ display: flex;
873
+ gap: 4px;
874
+ padding: 4px 0;
875
+ }
876
+
877
+ .typing-dot {
878
+ width: 6px;
879
+ height: 6px;
880
+ background: var(--text-tertiary);
881
+ border-radius: 50%;
882
+ animation: typingBounce 1.4s infinite ease-in-out;
883
+ }
884
+
885
+ .typing-dot:nth-child(2) {
886
+ animation-delay: 0.2s;
887
+ }
888
+
889
+ .typing-dot:nth-child(3) {
890
+ animation-delay: 0.4s;
891
+ }
892
+
893
+ @keyframes typingBounce {
894
+
895
+ 0%,
896
+ 80%,
897
+ 100% {
898
+ transform: scale(0.6);
899
+ opacity: 0.4;
900
+ }
901
+
902
+ 40% {
903
+ transform: scale(1);
904
+ opacity: 1;
905
+ }
906
+ }
907
+
908
+ /* Chat input (bottom) */
909
+ .chat-input-container {
910
+ padding: 12px 24px 20px;
911
+ background: linear-gradient(to top, var(--bg-primary) 70%, transparent);
912
+ }
913
+
914
+ .chat-input-wrapper {
915
+ max-width: 768px;
916
+ margin: 0 auto;
917
+ background: var(--bg-input);
918
+ border: 1px solid var(--border-input);
919
+ border-radius: 16px;
920
+ box-shadow: var(--shadow-input);
921
+ overflow: hidden;
922
+ transition: all var(--transition-normal);
923
+ }
924
+
925
+ .chat-input-wrapper:focus-within {
926
+ border-color: var(--border-focus);
927
+ box-shadow: var(--shadow-input-focus);
928
+ }
929
+
930
+ .chat-input {
931
+ width: 100%;
932
+ padding: 14px 18px 6px;
933
+ border: none;
934
+ outline: none;
935
+ font-size: 0.95rem;
936
+ font-family: var(--font-family);
937
+ color: var(--text-primary);
938
+ resize: none;
939
+ line-height: 1.5;
940
+ background: transparent;
941
+ min-height: 44px;
942
+ max-height: 200px;
943
+ }
944
+
945
+ .chat-input::placeholder {
946
+ color: var(--text-placeholder);
947
+ }
948
+
949
+ .chat-input-actions {
950
+ display: flex;
951
+ align-items: center;
952
+ justify-content: space-between;
953
+ padding: 6px 12px;
954
+ }
955
+
956
+ /* ===== MODEL DROPDOWN ===== */
957
+ .model-dropdown {
958
+ position: fixed;
959
+ background: var(--bg-input);
960
+ border: 1px solid var(--border-light);
961
+ border-radius: 12px;
962
+ box-shadow: var(--shadow-lg);
963
+ min-width: 260px;
964
+ z-index: 500;
965
+ padding: 6px;
966
+ animation: dropdownIn 0.2s ease-out;
967
+ }
968
+
969
+ .model-dropdown.hidden {
970
+ display: none;
971
+ }
972
+
973
+ @keyframes dropdownIn {
974
+ from {
975
+ opacity: 0;
976
+ transform: translateY(4px);
977
+ }
978
+
979
+ to {
980
+ opacity: 1;
981
+ transform: translateY(0);
982
+ }
983
+ }
984
+
985
+ .model-dropdown-header {
986
+ font-size: 0.72rem;
987
+ font-weight: 600;
988
+ color: var(--text-tertiary);
989
+ text-transform: uppercase;
990
+ letter-spacing: 0.08em;
991
+ padding: 8px 12px 6px;
992
+ }
993
+
994
+ .model-option {
995
+ display: flex;
996
+ align-items: center;
997
+ justify-content: space-between;
998
+ gap: 10px;
999
+ padding: 10px 12px;
1000
+ border-radius: 8px;
1001
+ cursor: pointer;
1002
+ transition: all var(--transition-fast);
1003
+ overflow: hidden;
1004
+ }
1005
+
1006
+ .model-option:hover {
1007
+ background: var(--bg-hover);
1008
+ }
1009
+
1010
+ .model-option.active {
1011
+ background: var(--accent-bg);
1012
+ }
1013
+
1014
+ .model-option-icon {
1015
+ width: 26px;
1016
+ height: 26px;
1017
+ max-width: 26px;
1018
+ max-height: 26px;
1019
+ min-width: 26px;
1020
+ border-radius: 6px;
1021
+ object-fit: contain;
1022
+ flex-shrink: 0;
1023
+ }
1024
+
1025
+ .model-option-info {
1026
+ display: flex;
1027
+ flex-direction: column;
1028
+ flex: 1;
1029
+ }
1030
+
1031
+ .model-option-name {
1032
+ font-size: 0.88rem;
1033
+ font-weight: 500;
1034
+ color: var(--text-primary);
1035
+ }
1036
+
1037
+ .model-option-desc {
1038
+ font-size: 0.75rem;
1039
+ color: var(--text-tertiary);
1040
+ margin-top: 1px;
1041
+ }
1042
+
1043
+ .model-check {
1044
+ color: var(--accent-primary);
1045
+ opacity: 0;
1046
+ transition: opacity var(--transition-fast);
1047
+ }
1048
+
1049
+ .model-option.active .model-check {
1050
+ opacity: 1;
1051
+ }
1052
+
1053
+ /* ===== UPLOAD MODAL ===== */
1054
+ .upload-modal {
1055
+ position: fixed;
1056
+ inset: 0;
1057
+ z-index: 400;
1058
+ display: flex;
1059
+ align-items: center;
1060
+ justify-content: center;
1061
+ }
1062
+
1063
+ .upload-modal.hidden {
1064
+ display: none;
1065
+ }
1066
+
1067
+ .upload-modal-backdrop {
1068
+ position: absolute;
1069
+ inset: 0;
1070
+ background: rgba(0, 0, 0, 0.4);
1071
+ backdrop-filter: blur(4px);
1072
+ animation: backdropIn 0.25s ease-out;
1073
+ }
1074
+
1075
+ @keyframes backdropIn {
1076
+ from {
1077
+ opacity: 0;
1078
+ }
1079
+
1080
+ to {
1081
+ opacity: 1;
1082
+ }
1083
+ }
1084
+
1085
+ .upload-modal-content {
1086
+ position: relative;
1087
+ background: var(--bg-input);
1088
+ border-radius: 18px;
1089
+ box-shadow: var(--shadow-lg);
1090
+ width: 90%;
1091
+ max-width: 520px;
1092
+ animation: modalIn 0.3s ease-out;
1093
+ }
1094
+
1095
+ @keyframes modalIn {
1096
+ from {
1097
+ opacity: 0;
1098
+ transform: scale(0.96) translateY(8px);
1099
+ }
1100
+
1101
+ to {
1102
+ opacity: 1;
1103
+ transform: scale(1) translateY(0);
1104
+ }
1105
+ }
1106
+
1107
+ .upload-modal-header {
1108
+ display: flex;
1109
+ align-items: center;
1110
+ justify-content: space-between;
1111
+ padding: 20px 24px 12px;
1112
+ }
1113
+
1114
+ .upload-modal-header h3 {
1115
+ font-size: 1.05rem;
1116
+ font-weight: 600;
1117
+ color: var(--text-primary);
1118
+ }
1119
+
1120
+ .upload-close {
1121
+ width: 32px;
1122
+ height: 32px;
1123
+ display: flex;
1124
+ align-items: center;
1125
+ justify-content: center;
1126
+ border: none;
1127
+ background: transparent;
1128
+ font-size: 1.4rem;
1129
+ color: var(--text-tertiary);
1130
+ border-radius: 8px;
1131
+ cursor: pointer;
1132
+ transition: all var(--transition-fast);
1133
+ }
1134
+
1135
+ .upload-close:hover {
1136
+ background: var(--bg-hover);
1137
+ color: var(--text-primary);
1138
+ }
1139
+
1140
+ .upload-modal-body {
1141
+ padding: 0 24px 24px;
1142
+ }
1143
+
1144
+ /* Upload type selector */
1145
+ .upload-type-selector {
1146
+ display: grid;
1147
+ grid-template-columns: repeat(4, 1fr);
1148
+ gap: 6px;
1149
+ margin-bottom: 16px;
1150
+ }
1151
+
1152
+ .upload-type {
1153
+ display: flex;
1154
+ flex-direction: column;
1155
+ align-items: center;
1156
+ gap: 4px;
1157
+ padding: 10px 6px;
1158
+ border: 1px solid var(--border-light);
1159
+ border-radius: 10px;
1160
+ font-size: 0.72rem;
1161
+ font-weight: 500;
1162
+ color: var(--text-secondary);
1163
+ cursor: pointer;
1164
+ text-align: center;
1165
+ transition: all var(--transition-fast);
1166
+ }
1167
+
1168
+ .upload-type:hover {
1169
+ border-color: var(--border-medium);
1170
+ background: var(--bg-hover);
1171
+ }
1172
+
1173
+ .upload-type.active {
1174
+ border-color: var(--accent-primary);
1175
+ background: var(--accent-bg);
1176
+ color: var(--accent-primary);
1177
+ }
1178
+
1179
+ .upload-type span {
1180
+ font-size: 1.2rem;
1181
+ }
1182
+
1183
+ /* Dropzone */
1184
+ .upload-dropzone {
1185
+ border: 2px dashed var(--border-light);
1186
+ border-radius: 14px;
1187
+ padding: 36px 24px;
1188
+ text-align: center;
1189
+ cursor: pointer;
1190
+ transition: all var(--transition-normal);
1191
+ color: var(--text-tertiary);
1192
+ }
1193
+
1194
+ .upload-dropzone:hover,
1195
+ .upload-dropzone.drag-over {
1196
+ border-color: var(--accent-primary);
1197
+ background: var(--accent-bg);
1198
+ color: var(--accent-primary);
1199
+ }
1200
+
1201
+ .upload-dropzone svg {
1202
+ margin-bottom: 12px;
1203
+ opacity: 0.5;
1204
+ }
1205
+
1206
+ .upload-dropzone p {
1207
+ font-size: 0.88rem;
1208
+ color: var(--text-secondary);
1209
+ margin-bottom: 6px;
1210
+ }
1211
+
1212
+ .upload-dropzone strong {
1213
+ color: var(--accent-primary);
1214
+ }
1215
+
1216
+ .upload-formats {
1217
+ font-size: 0.75rem;
1218
+ color: var(--text-tertiary);
1219
+ }
1220
+
1221
+ /* ===== HIDDEN UTILITY ===== */
1222
+ .hidden {
1223
+ display: none !important;
1224
+ }
1225
+
1226
+ /* ===== PROCESSING STATE ===== */
1227
+ .upload-processing {
1228
+ display: flex;
1229
+ align-items: center;
1230
+ gap: 10px;
1231
+ padding: 16px;
1232
+ background: var(--accent-bg);
1233
+ border-radius: 10px;
1234
+ font-size: 0.88rem;
1235
+ color: var(--accent-primary);
1236
+ font-weight: 500;
1237
+ }
1238
+
1239
+ .upload-processing .spinner {
1240
+ width: 18px;
1241
+ height: 18px;
1242
+ border: 2px solid var(--border-light);
1243
+ border-top-color: var(--accent-primary);
1244
+ border-radius: 50%;
1245
+ animation: spin 0.7s linear infinite;
1246
+ }
1247
+
1248
+ @keyframes spin {
1249
+ to {
1250
+ transform: rotate(360deg);
1251
+ }
1252
+ }
1253
+
1254
+ /* Upload success */
1255
+ .upload-success {
1256
+ display: flex;
1257
+ align-items: center;
1258
+ gap: 10px;
1259
+ padding: 12px 16px;
1260
+ background: rgba(22, 163, 74, 0.06);
1261
+ border: 1px solid rgba(22, 163, 74, 0.15);
1262
+ border-radius: 10px;
1263
+ font-size: 0.85rem;
1264
+ color: #16a34a;
1265
+ font-weight: 500;
1266
+ margin-top: 12px;
1267
+ }
1268
+
1269
+ /* ===== RESPONSIVE ===== */
1270
+ @media (max-width: 768px) {
1271
+ .sidebar {
1272
+ position: fixed;
1273
+ left: 0;
1274
+ top: 0;
1275
+ bottom: 0;
1276
+ z-index: 300;
1277
+ width: 100%;
1278
+ max-width: 300px;
1279
+ /* Instead of taking the whole screen on tablets */
1280
+ }
1281
+
1282
+ .sidebar.collapsed {
1283
+ transform: translateX(-100%);
1284
+ width: 100%;
1285
+ min-width: 100%;
1286
+ border-right: 1px solid var(--border-light);
1287
+ }
1288
+
1289
+ .mobile-sidebar-toggle {
1290
+ display: flex;
1291
+ }
1292
+
1293
+ .sidebar:not(.collapsed)~.mobile-sidebar-toggle {
1294
+ display: none;
1295
+ }
1296
+
1297
+ #toggleSidebar {
1298
+ display: flex;
1299
+ }
1300
+
1301
+ .welcome-heading {
1302
+ font-size: 1.6rem;
1303
+ }
1304
+
1305
+ .suggestion-chips {
1306
+ flex-direction: column;
1307
+ align-items: center;
1308
+ }
1309
+
1310
+ .chip {
1311
+ width: 100%;
1312
+ max-width: 300px;
1313
+ justify-content: center;
1314
+ }
1315
+
1316
+ .message-inner {
1317
+ padding: 0 16px;
1318
+ }
1319
+
1320
+ .upload-type-selector {
1321
+ grid-template-columns: repeat(2, 1fr);
1322
+ }
1323
+
1324
+ /* Force show delete button on mobile since there is no hover */
1325
+ .conversation-delete {
1326
+ opacity: 1;
1327
+ padding: 6px;
1328
+ /* slightly bigger touch target */
1329
+ }
1330
+ }
1331
+
1332
+ @media (max-width: 480px) {
1333
+ .welcome-heading {
1334
+ font-size: 1.35rem;
1335
+ }
1336
+
1337
+ .sidebar {
1338
+ width: 100%;
1339
+ min-width: 100%;
1340
+ }
1341
+
1342
+ .sidebar-inner {
1343
+ width: 100%;
1344
+ }
1345
+
1346
+ .notification-bar {
1347
+ font-size: 0.72rem;
1348
+ }
1349
+ }
1350
+
1351
+ /* ===== EXPORT TOOLBAR (in messages) ===== */
1352
+ .export-toolbar {
1353
+ display: flex;
1354
+ align-items: center;
1355
+ gap: 6px;
1356
+ margin-top: 12px;
1357
+ flex-wrap: wrap;
1358
+ }
1359
+
1360
+ .export-btn {
1361
+ padding: 5px 12px;
1362
+ border: 1px solid var(--border-light);
1363
+ border-radius: 8px;
1364
+ background: var(--bg-input);
1365
+ color: var(--text-secondary);
1366
+ font-size: 0.76rem;
1367
+ font-weight: 500;
1368
+ font-family: var(--font-family);
1369
+ cursor: pointer;
1370
+ transition: all var(--transition-fast);
1371
+ display: flex;
1372
+ align-items: center;
1373
+ gap: 4px;
1374
+ }
1375
+
1376
+ .export-btn:hover {
1377
+ border-color: var(--accent-primary);
1378
+ color: var(--accent-primary);
1379
+ background: var(--accent-bg);
1380
+ }
1381
+
1382
+ /* ===== TOAST NOTIFICATION ===== */
1383
+ .toast {
1384
+ position: fixed;
1385
+ bottom: 24px;
1386
+ left: 50%;
1387
+ transform: translateX(-50%) translateY(100px);
1388
+ background: var(--text-primary);
1389
+ color: var(--bg-primary);
1390
+ padding: 10px 20px;
1391
+ border-radius: 10px;
1392
+ font-size: 0.85rem;
1393
+ font-weight: 500;
1394
+ z-index: 1000;
1395
+ opacity: 0;
1396
+ transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
1397
+ pointer-events: none;
1398
+ }
1399
+
1400
+ .toast.show {
1401
+ opacity: 1;
1402
+ transform: translateX(-50%) translateY(0);
1403
+ }
1404
+
1405
+ /* ===== MARKDOWN STYLES in AI messages ===== */
1406
+ .message-content h1,
1407
+ .message-content h2,
1408
+ .message-content h3 {
1409
+ margin-top: 16px;
1410
+ margin-bottom: 8px;
1411
+ font-weight: 600;
1412
+ color: var(--text-primary);
1413
+ }
1414
+
1415
+ .message-content h1 {
1416
+ font-size: 1.25rem;
1417
+ }
1418
+
1419
+ .message-content h2 {
1420
+ font-size: 1.1rem;
1421
+ }
1422
+
1423
+ .message-content h3 {
1424
+ font-size: 1rem;
1425
+ }
1426
+
1427
+ .message-content blockquote {
1428
+ border-left: 3px solid var(--accent-primary);
1429
+ padding-left: 16px;
1430
+ margin: 12px 0;
1431
+ color: var(--text-secondary);
1432
+ font-style: italic;
1433
+ }
1434
+
1435
+ .message-content hr {
1436
+ border: none;
1437
+ border-top: 1px solid var(--border-light);
1438
+ margin: 16px 0;
1439
+ }
1440
+
1441
+ .message-content table {
1442
+ border-collapse: collapse;
1443
+ width: 100%;
1444
+ margin: 12px 0;
1445
+ font-size: 0.88rem;
1446
+ }
1447
+
1448
+ .message-content th,
1449
+ .message-content td {
1450
+ border: 1px solid var(--border-light);
1451
+ padding: 8px 12px;
1452
+ text-align: left;
1453
+ }
1454
+
1455
+ .message-content th {
1456
+ background: var(--bg-hover);
1457
+ font-weight: 600;
1458
+ }
1459
+
1460
+ /* ===== API KEY CONFIG PANEL ===== */
1461
+ .config-panel {
1462
+ max-width: 768px;
1463
+ margin: 40px auto;
1464
+ padding: 0 24px;
1465
+ }
1466
+
1467
+ .config-card {
1468
+ background: var(--bg-input);
1469
+ border: 1px solid var(--border-light);
1470
+ border-radius: 16px;
1471
+ padding: 32px;
1472
+ box-shadow: var(--shadow-md);
1473
+ }
1474
+
1475
+ .config-card h2 {
1476
+ font-size: 1.15rem;
1477
+ font-weight: 600;
1478
+ margin-bottom: 8px;
1479
+ display: flex;
1480
+ align-items: center;
1481
+ gap: 8px;
1482
+ }
1483
+
1484
+ .config-card p {
1485
+ color: var(--text-secondary);
1486
+ font-size: 0.88rem;
1487
+ margin-bottom: 20px;
1488
+ }
1489
+
1490
+ .config-input-group {
1491
+ display: flex;
1492
+ gap: 8px;
1493
+ margin-bottom: 12px;
1494
+ }
1495
+
1496
+ .config-input {
1497
+ flex: 1;
1498
+ padding: 10px 14px;
1499
+ border: 1px solid var(--border-input);
1500
+ border-radius: 10px;
1501
+ font-size: 0.9rem;
1502
+ font-family: var(--font-family);
1503
+ color: var(--text-primary);
1504
+ background: var(--bg-primary);
1505
+ outline: none;
1506
+ transition: all var(--transition-fast);
1507
+ }
1508
+
1509
+ .config-input:focus {
1510
+ border-color: var(--border-focus);
1511
+ box-shadow: var(--shadow-input-focus);
1512
+ }
1513
+
1514
+ .config-btn {
1515
+ padding: 10px 20px;
1516
+ border: none;
1517
+ background: var(--text-primary);
1518
+ color: var(--bg-primary);
1519
+ border-radius: 10px;
1520
+ font-size: 0.88rem;
1521
+ font-weight: 600;
1522
+ font-family: var(--font-family);
1523
+ cursor: pointer;
1524
+ transition: all var(--transition-fast);
1525
+ white-space: nowrap;
1526
+ }
1527
+
1528
+ .config-btn:hover {
1529
+ background: var(--accent-primary);
1530
+ transform: translateY(-1px);
1531
+ }
1532
+
1533
+ .config-status {
1534
+ display: flex;
1535
+ align-items: center;
1536
+ gap: 6px;
1537
+ font-size: 0.82rem;
1538
+ font-weight: 500;
1539
+ }
1540
+
1541
+ .config-status.connected {
1542
+ color: #16a34a;
1543
+ }
1544
+
1545
+ .config-status.disconnected {
1546
+ color: #dc2626;
1547
+ }
1548
+
1549
+ /* ===== LOGIN MODAL ===== */
1550
+ .google-btn {
1551
+ display: flex;
1552
+ align-items: center;
1553
+ justify-content: center;
1554
+ gap: 12px;
1555
+ width: 100%;
1556
+ padding: 12px 16px;
1557
+ background: white;
1558
+ color: #3c4043;
1559
+ border: 1px solid #dadce0;
1560
+ border-radius: 8px;
1561
+ font-size: 0.95rem;
1562
+ font-weight: 500;
1563
+ font-family: 'Roboto', var(--font-family);
1564
+ cursor: pointer;
1565
+ transition: all var(--transition-fast);
1566
+ }
1567
+
1568
+ .google-btn:hover {
1569
+ background: #f8f9fa;
1570
+ box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
1571
+ }
1572
+
1573
+ .google-btn svg {
1574
+ min-width: 20px;
1575
+ }
1576
+
1577
+ .status-dot {
1578
+ width: 6px;
1579
+ height: 6px;
1580
+ border-radius: 50%;
1581
+ background: currentColor;
1582
+ }
1583
+
1584
+ /* ===== DARK SELECT DROPDOWNS ===== */
1585
+ select.welcome-input,
1586
+ select {
1587
+ background-color: var(--bg-secondary) !important;
1588
+ color: var(--text-primary) !important;
1589
+ border-color: var(--border-medium) !important;
1590
+ color-scheme: dark;
1591
+ -webkit-appearance: none;
1592
+ appearance: none;
1593
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
1594
+ background-repeat: no-repeat;
1595
+ background-position: right 10px center;
1596
+ padding-right: 32px !important;
1597
+ }
1598
+
1599
+ select.welcome-input option,
1600
+ select option {
1601
+ background-color: var(--bg-secondary) !important;
1602
+ color: var(--text-primary) !important;
1603
+ }
1604
+
1605
+ /* ===== JOBS CUSTOM DROPDOWNS ===== */
1606
+ .jobs-custom-select {
1607
+ position: relative;
1608
+ user-select: none;
1609
+ }
1610
+
1611
+ .jobs-select-btn {
1612
+ display: flex;
1613
+ align-items: center;
1614
+ justify-content: space-between;
1615
+ gap: 6px;
1616
+ padding: 6px 10px;
1617
+ background: var(--bg-hover);
1618
+ border: 1px solid var(--border-medium);
1619
+ border-radius: 6px;
1620
+ color: var(--text-primary);
1621
+ font-size: 0.8rem;
1622
+ cursor: pointer;
1623
+ transition: border-color 0.2s, background 0.2s;
1624
+ white-space: nowrap;
1625
+ }
1626
+
1627
+ .jobs-select-btn:hover {
1628
+ border-color: var(--accent-primary);
1629
+ background: var(--bg-secondary);
1630
+ }
1631
+
1632
+ .jobs-select-btn svg {
1633
+ flex-shrink: 0;
1634
+ color: var(--text-tertiary);
1635
+ transition: transform 0.2s;
1636
+ }
1637
+
1638
+ .jobs-custom-select.open .jobs-select-btn svg {
1639
+ transform: rotate(180deg);
1640
+ }
1641
+
1642
+ .jobs-custom-select.open .jobs-select-btn {
1643
+ border-color: var(--accent-primary);
1644
+ }
1645
+
1646
+ .jobs-select-menu {
1647
+ display: none;
1648
+ position: absolute;
1649
+ top: calc(100% + 4px);
1650
+ left: 0;
1651
+ min-width: 100%;
1652
+ background: var(--bg-secondary);
1653
+ border: 1px solid var(--border-medium);
1654
+ border-radius: 8px;
1655
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1656
+ z-index: 9999;
1657
+ overflow: hidden;
1658
+ animation: dropdownFadeIn 0.15s ease;
1659
+ }
1660
+
1661
+ @keyframes dropdownFadeIn {
1662
+ from {
1663
+ opacity: 0;
1664
+ transform: translateY(-4px);
1665
+ }
1666
+
1667
+ to {
1668
+ opacity: 1;
1669
+ transform: translateY(0);
1670
+ }
1671
+ }
1672
+
1673
+ .jobs-custom-select.open .jobs-select-menu {
1674
+ display: block;
1675
+ }
1676
+
1677
+ .jobs-select-option {
1678
+ padding: 8px 14px;
1679
+ font-size: 0.82rem;
1680
+ color: var(--text-secondary);
1681
+ cursor: pointer;
1682
+ transition: background 0.15s, color 0.15s;
1683
+ white-space: nowrap;
1684
+ }
1685
+
1686
+ .jobs-select-option:hover {
1687
+ background: var(--bg-hover);
1688
+ color: var(--text-primary);
1689
+ }
1690
+
1691
+ .jobs-select-option.active {
1692
+ color: var(--accent-primary);
1693
+ font-weight: 600;
1694
+ background: rgba(139, 92, 246, 0.08);
1695
+ }
render.yaml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: careerai
4
+ runtime: docker
5
+ plan: free
6
+ region: oregon
7
+ dockerfilePath: ./Dockerfile
8
+ envVars:
9
+ # Use lightweight embedding model (fits in 512 MB RAM)
10
+ - key: EMBEDDING_MODEL
11
+ value: gte-multilingual
12
+ # Disable reranker to save ~1.5 GB RAM
13
+ - key: ENABLE_RERANKING
14
+ value: "false"
15
+ # Your API keys (set these in Render dashboard, NOT here)
16
+ - key: GROQ_API_KEY
17
+ sync: false
18
+ - key: SECRET_KEY
19
+ generateValue: true
20
+ - key: JSEARCH_API_KEY
21
+ sync: false
22
+ - key: MAIL_USERNAME
23
+ sync: false
24
+ - key: MAIL_PASSWORD
25
+ sync: false
26
+ - key: MAIL_FROM
27
+ sync: false
requirements.txt ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ======================== CareerAI Dependencies ========================
2
+ # Backend framework
3
+ fastapi>=0.115.0
4
+ uvicorn[standard]>=0.30.0
5
+ python-multipart>=0.0.9
6
+ python-dotenv>=1.0.0
7
+
8
+ # LLM & RAG
9
+ langchain>=0.3.0
10
+ langchain-groq>=0.2.0
11
+ langchain-huggingface>=0.1.2
12
+ langchain-chroma>=0.2.0
13
+ langchain-community>=0.3.0
14
+ chromadb>=0.5.0
15
+ sentence-transformers>=3.0.0
16
+ rank-bm25>=0.2.2
17
+
18
+ # Document processing
19
+ pypdf>=4.0.0
20
+ python-docx>=1.0.0
21
+ pdfplumber>=0.11.0
22
+ PyMuPDF>=1.24.0
23
+
24
+ # Export
25
+ fpdf2>=2.7.0
26
+
27
+ # Authentication
28
+ sqlalchemy>=2.0.0
29
+ python-jose[cryptography]>=3.3.0
30
+ bcrypt>=4.0.0
31
+ email-validator>=2.0.0
32
+ fastapi-mail>=1.4.0
33
+
34
+ # HTTP client (for JSearch API)
35
+ httpx>=0.27.0
36
+
37
+ # Google OAuth (optional)
38
+ google-auth>=2.0.0
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # CareerAI - AI Career Assistant with RAG
src/auth.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
3
+ from sqlalchemy.orm import Session
4
+ from sqlalchemy import or_
5
+ from datetime import datetime, timedelta
6
+ from jose import JWTError, jwt
7
+ from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
8
+ import bcrypt
9
+ import json
10
+ import random
11
+
12
+ from src.models import get_db, User, Conversation
13
+
14
+ import os
15
+
16
+ # Memory dictionary to mock sent emails for recovery
17
+ reset_codes = {}
18
+
19
+ # REAL EMAIL CONFIGURATION (reads from .env) β€” only if configured
20
+ _mail_from = os.environ.get("MAIL_FROM", "")
21
+ _mail_user = os.environ.get("MAIL_USERNAME", "")
22
+ _mail_pass = os.environ.get("MAIL_PASSWORD", "")
23
+
24
+ if _mail_from and "@" in _mail_from and _mail_user and _mail_pass:
25
+ conf_mail = ConnectionConfig(
26
+ MAIL_USERNAME=_mail_user,
27
+ MAIL_PASSWORD=_mail_pass,
28
+ MAIL_FROM=_mail_from,
29
+ MAIL_PORT=587,
30
+ MAIL_SERVER="smtp.gmail.com",
31
+ MAIL_STARTTLS=True,
32
+ MAIL_SSL_TLS=False,
33
+ USE_CREDENTIALS=True,
34
+ VALIDATE_CERTS=True
35
+ )
36
+ fast_mail = FastMail(conf_mail)
37
+ else:
38
+ conf_mail = None
39
+ fast_mail = None
40
+
41
+ # JWT configuration (reads from .env)
42
+ SECRET_KEY = os.environ.get("SECRET_KEY", "fallback_dev_key_change_this")
43
+ ALGORITHM = "HS256"
44
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days validity
45
+
46
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
47
+
48
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
49
+
50
+ def verify_password(plain_password: str, hashed_password: str):
51
+ if isinstance(hashed_password, str):
52
+ hashed_password = hashed_password.encode('utf-8')
53
+ return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password)
54
+
55
+ def get_password_hash(password: str):
56
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
57
+
58
+ def create_access_token(data: dict, expires_delta: timedelta = None):
59
+ to_encode = data.copy()
60
+ if expires_delta:
61
+ expire = datetime.utcnow() + expires_delta
62
+ else:
63
+ expire = datetime.utcnow() + timedelta(minutes=15)
64
+ to_encode.update({"exp": expire})
65
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
66
+
67
+ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
68
+ credentials_exception = HTTPException(
69
+ status_code=status.HTTP_401_UNAUTHORIZED,
70
+ detail="Could not validate credentials",
71
+ headers={"WWW-Authenticate": "Bearer"},
72
+ )
73
+ try:
74
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
75
+ email: str = payload.get("sub")
76
+ if email is None:
77
+ raise credentials_exception
78
+ except JWTError:
79
+ raise credentials_exception
80
+
81
+ user = db.query(User).filter(User.email == email).first()
82
+ if user is None:
83
+ raise credentials_exception
84
+ return user
85
+
86
+ from fastapi import Header
87
+
88
+ async def get_user_or_session_id(
89
+ authorization: str = Header(None),
90
+ x_session_id: str = Header(None)
91
+ ) -> str:
92
+ """Extracts a private effective user ID to isolate documents and chats."""
93
+ # 1. Try logged-in user from JWT
94
+ if authorization and authorization.startswith("Bearer "):
95
+ token = authorization.split(" ")[1]
96
+ try:
97
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
98
+ user_sub = payload.get("sub")
99
+ if user_sub:
100
+ return f"user_{user_sub}"
101
+ except JWTError:
102
+ pass
103
+ # 2. Try anonymous session header
104
+ if x_session_id:
105
+ return f"guest_{x_session_id}"
106
+ # 3. Fallback
107
+ return "anonymous"
108
+
109
+ from pydantic import BaseModel, EmailStr
110
+ from typing import Optional
111
+
112
+ class UserCreate(BaseModel):
113
+ name: str
114
+ email: EmailStr
115
+ password: str
116
+
117
+ class UserUpdate(BaseModel):
118
+ name: Optional[str] = None
119
+ picture: Optional[str] = None
120
+
121
+ class ForgotPasswordBody(BaseModel):
122
+ email: EmailStr
123
+
124
+ class ResetPasswordBody(BaseModel):
125
+ email: EmailStr
126
+ code: str
127
+ new_password: str
128
+
129
+ class GoogleLogin(BaseModel):
130
+ token: str
131
+
132
+ @router.post("/register")
133
+ def register(user: UserCreate, db: Session = Depends(get_db)):
134
+ db_user = db.query(User).filter(User.email == user.email).first()
135
+ if db_user:
136
+ raise HTTPException(status_code=400, detail="El correo ya estΓ‘ registrado")
137
+
138
+ new_user = User(
139
+ email=user.email,
140
+ name=user.name,
141
+ hashed_password=get_password_hash(user.password),
142
+ picture="https://ui-avatars.com/api/?name=" + user.name.replace(" ", "+")
143
+ )
144
+ db.add(new_user)
145
+ db.commit()
146
+ db.refresh(new_user)
147
+
148
+ access_token = create_access_token(
149
+ data={"sub": new_user.email}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
150
+ )
151
+
152
+ return {"access_token": access_token, "token_type": "bearer", "user": {"name": new_user.name, "email": new_user.email, "picture": new_user.picture}}
153
+
154
+ @router.post("/login")
155
+ def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
156
+ user = db.query(User).filter(User.email == form_data.username).first()
157
+ if not user or not user.hashed_password:
158
+ raise HTTPException(status_code=400, detail="Correo o contraseΓ±a incorrectos")
159
+ if not verify_password(form_data.password, user.hashed_password):
160
+ raise HTTPException(status_code=400, detail="Correo o contraseΓ±a incorrectos")
161
+
162
+ access_token = create_access_token(
163
+ data={"sub": user.email}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
164
+ )
165
+ return {"access_token": access_token, "token_type": "bearer", "user": {"name": user.name, "email": user.email, "picture": user.picture}}
166
+
167
+ @router.post("/forgot-password")
168
+ async def forgot_password(body: ForgotPasswordBody, db: Session = Depends(get_db)):
169
+ user = db.query(User).filter(User.email == body.email).first()
170
+ if not user:
171
+ # Prevent user-enumeration, always return ok
172
+ return {"status": "ok", "message": "Si el correo estΓ‘ registrado, se enviΓ³ un cΓ³digo temporal."}
173
+
174
+ code = str(random.randint(100000, 999999))
175
+ reset_codes[body.email] = code
176
+
177
+ # Send actual email if configured, otherwise print to terminal
178
+ if fast_mail is not None:
179
+ message = MessageSchema(
180
+ subject="RecuperaciΓ³n de ContraseΓ±a - CareerAI",
181
+ recipients=[body.email],
182
+ body=f"Hola {user.name},\n\nHemos recibido una solicitud para restablecer tu contraseΓ±a.\n\nTu cΓ³digo de recuperaciΓ³n es: {code}\n\nSi no fuiste tΓΊ, ignora este mensaje.",
183
+ subtype=MessageType.plain
184
+ )
185
+ try:
186
+ await fast_mail.send_message(message)
187
+ print(f"πŸ“§ Correo Real enviado exitosamente a {body.email}")
188
+ except Exception as e:
189
+ print(f"❌ Error enviando el correo real: {str(e)}")
190
+ else:
191
+ print("\n" + "="*50)
192
+ print("πŸ“§ SIMULACIΓ“N (Email no configurado en producciΓ³n):")
193
+ print(f"Para: {body.email}")
194
+ print("Asunto: RecuperaciΓ³n de tu contraseΓ±a")
195
+ print(f"Tu cΓ³digo de recuperaciΓ³n temporal es: {code}")
196
+ print("="*50 + "\n")
197
+
198
+ return {"status": "ok", "message": "Si el correo estΓ‘ registrado, se enviΓ³ un cΓ³digo temporal."}
199
+
200
+ @router.post("/reset-password")
201
+ def reset_password(body: ResetPasswordBody, db: Session = Depends(get_db)):
202
+ if reset_codes.get(body.email) != body.code:
203
+ raise HTTPException(status_code=400, detail="CΓ³digo invΓ‘lido o ya ha expirado")
204
+
205
+ user = db.query(User).filter(User.email == body.email).first()
206
+ if not user:
207
+ raise HTTPException(status_code=400, detail="Usuario no encontrado")
208
+
209
+ user.hashed_password = get_password_hash(body.new_password)
210
+ db.commit()
211
+
212
+ reset_codes.pop(body.email, None) # Invalidate token safely
213
+ return {"status": "ok", "message": "ContraseΓ±a actualizada exitosamente"}
214
+
215
+ # Try to import Google Auth (if installed)
216
+ try:
217
+ from google.oauth2 import id_token
218
+ from google.auth.transport import requests as google_requests
219
+ GOOGLE_AUTH_AVAILABLE = True
220
+ except ImportError:
221
+ GOOGLE_AUTH_AVAILABLE = False
222
+
223
+ @router.post("/google")
224
+ def google_login(google_data: GoogleLogin, db: Session = Depends(get_db)):
225
+ if not GOOGLE_AUTH_AVAILABLE:
226
+ raise HTTPException(status_code=500, detail="Google Auth is not installed properly")
227
+
228
+ try:
229
+ # Avoid verifying clientId to allow any client side requests for demo purposes
230
+ # In production use ONLY your registered CLIENT_ID
231
+ idinfo = id_token.verify_oauth2_token(
232
+ google_data.token,
233
+ google_requests.Request()
234
+ )
235
+
236
+ email = idinfo['email']
237
+ name = idinfo.get('name', 'Google User')
238
+ picture = idinfo.get('picture', '')
239
+ google_id = idinfo['sub']
240
+
241
+ user = db.query(User).filter(or_(User.email == email, User.google_id == google_id)).first()
242
+
243
+ if not user:
244
+ # Create user automatically
245
+ user = User(email=email, name=name, picture=picture, google_id=google_id)
246
+ db.add(user)
247
+ db.commit()
248
+ db.refresh(user)
249
+ else:
250
+ # Update user info if needed
251
+ if not user.google_id:
252
+ user.google_id = google_id
253
+ if picture:
254
+ user.picture = picture
255
+ db.commit()
256
+
257
+ access_token = create_access_token(
258
+ data={"sub": user.email}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
259
+ )
260
+ return {"access_token": access_token, "token_type": "bearer", "user": {"name": user.name, "email": user.email, "picture": user.picture}}
261
+
262
+ except ValueError as e:
263
+ raise HTTPException(status_code=400, detail="Token de Google invΓ‘lido")
264
+
265
+ @router.get("/me")
266
+ def get_me(current_user: User = Depends(get_current_user)):
267
+ return {"name": current_user.name, "email": current_user.email, "picture": current_user.picture}
268
+
269
+ @router.post("/me")
270
+ def update_me(user_update: UserUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
271
+ if user_update.name is not None:
272
+ current_user.name = user_update.name
273
+ if user_update.picture is not None:
274
+ current_user.picture = user_update.picture
275
+
276
+ db.commit()
277
+ db.refresh(current_user)
278
+ return {"name": current_user.name, "email": current_user.email, "picture": current_user.picture}
279
+
280
+ # ================= Conversations Router Endpoints =================
281
+ conv_router = APIRouter(prefix="/api/conversations", tags=["conversations"])
282
+
283
+ class ConversationBody(BaseModel):
284
+ id: str
285
+ title: str
286
+ messages: list
287
+
288
+ @conv_router.get("")
289
+ def list_conversations(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
290
+ convs = db.query(Conversation).filter(Conversation.user_id == current_user.id).order_by(Conversation.updated_at.desc()).all()
291
+ # Format according to frontend expectations
292
+ return [{"id": c.id, "title": c.title, "messages": c.messages} for c in convs]
293
+
294
+ @conv_router.post("")
295
+ def save_conversation(data: ConversationBody, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
296
+ conv = db.query(Conversation).filter(Conversation.id == data.id).first()
297
+
298
+ if conv:
299
+ if conv.user_id != current_user.id:
300
+ raise HTTPException(status_code=403, detail="Not authorized")
301
+ conv.title = data.title
302
+ conv.messages = data.messages
303
+ # updated_at will auto-update
304
+ else:
305
+ conv = Conversation(
306
+ id=data.id,
307
+ user_id=current_user.id,
308
+ title=data.title,
309
+ messages=data.messages
310
+ )
311
+ db.add(conv)
312
+
313
+ db.commit()
314
+ return {"status": "ok", "message": "ConversaciΓ³n guardada"}
315
+
316
+ @conv_router.delete("/{conv_id}")
317
+ def delete_conversation(conv_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
318
+ conv = db.query(Conversation).filter(Conversation.id == conv_id).first()
319
+ if not conv:
320
+ raise HTTPException(status_code=404, detail="Not found")
321
+ if conv.user_id != current_user.id:
322
+ raise HTTPException(status_code=403, detail="Not authorized")
323
+
324
+ db.delete(conv)
325
+ db.commit()
326
+ return {"status": "ok", "message": "ConversaciΓ³n eliminada"}
src/career_assistant.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Career Assistant - AI-powered career advisor using Groq + Llama 3.3 with specialized modes.
3
+ """
4
+ from typing import List, Dict, Generator
5
+ from langchain_groq import ChatGroq
6
+ from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
7
+
8
+
9
+ class CareerAssistant:
10
+ """AI Career Assistant with specialized modes for job matching, cover letters, and skills analysis."""
11
+
12
+ SYSTEM_BASE = """Eres CareerAI, un Asistente de Carrera Profesional de Γ©lite. Eres experto en:
13
+ - AnΓ‘lisis de CVs y perfiles profesionales
14
+ - Matching de candidatos con ofertas de trabajo
15
+ - RedacciΓ³n de cover letters y cartas de presentaciΓ³n
16
+ - AnΓ‘lisis de brechas de habilidades (skills gap)
17
+ - Estrategia de carrera y desarrollo profesional
18
+
19
+ REGLAS FUNDAMENTALES:
20
+ 1. SIEMPRE basa tus respuestas en los documentos REALES del usuario que se te proporcionan
21
+ 2. Si no tienes informaciΓ³n suficiente en los documentos, indΓ­calo claramente
22
+ 3. NO inventes datos, experiencias o habilidades que no estΓ©n en los documentos
23
+ 4. SΓ© especΓ­fico, accionable y prΓ‘ctico en tus recomendaciones
24
+ 5. Responde en el MISMO IDIOMA que usa el usuario
25
+ 6. Usa formato Markdown rico (headers, bullets, emojis, tablas) para estructurar
26
+ 7. SΓ© honesto pero motivador - seΓ±ala fortalezas Y Γ‘reas de mejora
27
+ 8. Cuando des porcentajes o mΓ©tricas, explica tu razonamiento"""
28
+
29
+ PROMPTS = {
30
+ "general": """Eres CareerAI. Responde la pregunta del usuario sobre su carrera profesional.
31
+
32
+ DOCUMENTOS DEL USUARIO:
33
+ {context}
34
+
35
+ Instrucciones:
36
+ - Basa tu respuesta en los documentos proporcionados
37
+ - SΓ© prΓ‘ctico y accionable
38
+ - Si el usuario no ha subido documentos relevantes, sugiΓ©rele quΓ© subir
39
+ - Formato: Usa markdown con headers, bullets y emojis
40
+
41
+ Pregunta del usuario: {query}""",
42
+
43
+ "job_match": """Eres CareerAI en modo ANÁLISIS DE COMPATIBILIDAD LABORAL.
44
+
45
+ DOCUMENTOS DEL USUARIO (CV, perfil, ofertas de trabajo):
46
+ {context}
47
+
48
+ INSTRUCCIONES - Analiza la compatibilidad y genera un reporte detallado:
49
+
50
+ ## 1. 🎯 Score de Compatibilidad
51
+ - Calcula un porcentaje REALISTA (0-100%) basado en:
52
+ β€’ Skills tΓ©cnicos que coinciden vs. requeridos
53
+ β€’ AΓ±os de experiencia relevante
54
+ β€’ Nivel de seniority (Junior/Mid/Senior/Lead)
55
+ β€’ Requisitos especΓ­ficos (idiomas, certificaciones, ubicaciΓ³n)
56
+ β€’ Soft skills mencionados
57
+
58
+ ## 2. βœ… Lo que SÍ tiene el candidato
59
+ - Lista cada skill/requisito que el candidato cumple
60
+ - Referencia dΓ³nde aparece en su CV
61
+
62
+ ## 3. ❌ Lo que le FALTA
63
+ - Lista cada gap identificado
64
+ - Clasifica por importancia (CrΓ­tico / Importante / Nice-to-have)
65
+
66
+ ## 4. πŸ’‘ Recomendaciones
67
+ - CΓ³mo cubrir cada gap en orden de prioridad
68
+ - Recursos gratuitos especΓ­ficos para aprender
69
+ - Timeframe estimado
70
+
71
+ ## 5. πŸ“Š Resumen Ejecutivo
72
+ - Veredicto: ΒΏDeberΓ­a aplicar? ΒΏCon quΓ© estrategia?
73
+
74
+ Pregunta del usuario: {query}""",
75
+
76
+ "cover_letter": """Eres CareerAI en modo GENERADOR DE COVER LETTERS.
77
+
78
+ DOCUMENTOS DEL USUARIO (CV, perfil, oferta de trabajo):
79
+ {context}
80
+
81
+ INSTRUCCIONES - Genera una cover letter profesional y personalizada:
82
+
83
+ 1. **Usa datos REALES** del CV/perfil del usuario (nombre, experiencia, logros)
84
+ 2. **Adapta** especΓ­ficamente a la oferta de trabajo (empresa, rol, requisitos)
85
+ 3. **Estructura**:
86
+ - Apertura impactante (hook + por quΓ© esta empresa)
87
+ - PΓ‘rrafo de experiencia relevante (con logros cuantificables)
88
+ - PΓ‘rrafo de skills matching (conecta tu perfil con requisitos)
89
+ - Cierre fuerte (call to action)
90
+ 4. **Tono**: Profesional pero autΓ©ntico, no genΓ©rico
91
+ 5. **Longitud**: 3-4 pΓ‘rrafos (250-400 palabras)
92
+ 6. **Idioma**: Genera en el idioma de la oferta de trabajo
93
+ 7. **Formato**: Cover letter lista para copiar y pegar
94
+
95
+ DespuΓ©s de la carta, incluye:
96
+ - πŸ’‘ Tips para personalizar aΓΊn mΓ‘s
97
+ - πŸ“§ Sugerencia de subject line para email
98
+ - ⚠️ Cosas a verificar antes de enviar
99
+
100
+ Solicitud del usuario: {query}""",
101
+
102
+ "skills_gap": """Eres CareerAI en modo ANÁLISIS DE BRECHA DE HABILIDADES.
103
+
104
+ DOCUMENTOS DEL USUARIO:
105
+ {context}
106
+
107
+ INSTRUCCIONES - Realiza un anΓ‘lisis profundo de skills:
108
+
109
+ ## 1. πŸ“‹ Inventario de Skills Actuales
110
+ Extrae TODAS las habilidades del usuario de sus documentos:
111
+ - πŸ’» Hard Skills / TΓ©cnicos
112
+ - 🧠 Soft Skills
113
+ - πŸ› οΈ Herramientas y TecnologΓ­as
114
+ - 🌍 Idiomas
115
+ - πŸŽ“ Certificaciones
116
+
117
+ ## 2. πŸ“ Nivel Actual Estimado
118
+ - Junior / Mid-Level / Senior / Lead / Principal
119
+ - Justifica tu evaluaciΓ³n con evidencia de los documentos
120
+
121
+ ## 3. πŸš€ Roadmap al Siguiente Nivel
122
+ Para cada categorΓ­a, indica:
123
+
124
+ | Skill Necesario | Prioridad | Recurso Gratuito Recomendado | Tiempo Estimado |
125
+ |----------------|-----------|------------------------------|-----------------|
126
+
127
+ ## 4. πŸ“ˆ Plan de AcciΓ³n (90 dΓ­as)
128
+ - Semana 1-2: Quick wins
129
+ - Semana 3-6: Skills prioritarios
130
+ - Semana 7-12: ProfundizaciΓ³n y proyectos
131
+
132
+ ## 5. 🎯 Skills mÑs Demandados en el Mercado
133
+ - Basado en el perfil del usuario, quΓ© skills tienen mΓ‘s demanda
134
+
135
+ Pregunta del usuario: {query}""",
136
+
137
+ "interview": """Eres CareerAI en modo SIMULADOR DE ENTREVISTAS LABORALES.
138
+
139
+ DOCUMENTOS DEL USUARIO (CV, perfil, ofertas de trabajo):
140
+ {context}
141
+
142
+ INSTRUCCIONES - ActΓΊa como un entrevistador profesional experto:
143
+
144
+ ## Tu rol:
145
+ Eres un entrevistador senior que estΓ‘ evaluando al candidato para el puesto.
146
+ Tus preguntas deben ser ESPECÍFICAS basadas en el CV real y la oferta de trabajo (si hay).
147
+
148
+ ## CΓ³mo funciona la simulaciΓ³n:
149
+
150
+ ### Si el usuario dice "empezar entrevista" o "simular entrevista":
151
+ Genera una sesiΓ³n de entrevista estructurada con:
152
+
153
+ ## 🎀 Simulación de Entrevista
154
+
155
+ ### πŸ‘‹ IntroducciΓ³n
156
+ - PresΓ©ntate como entrevistador (inventa un nombre y empresa basado en la oferta)
157
+ - Rompe el hielo con una pregunta ligera
158
+
159
+ ### πŸ“‹ Fase 1: Preguntas de Comportamiento (STAR Method)
160
+ Genera 3-4 preguntas basadas en la experiencia del CV:
161
+ - Usa el mΓ©todo STAR (SituaciΓ³n, Tarea, AcciΓ³n, Resultado)
162
+ - Referencia experiencias especΓ­ficas del CV
163
+ - Ejemplos: "CuΓ©ntame sobre un proyecto donde tuviste que..."
164
+
165
+ ### πŸ’» Fase 2: Preguntas TΓ©cnicas
166
+ Genera 3-4 preguntas tΓ©cnicas relevantes:
167
+ - Basadas en los skills del CV y requisitos de la oferta
168
+ - Variedad: conceptuales, de diseΓ±o, y prΓ‘cticas
169
+ - Adaptadas al nivel del candidato (junior/mid/senior)
170
+
171
+ ### 🧠 Fase 3: Preguntas Situacionales
172
+ Genera 2-3 preguntas hipotΓ©ticas:
173
+ - "ΒΏQuΓ© harΓ­as si...?"
174
+ - Basadas en desafΓ­os reales del puesto
175
+
176
+ ### ❓ Fase 4: Preguntas del Candidato
177
+ - "ΒΏTenΓ©s preguntas para nosotros?"
178
+ - Sugiere 3 preguntas inteligentes que el candidato podrΓ­a hacer
179
+
180
+ Para CADA pregunta incluye:
181
+ - πŸ’‘ **Tip**: QuΓ© busca el entrevistador con esta pregunta
182
+ - βœ… **Respuesta ideal**: Framework o puntos clave que deberΓ­a mencionar
183
+ - ⚠️ **Red flags**: Qué NO decir
184
+
185
+ ### Si el usuario RESPONDE una pregunta de entrevista:
186
+ - EvalΓΊa su respuesta (fortalezas y debilidades)
187
+ - Da feedback constructivo y especΓ­fico
188
+ - Sugiere cΓ³mo mejorar la respuesta
189
+ - DespuΓ©s hace la SIGUIENTE pregunta
190
+
191
+ Solicitud del usuario: {query}""",
192
+ }
193
+
194
+ AVAILABLE_MODELS = [
195
+ "llama-3.3-70b-versatile",
196
+ "llama-3.1-8b-instant",
197
+ "mixtral-8x7b-32768",
198
+ "gemma2-9b-it",
199
+ ]
200
+
201
+ def __init__(self, api_key: str, model: str = "llama-3.3-70b-versatile"):
202
+ """Initialize the career assistant with Groq API."""
203
+ self.api_key = api_key
204
+ self.model = model
205
+ self.llm = ChatGroq(
206
+ groq_api_key=api_key,
207
+ model_name=model,
208
+ temperature=0.3,
209
+ max_tokens=4096,
210
+ )
211
+
212
+ def _build_messages(
213
+ self,
214
+ system_prompt: str,
215
+ query: str,
216
+ chat_history: List[Dict] = None,
217
+ ) -> list:
218
+ """Build the message list for the LLM."""
219
+ messages = [SystemMessage(content=system_prompt)]
220
+
221
+ # Include recent chat history for context continuity
222
+ if chat_history:
223
+ for msg in chat_history[-8:]: # Last 8 messages
224
+ if msg["role"] == "user":
225
+ messages.append(HumanMessage(content=msg["content"]))
226
+ elif msg["role"] == "assistant":
227
+ # Truncate long assistant messages in history
228
+ content = msg["content"]
229
+ if len(content) > 1000:
230
+ content = content[:1000] + "\n... [respuesta anterior truncada]"
231
+ messages.append(AIMessage(content=content))
232
+
233
+ messages.append(HumanMessage(content=query))
234
+ return messages
235
+
236
+ def chat(
237
+ self,
238
+ query: str,
239
+ context: str,
240
+ chat_history: List[Dict] = None,
241
+ mode: str = "general",
242
+ ) -> str:
243
+ """Process a query and return a complete response."""
244
+ template = self.PROMPTS.get(mode, self.PROMPTS["general"])
245
+ system_prompt = self.SYSTEM_BASE + "\n\n" + template.format(
246
+ context=context, query=query
247
+ )
248
+
249
+ messages = self._build_messages(system_prompt, query, chat_history)
250
+
251
+ try:
252
+ response = self.llm.invoke(messages)
253
+ return response.content
254
+ except Exception as e:
255
+ error_msg = str(e)
256
+ if "rate_limit" in error_msg.lower():
257
+ return "⏳ **Límite de velocidad alcanzado.** Espera unos segundos e intenta de nuevo. Groq tiene un límite generoso pero puede saturarse con consultas muy seguidas."
258
+ elif "authentication" in error_msg.lower() or "api_key" in error_msg.lower():
259
+ return "πŸ”‘ **Error de autenticaciΓ³n.** Verifica tu API key de Groq. Puedes obtener una gratis en [console.groq.com](https://console.groq.com)"
260
+ else:
261
+ return f"❌ **Error al procesar tu consulta:**\n\n`{error_msg}`\n\nVerifica tu API key y conexión a internet."
262
+
263
+ def stream_chat(
264
+ self,
265
+ query: str,
266
+ context: str,
267
+ chat_history: List[Dict] = None,
268
+ mode: str = "general",
269
+ ) -> Generator[str, None, None]:
270
+ """Stream a response token by token for real-time display."""
271
+ template = self.PROMPTS.get(mode, self.PROMPTS["general"])
272
+ system_prompt = self.SYSTEM_BASE + "\n\n" + template.format(
273
+ context=context, query=query
274
+ )
275
+
276
+ messages = self._build_messages(system_prompt, query, chat_history)
277
+
278
+ try:
279
+ for chunk in self.llm.stream(messages):
280
+ if chunk.content:
281
+ yield chunk.content
282
+ except Exception as e:
283
+ error_msg = str(e)
284
+ if "rate_limit" in error_msg.lower():
285
+ yield "\n\n⏳ **Límite de velocidad alcanzado.** Espera unos segundos e intenta de nuevo."
286
+ elif "authentication" in error_msg.lower():
287
+ yield "\n\nπŸ”‘ **Error de autenticaciΓ³n.** Verifica tu API key de Groq."
288
+ else:
289
+ yield f"\n\n❌ **Error:** `{error_msg}`"
290
+
291
+ def detect_mode(self, query: str) -> str:
292
+ """Auto-detect the best mode based on the user's query."""
293
+ query_lower = query.lower()
294
+
295
+ interview_keywords = [
296
+ "entrevista", "interview", "simula", "pregunta",
297
+ "practica", "preparar entrevista", "mock interview",
298
+ "entrevistar", "preguntas tΓ©cnicas", "behavioral",
299
+ ]
300
+ job_keywords = [
301
+ "match", "compatib", "oferta", "job", "vacante", "posiciΓ³n",
302
+ "requisito", "aplica", "pegan", "encaj", "cumplo",
303
+ ]
304
+ cover_keywords = [
305
+ "cover letter", "carta", "presentaciΓ³n", "letter",
306
+ "aplicar", "postular", "escribir carta", "redacta",
307
+ ]
308
+ skills_keywords = [
309
+ "skill", "habilidad", "faltan", "gap", "senior",
310
+ "mejorar", "aprender", "certificac", "nivel",
311
+ "roadmap", "plan", "desarrollo",
312
+ ]
313
+
314
+ for kw in interview_keywords:
315
+ if kw in query_lower:
316
+ return "interview"
317
+
318
+ for kw in cover_keywords:
319
+ if kw in query_lower:
320
+ return "cover_letter"
321
+
322
+ for kw in job_keywords:
323
+ if kw in query_lower:
324
+ return "job_match"
325
+
326
+ for kw in skills_keywords:
327
+ if kw in query_lower:
328
+ return "skills_gap"
329
+
330
+ return "general"
src/document_processor.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Document Processor - Extracts text from PDF, DOCX, TXT, and IMAGES (via Groq Vision).
3
+ Supports scanned PDFs and photos of documents.
4
+ """
5
+ import os
6
+ import base64
7
+ from typing import List
8
+
9
+
10
+ class DocumentProcessor:
11
+ """Process various document formats and extract text for RAG indexing."""
12
+
13
+ SUPPORTED_FORMATS = [".pdf", ".txt", ".docx", ".doc", ".jpg", ".jpeg", ".png", ".webp"]
14
+ IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"]
15
+
16
+ @staticmethod
17
+ def extract_text(file_path: str, groq_api_key: str = None) -> str:
18
+ """Extract text from a file based on its extension.
19
+ For images and scanned PDFs, uses Groq Vision API.
20
+ """
21
+ ext = os.path.splitext(file_path)[1].lower()
22
+
23
+ if ext in DocumentProcessor.IMAGE_FORMATS:
24
+ if not groq_api_key:
25
+ raise ValueError("Se necesita API key de Groq para procesar imΓ‘genes")
26
+ return DocumentProcessor._extract_image(file_path, groq_api_key)
27
+ elif ext == ".pdf":
28
+ return DocumentProcessor._extract_pdf(file_path, groq_api_key)
29
+ elif ext == ".txt":
30
+ return DocumentProcessor._extract_txt(file_path)
31
+ elif ext in [".docx", ".doc"]:
32
+ return DocumentProcessor._extract_docx(file_path)
33
+ else:
34
+ raise ValueError(f"Formato no soportado: {ext}")
35
+
36
+ @staticmethod
37
+ def _extract_image(file_path: str, groq_api_key: str) -> str:
38
+ """Extract text from an image using Groq Vision (Llama 4 Scout)."""
39
+ try:
40
+ from groq import Groq
41
+
42
+ # Read and encode image
43
+ with open(file_path, "rb") as f:
44
+ image_data = f.read()
45
+
46
+ base64_image = base64.b64encode(image_data).decode("utf-8")
47
+
48
+ # Detect MIME type
49
+ ext = os.path.splitext(file_path)[1].lower()
50
+ mime_map = {
51
+ ".jpg": "image/jpeg",
52
+ ".jpeg": "image/jpeg",
53
+ ".png": "image/png",
54
+ ".webp": "image/webp",
55
+ ".gif": "image/gif",
56
+ ".bmp": "image/bmp",
57
+ }
58
+ mime_type = mime_map.get(ext, "image/jpeg")
59
+
60
+ # Call Groq Vision API
61
+ client = Groq(api_key=groq_api_key)
62
+ response = client.chat.completions.create(
63
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
64
+ messages=[
65
+ {
66
+ "role": "user",
67
+ "content": [
68
+ {
69
+ "type": "text",
70
+ "text": (
71
+ "ExtraΓ© TODO el texto de esta imagen de documento exactamente como aparece. "
72
+ "IncluΓ­ todos los detalles: nombres, fechas, experiencia laboral, educaciΓ³n, "
73
+ "habilidades, idiomas, certificaciones, datos de contacto, y cualquier otra "
74
+ "informaciΓ³n. MantenΓ© la estructura original. Si hay tablas, extraΓ© el contenido. "
75
+ "RespondΓ© SOLO con el texto extraΓ­do, sin comentarios adicionales."
76
+ ),
77
+ },
78
+ {
79
+ "type": "image_url",
80
+ "image_url": {
81
+ "url": f"data:{mime_type};base64,{base64_image}"
82
+ },
83
+ },
84
+ ],
85
+ }
86
+ ],
87
+ max_tokens=4096,
88
+ temperature=0.1,
89
+ )
90
+
91
+ text = response.choices[0].message.content
92
+ if text and text.strip():
93
+ return text.strip()
94
+ else:
95
+ raise ValueError("No se pudo extraer texto de la imagen")
96
+
97
+ except ImportError:
98
+ raise ValueError("Instala el paquete 'groq': pip install groq")
99
+ except Exception as e:
100
+ if "groq" in str(type(e).__module__).lower():
101
+ raise ValueError(f"Error de Groq Vision API: {e}")
102
+ raise ValueError(f"Error procesando imagen: {e}")
103
+
104
+ @staticmethod
105
+ def _extract_pdf(file_path: str, groq_api_key: str = None) -> str:
106
+ """Extract text from PDF. Tries 3 methods + Vision API for scanned PDFs."""
107
+ text = ""
108
+
109
+ # Method 1: PyPDF (fast, works with text PDFs)
110
+ try:
111
+ from pypdf import PdfReader
112
+
113
+ reader = PdfReader(file_path)
114
+ for page in reader.pages:
115
+ page_text = page.extract_text()
116
+ if page_text:
117
+ text += page_text + "\n"
118
+ if text.strip() and len(text.strip()) > 50:
119
+ return text.strip()
120
+ except Exception:
121
+ pass
122
+
123
+ # Method 2: pdfplumber (better with complex layouts)
124
+ try:
125
+ import pdfplumber
126
+
127
+ text = ""
128
+ with pdfplumber.open(file_path) as pdf:
129
+ for page in pdf.pages:
130
+ page_text = page.extract_text()
131
+ if page_text:
132
+ text += page_text + "\n"
133
+
134
+ # Also try extracting tables
135
+ try:
136
+ tables = page.extract_tables()
137
+ for table in tables:
138
+ for row in table:
139
+ if row:
140
+ row_text = " | ".join(
141
+ str(cell).strip() for cell in row if cell
142
+ )
143
+ if row_text:
144
+ text += row_text + "\n"
145
+ except Exception:
146
+ pass
147
+
148
+ if text.strip() and len(text.strip()) > 50:
149
+ return text.strip()
150
+ except Exception:
151
+ pass
152
+
153
+ # Method 3: PyMuPDF / fitz (handles more PDF types)
154
+ try:
155
+ import fitz
156
+
157
+ doc = fitz.open(file_path)
158
+ fitz_text = ""
159
+ for page in doc:
160
+ page_text = page.get_text()
161
+ if page_text:
162
+ fitz_text += page_text + "\n"
163
+ doc.close()
164
+
165
+ if fitz_text.strip() and len(fitz_text.strip()) > 50:
166
+ return fitz_text.strip()
167
+ except Exception:
168
+ pass
169
+
170
+ # Method 4: Vision AI - render PDF pages as images and read with Llama Vision
171
+ if groq_api_key:
172
+ try:
173
+ return DocumentProcessor._extract_pdf_via_vision(
174
+ file_path, groq_api_key
175
+ )
176
+ except Exception as vision_err:
177
+ # If vision also fails, give detailed error
178
+ pass
179
+
180
+ # Last resort
181
+ if text.strip():
182
+ return text.strip()
183
+
184
+ raise ValueError(
185
+ "No se pudo extraer texto del PDF. "
186
+ "Puede ser un PDF escaneado. Intenta subir una imagen/captura del documento."
187
+ )
188
+
189
+ @staticmethod
190
+ def _extract_pdf_via_vision(file_path: str, groq_api_key: str) -> str:
191
+ """Extract text from a scanned PDF by converting pages to images and using Vision."""
192
+ try:
193
+ # Try using fitz (PyMuPDF) to convert PDF pages to images
194
+ import fitz # PyMuPDF
195
+
196
+ doc = fitz.open(file_path)
197
+ all_text = []
198
+
199
+ for page_num in range(min(len(doc), 5)): # Max 5 pages
200
+ page = doc[page_num]
201
+ # Render page as image
202
+ mat = fitz.Matrix(2, 2) # 2x zoom for better quality
203
+ pix = page.get_pixmap(matrix=mat)
204
+ img_bytes = pix.tobytes("png")
205
+
206
+ # Use Vision API
207
+ base64_image = base64.b64encode(img_bytes).decode("utf-8")
208
+
209
+ from groq import Groq
210
+
211
+ client = Groq(api_key=groq_api_key)
212
+ response = client.chat.completions.create(
213
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
214
+ messages=[
215
+ {
216
+ "role": "user",
217
+ "content": [
218
+ {
219
+ "type": "text",
220
+ "text": (
221
+ f"PΓ‘gina {page_num + 1}. ExtraΓ© TODO el texto de esta pΓ‘gina "
222
+ "exactamente como aparece. IncluΓ­ todos los detalles. "
223
+ "RespondΓ© SOLO con el texto extraΓ­do."
224
+ ),
225
+ },
226
+ {
227
+ "type": "image_url",
228
+ "image_url": {
229
+ "url": f"data:image/png;base64,{base64_image}"
230
+ },
231
+ },
232
+ ],
233
+ }
234
+ ],
235
+ max_tokens=4096,
236
+ temperature=0.1,
237
+ )
238
+
239
+ page_text = response.choices[0].message.content
240
+ if page_text and page_text.strip():
241
+ all_text.append(page_text.strip())
242
+
243
+ doc.close()
244
+
245
+ if all_text:
246
+ return "\n\n".join(all_text)
247
+
248
+ except ImportError:
249
+ # PyMuPDF not installed, try converting via PIL
250
+ pass
251
+ except Exception:
252
+ pass
253
+
254
+ # If PyMuPDF conversion failed, try reading the raw PDF as image
255
+ # (some PDFs are essentially single-page images)
256
+ try:
257
+ with open(file_path, "rb") as f:
258
+ pdf_bytes = f.read()
259
+ base64_pdf = base64.b64encode(pdf_bytes).decode("utf-8")
260
+
261
+ from groq import Groq
262
+
263
+ client = Groq(api_key=groq_api_key)
264
+ response = client.chat.completions.create(
265
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
266
+ messages=[
267
+ {
268
+ "role": "user",
269
+ "content": [
270
+ {
271
+ "type": "text",
272
+ "text": (
273
+ "ExtraΓ© TODO el texto de este documento. "
274
+ "IncluΓ­ nombres, fechas, experiencia, skills. "
275
+ "RespondΓ© SOLO con el texto extraΓ­do."
276
+ ),
277
+ },
278
+ {
279
+ "type": "image_url",
280
+ "image_url": {
281
+ "url": f"data:application/pdf;base64,{base64_pdf}"
282
+ },
283
+ },
284
+ ],
285
+ }
286
+ ],
287
+ max_tokens=4096,
288
+ temperature=0.1,
289
+ )
290
+ text = response.choices[0].message.content
291
+ if text and text.strip():
292
+ return text.strip()
293
+ except Exception:
294
+ pass
295
+
296
+ raise ValueError("No se pudo extraer texto del PDF escaneado")
297
+
298
+ @staticmethod
299
+ def _extract_txt(file_path: str) -> str:
300
+ """Extract text from a plain text file."""
301
+ encodings = ["utf-8", "latin-1", "cp1252"]
302
+ for encoding in encodings:
303
+ try:
304
+ with open(file_path, "r", encoding=encoding) as f:
305
+ return f.read().strip()
306
+ except (UnicodeDecodeError, UnicodeError):
307
+ continue
308
+ raise ValueError("No se pudo leer el archivo de texto")
309
+
310
+ @staticmethod
311
+ def _extract_docx(file_path: str) -> str:
312
+ """Extract text from a Word document."""
313
+ try:
314
+ from docx import Document
315
+
316
+ doc = Document(file_path)
317
+ paragraphs = []
318
+ for para in doc.paragraphs:
319
+ if para.text.strip():
320
+ paragraphs.append(para.text.strip())
321
+
322
+ # Also extract from tables
323
+ for table in doc.tables:
324
+ for row in table.rows:
325
+ row_text = " | ".join(
326
+ cell.text.strip() for cell in row.cells if cell.text.strip()
327
+ )
328
+ if row_text:
329
+ paragraphs.append(row_text)
330
+
331
+ return "\n".join(paragraphs)
332
+ except Exception as e:
333
+ raise ValueError(f"No se pudo leer el archivo DOCX: {e}")
334
+
335
+ @staticmethod
336
+ def chunk_text(
337
+ text: str, chunk_size: int = 400, overlap: int = 80
338
+ ) -> List[str]:
339
+ """Split text into overlapping chunks for embedding."""
340
+ if not text or not text.strip():
341
+ return []
342
+
343
+ paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
344
+ full_text = "\n".join(paragraphs)
345
+ words = full_text.split()
346
+
347
+ if len(words) <= chunk_size:
348
+ return [full_text]
349
+
350
+ chunks = []
351
+ start = 0
352
+
353
+ while start < len(words):
354
+ end = min(start + chunk_size, len(words))
355
+ chunk = " ".join(words[start:end])
356
+ if chunk.strip():
357
+ chunks.append(chunk.strip())
358
+
359
+ if end >= len(words):
360
+ break
361
+
362
+ start += chunk_size - overlap
363
+
364
+ return chunks
365
+
366
+ @staticmethod
367
+ def extract_key_info(text: str) -> dict:
368
+ """Extract basic key information from document text."""
369
+ info = {
370
+ "has_email": False,
371
+ "has_phone": False,
372
+ "word_count": len(text.split()),
373
+ "line_count": len(text.split("\n")),
374
+ }
375
+
376
+ import re
377
+
378
+ if re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", text):
379
+ info["has_email"] = True
380
+ if re.search(r"[\+]?[\d\s\-\(\)]{7,15}", text):
381
+ info["has_phone"] = True
382
+
383
+ return info
src/exporter.py ADDED
@@ -0,0 +1,1171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export Module - Generate PDF, DOCX, TXT, and HTML downloads from AI responses.
3
+ Premium formatting with professional layouts and smart content detection.
4
+ """
5
+ import io
6
+ import re
7
+ from datetime import datetime
8
+ from typing import List, Dict, Optional
9
+
10
+
11
+ # ======================== TEXT CLEANING ========================
12
+
13
+ def clean_markdown(text: str) -> str:
14
+ """Remove markdown formatting for clean document export."""
15
+ # Remove bold/italic markers
16
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
17
+ text = re.sub(r'\*(.+?)\*', r'\1', text)
18
+ text = re.sub(r'__(.+?)__', r'\1', text)
19
+ text = re.sub(r'_(.+?)_', r'\1', text)
20
+ # Remove headers markers but keep text
21
+ text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
22
+ # Remove bullet markers
23
+ text = re.sub(r'^[\-\*]\s+', '- ', text, flags=re.MULTILINE)
24
+ # Remove numbered lists prefix (keep number)
25
+ text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE)
26
+ # Remove code blocks markers
27
+ text = re.sub(r'```[\w]*\n?', '', text)
28
+ # Remove inline code
29
+ text = re.sub(r'`(.+?)`', r'\1', text)
30
+ # Remove links but keep text
31
+ text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
32
+ # Remove emojis (common ones)
33
+ text = re.sub(
34
+ r'[πŸŽ―βœ‰οΈπŸ“ˆπŸ§ πŸ’‘πŸš€β­πŸ“‹πŸ”πŸ’ΌπŸ“Šβœ…βŒβš οΈπŸŽ“πŸ—ΊοΈπŸ“„πŸ€–πŸ‘‹πŸ’¬πŸ“ŽπŸ“·πŸ“šπŸ“­πŸŽ¨πŸ”₯πŸ’ͺπŸ†πŸŒŸβœ¨πŸŽ‰πŸ’°πŸ“ŒπŸ”‘βš‘πŸ› οΈπŸπŸ“πŸ’ŽπŸ₯‡πŸ₯ˆπŸ₯‰]',
35
+ '', text
36
+ )
37
+ # Clean up extra whitespace
38
+ text = re.sub(r'\n{3,}', '\n\n', text)
39
+ return text.strip()
40
+
41
+
42
+ def detect_content_type(text: str) -> str:
43
+ """Detect the type of content for smart file naming."""
44
+ text_lower = text.lower()
45
+ if any(w in text_lower for w in ['cover letter', 'carta de presentaciΓ³n', 'carta de motivaciΓ³n', 'estimado', 'dear']):
46
+ return "cover_letter"
47
+ if any(w in text_lower for w in ['match', 'compatibilidad', 'porcentaje', 'afinidad', '% de match']):
48
+ return "job_match"
49
+ if any(w in text_lower for w in ['skills gap', 'habilidades faltantes', 'roadmap', 'skill gap', 'brecha']):
50
+ return "skills_analysis"
51
+ if any(w in text_lower for w in ['resumen', 'perfil profesional', 'summary', 'strengths']):
52
+ return "profile_summary"
53
+ return "response"
54
+
55
+
56
+ def get_smart_filename(text: str, extension: str) -> str:
57
+ """Generate an intelligent filename based on content type."""
58
+ content_type = detect_content_type(text)
59
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M")
60
+
61
+ type_names = {
62
+ "cover_letter": "CoverLetter",
63
+ "job_match": "JobMatch",
64
+ "skills_analysis": "SkillsAnalysis",
65
+ "profile_summary": "ProfileSummary",
66
+ "response": "CareerAI",
67
+ }
68
+
69
+ name = type_names.get(content_type, "CareerAI")
70
+ return f"{name}_{timestamp}.{extension}"
71
+
72
+
73
+ def get_smart_title(text: str) -> str:
74
+ """Generate a smart document title based on content."""
75
+ content_type = detect_content_type(text)
76
+ titles = {
77
+ "cover_letter": "Carta de PresentaciΓ³n",
78
+ "job_match": "AnΓ‘lisis de Compatibilidad",
79
+ "skills_analysis": "AnΓ‘lisis de Habilidades",
80
+ "profile_summary": "Resumen de Perfil",
81
+ "response": "Respuesta CareerAI",
82
+ }
83
+ return titles.get(content_type, "Respuesta CareerAI")
84
+
85
+
86
+ # ======================== MARKDOWN PARSER ========================
87
+
88
+ def parse_markdown_blocks(text: str) -> list:
89
+ """
90
+ Parse markdown into structured blocks for rich document rendering.
91
+ Returns list of dicts: {type, content, level}
92
+ """
93
+ blocks = []
94
+ lines = text.split('\n')
95
+ i = 0
96
+
97
+ while i < len(lines):
98
+ line = lines[i]
99
+
100
+ # Headers
101
+ header_match = re.match(r'^(#{1,6})\s+(.+)', line)
102
+ if header_match:
103
+ level = len(header_match.group(1))
104
+ blocks.append({
105
+ 'type': 'header',
106
+ 'content': header_match.group(2).strip(),
107
+ 'level': level
108
+ })
109
+ i += 1
110
+ continue
111
+
112
+ # Horizontal rules
113
+ if re.match(r'^[\-\*\_]{3,}\s*$', line):
114
+ blocks.append({'type': 'hr', 'content': '', 'level': 0})
115
+ i += 1
116
+ continue
117
+
118
+ # Bullet lists
119
+ bullet_match = re.match(r'^[\-\*]\s+(.+)', line)
120
+ if bullet_match:
121
+ items = [bullet_match.group(1).strip()]
122
+ i += 1
123
+ while i < len(lines):
124
+ next_bullet = re.match(r'^[\-\*]\s+(.+)', lines[i])
125
+ if next_bullet:
126
+ items.append(next_bullet.group(1).strip())
127
+ i += 1
128
+ else:
129
+ break
130
+ blocks.append({'type': 'bullet_list', 'content': items, 'level': 0})
131
+ continue
132
+
133
+ # Numbered lists
134
+ num_match = re.match(r'^(\d+)\.\s+(.+)', line)
135
+ if num_match:
136
+ items = [num_match.group(2).strip()]
137
+ i += 1
138
+ while i < len(lines):
139
+ next_num = re.match(r'^\d+\.\s+(.+)', lines[i])
140
+ if next_num:
141
+ items.append(next_num.group(1).strip())
142
+ i += 1
143
+ else:
144
+ break
145
+ blocks.append({'type': 'numbered_list', 'content': items, 'level': 0})
146
+ continue
147
+
148
+ # Code blocks
149
+ if line.strip().startswith('```'):
150
+ lang = line.strip()[3:]
151
+ code_lines = []
152
+ i += 1
153
+ while i < len(lines) and not lines[i].strip().startswith('```'):
154
+ code_lines.append(lines[i])
155
+ i += 1
156
+ if i < len(lines):
157
+ i += 1 # skip closing ```
158
+ blocks.append({
159
+ 'type': 'code',
160
+ 'content': '\n'.join(code_lines),
161
+ 'level': 0,
162
+ 'lang': lang
163
+ })
164
+ continue
165
+
166
+ # Bold/emphasis lines (like "**SecciΓ³n:**")
167
+ bold_match = re.match(r'^\*\*(.+?)\*\*:?\s*$', line.strip())
168
+ if bold_match:
169
+ blocks.append({
170
+ 'type': 'bold_heading',
171
+ 'content': bold_match.group(1).strip(),
172
+ 'level': 0
173
+ })
174
+ i += 1
175
+ continue
176
+
177
+ # Empty lines
178
+ if not line.strip():
179
+ i += 1
180
+ continue
181
+
182
+ # Regular paragraph (collect consecutive lines)
183
+ para_lines = [line]
184
+ i += 1
185
+ while i < len(lines) and lines[i].strip() and not re.match(r'^#{1,6}\s+', lines[i]) \
186
+ and not re.match(r'^[\-\*]\s+', lines[i]) and not re.match(r'^\d+\.\s+', lines[i]) \
187
+ and not lines[i].strip().startswith('```') and not re.match(r'^\*\*(.+?)\*\*:?\s*$', lines[i].strip()):
188
+ para_lines.append(lines[i])
189
+ i += 1
190
+
191
+ blocks.append({
192
+ 'type': 'paragraph',
193
+ 'content': ' '.join(para_lines),
194
+ 'level': 0
195
+ })
196
+
197
+ return blocks
198
+
199
+
200
+ def strip_inline_md(text: str) -> str:
201
+ """Remove inline markdown (bold, italic, code, links) from text."""
202
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
203
+ text = re.sub(r'\*(.+?)\*', r'\1', text)
204
+ text = re.sub(r'__(.+?)__', r'\1', text)
205
+ text = re.sub(r'_(.+?)_', r'\1', text)
206
+ text = re.sub(r'`(.+?)`', r'\1', text)
207
+ text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
208
+ return text
209
+
210
+
211
+ def _sanitize_for_pdf(text: str) -> str:
212
+ """Replace Unicode characters with ASCII equivalents for PDF Helvetica font."""
213
+ replacements = {
214
+ '\u2022': '-',
215
+ '\u2013': '-',
216
+ '\u2014': '--',
217
+ '\u2018': "'",
218
+ '\u2019': "'",
219
+ '\u201c': '"',
220
+ '\u201d': '"',
221
+ '\u2026': '...',
222
+ '\u2192': '->',
223
+ '\u2190': '<-',
224
+ '\u00b7': '-',
225
+ '\u2500': '-',
226
+ '\u2501': '-',
227
+ '\u25cf': '-',
228
+ '\u2605': '*',
229
+ '\u2713': 'v',
230
+ '\u2717': 'x',
231
+ }
232
+ for char, replacement in replacements.items():
233
+ text = text.replace(char, replacement)
234
+ text = re.sub(
235
+ r'[\U0001F300-\U0001F9FF\U00002702-\U000027B0\U0000FE00-\U0000FE0F\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF]',
236
+ '', text
237
+ )
238
+ return text
239
+
240
+
241
+ # ======================== PDF EXPORT ========================
242
+
243
+ def export_to_pdf(text: str, title: Optional[str] = None) -> bytes:
244
+ """Export text content to a premium-styled PDF."""
245
+ try:
246
+ from fpdf import FPDF
247
+ except ImportError:
248
+ raise ValueError("Instala fpdf2: pip install fpdf2")
249
+
250
+ if title is None:
251
+ title = get_smart_title(text)
252
+
253
+ pdf = FPDF()
254
+ pdf.set_auto_page_break(auto=True, margin=25)
255
+ pdf.add_page()
256
+
257
+ page_width = pdf.w - 40 # margins
258
+
259
+ # ---- Header Band ----
260
+ pdf.set_fill_color(88, 60, 200)
261
+ pdf.rect(0, 0, 210, 3, 'F')
262
+
263
+ # ---- Title ----
264
+ pdf.set_y(15)
265
+ pdf.set_font("Helvetica", "B", 20)
266
+ pdf.set_text_color(88, 60, 200)
267
+ pdf.cell(0, 12, title, new_x="LMARGIN", new_y="NEXT", align="C")
268
+ pdf.ln(1)
269
+
270
+ # ---- Subtitle / Date ----
271
+ pdf.set_font("Helvetica", "", 9)
272
+ pdf.set_text_color(140, 140, 150)
273
+ date_str = datetime.now().strftime("%d de %B, %Y | %H:%M")
274
+ pdf.cell(0, 6, f"Generado el {date_str}", new_x="LMARGIN", new_y="NEXT", align="C")
275
+ pdf.ln(2)
276
+
277
+ # ---- Divider ----
278
+ y = pdf.get_y()
279
+ pdf.set_draw_color(200, 200, 215)
280
+ pdf.set_line_width(0.3)
281
+ # Gradient-like effect with multiple lines
282
+ pdf.set_draw_color(88, 60, 200)
283
+ pdf.line(70, y, 140, y)
284
+ pdf.set_draw_color(200, 200, 215)
285
+ pdf.line(40, y + 0.5, 170, y + 0.5)
286
+ pdf.ln(10)
287
+
288
+ # ---- Content Blocks ----
289
+ blocks = parse_markdown_blocks(text)
290
+
291
+ for block in blocks:
292
+ btype = block['type']
293
+
294
+ if btype == 'header':
295
+ level = block['level']
296
+ content = _sanitize_for_pdf(strip_inline_md(block['content']))
297
+ pdf.ln(4)
298
+
299
+ if level == 1:
300
+ pdf.set_font("Helvetica", "B", 16)
301
+ pdf.set_text_color(30, 30, 45)
302
+ elif level == 2:
303
+ pdf.set_font("Helvetica", "B", 14)
304
+ pdf.set_text_color(88, 60, 200)
305
+ elif level == 3:
306
+ pdf.set_font("Helvetica", "B", 12)
307
+ pdf.set_text_color(60, 60, 80)
308
+ else:
309
+ pdf.set_font("Helvetica", "B", 11)
310
+ pdf.set_text_color(80, 80, 100)
311
+
312
+ pdf.multi_cell(0, 7, content)
313
+ pdf.ln(2)
314
+
315
+ elif btype == 'bold_heading':
316
+ content = _sanitize_for_pdf(strip_inline_md(block['content']))
317
+ pdf.ln(3)
318
+ pdf.set_font("Helvetica", "B", 12)
319
+ pdf.set_text_color(88, 60, 200)
320
+ pdf.multi_cell(0, 7, content)
321
+ pdf.set_text_color(30, 30, 45)
322
+ pdf.ln(1)
323
+
324
+ elif btype == 'paragraph':
325
+ content = _sanitize_for_pdf(strip_inline_md(block['content']))
326
+ pdf.set_font("Helvetica", "", 10.5)
327
+ pdf.set_text_color(40, 40, 50)
328
+ pdf.multi_cell(0, 5.5, content)
329
+ pdf.ln(3)
330
+
331
+ elif btype == 'bullet_list':
332
+ for item in block['content']:
333
+ item_clean = _sanitize_for_pdf(strip_inline_md(item))
334
+ pdf.set_font("Helvetica", "", 10.5)
335
+ pdf.set_text_color(88, 60, 200)
336
+ pdf.cell(8, 5.5, "-")
337
+ pdf.set_text_color(40, 40, 50)
338
+ pdf.multi_cell(0, 5.5, f" {item_clean}")
339
+ pdf.ln(1)
340
+ pdf.ln(2)
341
+
342
+ elif btype == 'numbered_list':
343
+ for idx, item in enumerate(block['content'], 1):
344
+ item_clean = _sanitize_for_pdf(strip_inline_md(item))
345
+ pdf.set_font("Helvetica", "B", 10.5)
346
+ pdf.set_text_color(88, 60, 200)
347
+ pdf.cell(10, 5.5, f"{idx}.")
348
+ pdf.set_font("Helvetica", "", 10.5)
349
+ pdf.set_text_color(40, 40, 50)
350
+ pdf.multi_cell(0, 5.5, f" {item_clean}")
351
+ pdf.ln(1)
352
+ pdf.ln(2)
353
+
354
+ elif btype == 'code':
355
+ pdf.ln(2)
356
+ # Code block background
357
+ pdf.set_fill_color(245, 245, 248)
358
+ pdf.set_font("Courier", "", 9)
359
+ pdf.set_text_color(60, 60, 80)
360
+ code_lines = block['content'].split('\n')
361
+ for cl in code_lines:
362
+ pdf.cell(0, 5, f" {cl}", new_x="LMARGIN", new_y="NEXT", fill=True)
363
+ pdf.ln(3)
364
+
365
+ elif btype == 'hr':
366
+ pdf.ln(3)
367
+ y = pdf.get_y()
368
+ pdf.set_draw_color(200, 200, 215)
369
+ pdf.line(20, y, 190, y)
370
+ pdf.ln(5)
371
+
372
+ # ---- Footer ----
373
+ pdf.ln(10)
374
+ y = pdf.get_y()
375
+ pdf.set_draw_color(88, 60, 200)
376
+ pdf.line(60, y, 150, y)
377
+ pdf.ln(6)
378
+ pdf.set_font("Helvetica", "I", 8)
379
+ pdf.set_text_color(160, 160, 175)
380
+ pdf.cell(0, 5, "Generado por CareerAI - Asistente de Carrera con IA", align="C")
381
+ pdf.ln(4)
382
+ pdf.set_font("Helvetica", "", 7)
383
+ pdf.cell(0, 4, "Powered by RAG + Llama 3.3 + ChromaDB", align="C")
384
+
385
+ # Bottom band
386
+ pdf.set_fill_color(88, 60, 200)
387
+ pdf.rect(0, 294, 210, 3, 'F')
388
+
389
+ return pdf.output()
390
+
391
+
392
+ # ======================== DOCX EXPORT ========================
393
+
394
+ def export_to_docx(text: str, title: Optional[str] = None) -> bytes:
395
+ """Export text content to a professionally styled DOCX."""
396
+ try:
397
+ from docx import Document
398
+ from docx.shared import Pt, RGBColor, Inches, Cm
399
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
400
+ from docx.oxml.ns import qn
401
+ from docx.oxml import OxmlElement
402
+ except ImportError:
403
+ raise ValueError("Instala python-docx: pip install python-docx")
404
+
405
+ if title is None:
406
+ title = get_smart_title(text)
407
+
408
+ doc = Document()
409
+
410
+ # ---- Page margins ----
411
+ for section in doc.sections:
412
+ section.top_margin = Cm(2)
413
+ section.bottom_margin = Cm(2)
414
+ section.left_margin = Cm(2.5)
415
+ section.right_margin = Cm(2.5)
416
+
417
+ # ---- Default font ----
418
+ style = doc.styles['Normal']
419
+ font = style.font
420
+ font.name = 'Calibri'
421
+ font.size = Pt(11)
422
+ font.color.rgb = RGBColor(40, 40, 50)
423
+
424
+ # ---- Accent line ----
425
+ accent_para = doc.add_paragraph()
426
+ accent_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
427
+ accent_run = accent_para.add_run("━" * 40)
428
+ accent_run.font.color.rgb = RGBColor(88, 60, 200)
429
+ accent_run.font.size = Pt(6)
430
+
431
+ # ---- Title ----
432
+ title_para = doc.add_paragraph()
433
+ title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
434
+ title_para.space_after = Pt(4)
435
+ title_run = title_para.add_run(title)
436
+ title_run.font.size = Pt(22)
437
+ title_run.font.bold = True
438
+ title_run.font.color.rgb = RGBColor(88, 60, 200)
439
+ title_run.font.name = 'Calibri Light'
440
+
441
+ # ---- Date ----
442
+ date_para = doc.add_paragraph()
443
+ date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
444
+ date_para.space_after = Pt(2)
445
+ date_str = datetime.now().strftime("%d de %B, %Y β€’ %H:%M")
446
+ date_run = date_para.add_run(f"Generado el {date_str}")
447
+ date_run.font.size = Pt(9)
448
+ date_run.font.color.rgb = RGBColor(140, 140, 150)
449
+
450
+ # ---- Divider ----
451
+ div_para = doc.add_paragraph()
452
+ div_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
453
+ div_para.space_after = Pt(12)
454
+ div_run = div_para.add_run("─" * 50)
455
+ div_run.font.color.rgb = RGBColor(200, 200, 215)
456
+ div_run.font.size = Pt(8)
457
+
458
+ # ---- Content Blocks ----
459
+ blocks = parse_markdown_blocks(text)
460
+
461
+ for block in blocks:
462
+ btype = block['type']
463
+
464
+ if btype == 'header':
465
+ level = block['level']
466
+ content = strip_inline_md(block['content'])
467
+
468
+ p = doc.add_paragraph()
469
+ p.space_before = Pt(12)
470
+ p.space_after = Pt(4)
471
+ run = p.add_run(content)
472
+ run.font.bold = True
473
+
474
+ if level == 1:
475
+ run.font.size = Pt(18)
476
+ run.font.color.rgb = RGBColor(30, 30, 45)
477
+ elif level == 2:
478
+ run.font.size = Pt(15)
479
+ run.font.color.rgb = RGBColor(88, 60, 200)
480
+ elif level == 3:
481
+ run.font.size = Pt(13)
482
+ run.font.color.rgb = RGBColor(60, 60, 80)
483
+ else:
484
+ run.font.size = Pt(12)
485
+ run.font.color.rgb = RGBColor(80, 80, 100)
486
+
487
+ elif btype == 'bold_heading':
488
+ content = strip_inline_md(block['content'])
489
+ p = doc.add_paragraph()
490
+ p.space_before = Pt(8)
491
+ p.space_after = Pt(2)
492
+ run = p.add_run(content)
493
+ run.font.bold = True
494
+ run.font.size = Pt(12)
495
+ run.font.color.rgb = RGBColor(88, 60, 200)
496
+
497
+ elif btype == 'paragraph':
498
+ content = strip_inline_md(block['content'])
499
+ p = doc.add_paragraph(content)
500
+ p.paragraph_format.line_spacing = Pt(16)
501
+ p.space_after = Pt(6)
502
+
503
+ elif btype == 'bullet_list':
504
+ for item in block['content']:
505
+ item_clean = strip_inline_md(item)
506
+ p = doc.add_paragraph(item_clean, style='List Bullet')
507
+ p.paragraph_format.line_spacing = Pt(15)
508
+
509
+ elif btype == 'numbered_list':
510
+ for item in block['content']:
511
+ item_clean = strip_inline_md(item)
512
+ p = doc.add_paragraph(item_clean, style='List Number')
513
+ p.paragraph_format.line_spacing = Pt(15)
514
+
515
+ elif btype == 'code':
516
+ code_para = doc.add_paragraph()
517
+ code_para.space_before = Pt(6)
518
+ code_para.space_after = Pt(6)
519
+ # Add shading to code block
520
+ shading = OxmlElement('w:shd')
521
+ shading.set(qn('w:fill'), 'F5F5F8')
522
+ shading.set(qn('w:val'), 'clear')
523
+ code_para.paragraph_format.element.get_or_add_pPr().append(shading)
524
+
525
+ run = code_para.add_run(block['content'])
526
+ run.font.name = 'Consolas'
527
+ run.font.size = Pt(9)
528
+ run.font.color.rgb = RGBColor(60, 60, 80)
529
+
530
+ elif btype == 'hr':
531
+ hr_para = doc.add_paragraph()
532
+ hr_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
533
+ hr_run = hr_para.add_run("─" * 50)
534
+ hr_run.font.color.rgb = RGBColor(200, 200, 215)
535
+ hr_run.font.size = Pt(8)
536
+
537
+ # ---- Footer ----
538
+ div_para2 = doc.add_paragraph()
539
+ div_para2.alignment = WD_ALIGN_PARAGRAPH.CENTER
540
+ div_para2.space_before = Pt(20)
541
+ div_run2 = div_para2.add_run("─" * 50)
542
+ div_run2.font.color.rgb = RGBColor(200, 200, 215)
543
+ div_run2.font.size = Pt(8)
544
+
545
+ footer_para = doc.add_paragraph()
546
+ footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
547
+ footer_run = footer_para.add_run(
548
+ "Generado por CareerAI β€” Asistente de Carrera con IA"
549
+ )
550
+ footer_run.font.size = Pt(8)
551
+ footer_run.font.italic = True
552
+ footer_run.font.color.rgb = RGBColor(160, 160, 175)
553
+
554
+ sub_para = doc.add_paragraph()
555
+ sub_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
556
+ sub_run = sub_para.add_run("Powered by RAG + Llama 3.3 + ChromaDB")
557
+ sub_run.font.size = Pt(7)
558
+ sub_run.font.color.rgb = RGBColor(180, 180, 195)
559
+
560
+ # ---- Save to bytes ----
561
+ buffer = io.BytesIO()
562
+ doc.save(buffer)
563
+ buffer.seek(0)
564
+ return buffer.getvalue()
565
+
566
+
567
+ # ======================== TXT EXPORT ========================
568
+
569
+ def export_to_txt(text: str) -> bytes:
570
+ """Export text content as a clean, well-formatted TXT file."""
571
+ clean = clean_markdown(text)
572
+ title = get_smart_title(text)
573
+ date_str = datetime.now().strftime('%d/%m/%Y %H:%M')
574
+
575
+ header = (
576
+ f"{'=' * 60}\n"
577
+ f" {title}\n"
578
+ f" Generado: {date_str}\n"
579
+ f" CareerAI β€” Asistente de Carrera con IA\n"
580
+ f"{'=' * 60}\n\n"
581
+ )
582
+
583
+ footer = (
584
+ f"\n\n{'─' * 60}\n"
585
+ f"Generado por CareerAI | Powered by RAG + Llama 3.3\n"
586
+ )
587
+
588
+ return (header + clean + footer).encode("utf-8")
589
+
590
+
591
+ # ======================== HTML EXPORT ========================
592
+
593
+ def export_to_html(text: str, title: Optional[str] = None) -> bytes:
594
+ """Export text content as a beautifully styled standalone HTML file."""
595
+ import html as html_lib
596
+
597
+ if title is None:
598
+ title = get_smart_title(text)
599
+
600
+ date_str = datetime.now().strftime("%d de %B, %Y β€’ %H:%M")
601
+
602
+ # Convert markdown to HTML-like content
603
+ blocks = parse_markdown_blocks(text)
604
+ content_html = ""
605
+
606
+ for block in blocks:
607
+ btype = block['type']
608
+
609
+ if btype == 'header':
610
+ level = block['level']
611
+ content = html_lib.escape(strip_inline_md(block['content']))
612
+ tag = f"h{min(level + 1, 6)}" # shift down since h1 is title
613
+ content_html += f"<{tag}>{content}</{tag}>\n"
614
+
615
+ elif btype == 'bold_heading':
616
+ content = html_lib.escape(strip_inline_md(block['content']))
617
+ content_html += f'<h3 class="accent">{content}</h3>\n'
618
+
619
+ elif btype == 'paragraph':
620
+ content = html_lib.escape(strip_inline_md(block['content']))
621
+ content_html += f"<p>{content}</p>\n"
622
+
623
+ elif btype == 'bullet_list':
624
+ content_html += "<ul>\n"
625
+ for item in block['content']:
626
+ item_clean = html_lib.escape(strip_inline_md(item))
627
+ content_html += f" <li>{item_clean}</li>\n"
628
+ content_html += "</ul>\n"
629
+
630
+ elif btype == 'numbered_list':
631
+ content_html += "<ol>\n"
632
+ for item in block['content']:
633
+ item_clean = html_lib.escape(strip_inline_md(item))
634
+ content_html += f" <li>{item_clean}</li>\n"
635
+ content_html += "</ol>\n"
636
+
637
+ elif btype == 'code':
638
+ lang = block.get('lang', '')
639
+ code_content = html_lib.escape(block['content'])
640
+ content_html += f'<pre><code class="{lang}">{code_content}</code></pre>\n'
641
+
642
+ elif btype == 'hr':
643
+ content_html += '<hr>\n'
644
+
645
+ html_template = f"""<!DOCTYPE html>
646
+ <html lang="es">
647
+ <head>
648
+ <meta charset="UTF-8">
649
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
650
+ <title>{html_lib.escape(title)} β€” CareerAI</title>
651
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
652
+ <style>
653
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
654
+
655
+ body {{
656
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
657
+ background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 30%, #16213e 60%, #0f0f23 100%);
658
+ color: #e4e4e7;
659
+ min-height: 100vh;
660
+ line-height: 1.7;
661
+ }}
662
+
663
+ .container {{
664
+ max-width: 780px;
665
+ margin: 0 auto;
666
+ padding: 40px 30px;
667
+ }}
668
+
669
+ .header {{
670
+ text-align: center;
671
+ margin-bottom: 40px;
672
+ padding-bottom: 30px;
673
+ border-bottom: 1px solid rgba(139, 92, 246, 0.2);
674
+ position: relative;
675
+ }}
676
+
677
+ .header::before {{
678
+ content: '';
679
+ position: absolute;
680
+ bottom: -1px;
681
+ left: 50%;
682
+ transform: translateX(-50%);
683
+ width: 120px;
684
+ height: 2px;
685
+ background: linear-gradient(90deg, transparent, #8b5cf6, transparent);
686
+ }}
687
+
688
+ .brand {{
689
+ font-size: 0.75rem;
690
+ font-weight: 600;
691
+ text-transform: uppercase;
692
+ letter-spacing: 0.15em;
693
+ color: #8b5cf6;
694
+ margin-bottom: 12px;
695
+ }}
696
+
697
+ h1 {{
698
+ font-size: 2rem;
699
+ font-weight: 700;
700
+ background: linear-gradient(135deg, #a78bfa, #c084fc, #e879f9, #f472b6);
701
+ background-clip: text;
702
+ -webkit-background-clip: text;
703
+ -webkit-text-fill-color: transparent;
704
+ margin-bottom: 8px;
705
+ letter-spacing: -0.02em;
706
+ }}
707
+
708
+ .date {{
709
+ font-size: 0.85rem;
710
+ color: #71717a;
711
+ }}
712
+
713
+ .content {{
714
+ background: rgba(24, 24, 27, 0.5);
715
+ border: 1px solid rgba(63, 63, 70, 0.3);
716
+ border-radius: 20px;
717
+ padding: 40px 36px;
718
+ backdrop-filter: blur(20px);
719
+ box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.4);
720
+ }}
721
+
722
+ h2 {{
723
+ font-size: 1.5rem;
724
+ font-weight: 700;
725
+ color: #fafafa;
726
+ margin: 28px 0 12px 0;
727
+ letter-spacing: -0.01em;
728
+ }}
729
+
730
+ h3 {{
731
+ font-size: 1.2rem;
732
+ font-weight: 600;
733
+ color: #d4d4d8;
734
+ margin: 22px 0 10px 0;
735
+ }}
736
+
737
+ h3.accent {{
738
+ color: #a78bfa;
739
+ }}
740
+
741
+ h4, h5, h6 {{
742
+ font-size: 1rem;
743
+ font-weight: 600;
744
+ color: #a1a1aa;
745
+ margin: 18px 0 8px 0;
746
+ }}
747
+
748
+ p {{
749
+ margin: 0 0 14px 0;
750
+ color: #d4d4d8;
751
+ font-size: 0.95rem;
752
+ }}
753
+
754
+ ul, ol {{
755
+ margin: 10px 0 18px 0;
756
+ padding-left: 24px;
757
+ }}
758
+
759
+ li {{
760
+ margin-bottom: 8px;
761
+ color: #d4d4d8;
762
+ font-size: 0.95rem;
763
+ }}
764
+
765
+ li::marker {{
766
+ color: #8b5cf6;
767
+ }}
768
+
769
+ pre {{
770
+ background: rgba(15, 15, 30, 0.6);
771
+ border: 1px solid rgba(63, 63, 70, 0.3);
772
+ border-radius: 12px;
773
+ padding: 18px 20px;
774
+ overflow-x: auto;
775
+ margin: 14px 0;
776
+ }}
777
+
778
+ code {{
779
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
780
+ font-size: 0.85rem;
781
+ color: #c4b5fd;
782
+ }}
783
+
784
+ hr {{
785
+ border: none;
786
+ height: 1px;
787
+ background: linear-gradient(90deg, transparent, rgba(139, 92, 246, 0.3), transparent);
788
+ margin: 24px 0;
789
+ }}
790
+
791
+ .footer {{
792
+ text-align: center;
793
+ margin-top: 40px;
794
+ padding-top: 24px;
795
+ border-top: 1px solid rgba(63, 63, 70, 0.2);
796
+ }}
797
+
798
+ .footer p {{
799
+ color: #52525b;
800
+ font-size: 0.78rem;
801
+ }}
802
+
803
+ .footer .powered {{
804
+ font-size: 0.72rem;
805
+ color: #3f3f46;
806
+ margin-top: 4px;
807
+ }}
808
+
809
+ @media print {{
810
+ body {{ background: white; color: #1a1a2e; }}
811
+ .content {{ border: 1px solid #e5e7eb; box-shadow: none; background: white; }}
812
+ h1 {{ color: #4c1d95; -webkit-text-fill-color: #4c1d95; }}
813
+ h2 {{ color: #1a1a2e; }}
814
+ h3, h3.accent {{ color: #4c1d95; }}
815
+ p, li {{ color: #374151; }}
816
+ pre {{ background: #f9fafb; border: 1px solid #e5e7eb; }}
817
+ code {{ color: #6d28d9; }}
818
+ }}
819
+ </style>
820
+ </head>
821
+ <body>
822
+ <div class="container">
823
+ <div class="header">
824
+ <div class="brand">CareerAI</div>
825
+ <h1>{html_lib.escape(title)}</h1>
826
+ <div class="date">{date_str}</div>
827
+ </div>
828
+
829
+ <div class="content">
830
+ {content_html}
831
+ </div>
832
+
833
+ <div class="footer">
834
+ <p>Generado por CareerAI β€” Asistente de Carrera con IA</p>
835
+ <p class="powered">Powered by RAG + Llama 3.3 + ChromaDB</p>
836
+ </div>
837
+ </div>
838
+ </body>
839
+ </html>"""
840
+
841
+ return html_template.encode("utf-8")
842
+
843
+
844
+ # ======================== CONVERSATION EXPORT ========================
845
+
846
+ def export_conversation_to_pdf(messages: List[Dict], title: str = "ConversaciΓ³n CareerAI") -> bytes:
847
+ """Export full conversation history to PDF."""
848
+ try:
849
+ from fpdf import FPDF
850
+ except ImportError:
851
+ raise ValueError("Instala fpdf2: pip install fpdf2")
852
+
853
+ pdf = FPDF()
854
+ pdf.set_auto_page_break(auto=True, margin=20)
855
+ pdf.add_page()
856
+
857
+ # Header band
858
+ pdf.set_fill_color(88, 60, 200)
859
+ pdf.rect(0, 0, 210, 3, 'F')
860
+
861
+ # Title
862
+ pdf.set_y(15)
863
+ pdf.set_font("Helvetica", "B", 18)
864
+ pdf.set_text_color(88, 60, 200)
865
+ pdf.cell(0, 12, title, new_x="LMARGIN", new_y="NEXT", align="C")
866
+
867
+ # Date
868
+ pdf.set_font("Helvetica", "", 9)
869
+ pdf.set_text_color(140, 140, 150)
870
+ date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
871
+ pdf.cell(0, 6, f"Exportado el {date_str}", new_x="LMARGIN", new_y="NEXT", align="C")
872
+ pdf.ln(4)
873
+
874
+ # Stats
875
+ user_msgs = sum(1 for m in messages if m["role"] == "user")
876
+ ai_msgs = sum(1 for m in messages if m["role"] == "assistant")
877
+ pdf.set_font("Helvetica", "", 8)
878
+ pdf.set_text_color(160, 160, 175)
879
+ pdf.cell(0, 5, f"{user_msgs} preguntas Β· {ai_msgs} respuestas Β· {len(messages)} mensajes totales",
880
+ new_x="LMARGIN", new_y="NEXT", align="C")
881
+ pdf.ln(6)
882
+
883
+ # Divider
884
+ y = pdf.get_y()
885
+ pdf.set_draw_color(200, 200, 215)
886
+ pdf.line(20, y, 190, y)
887
+ pdf.ln(8)
888
+
889
+ # Messages
890
+ for i, msg in enumerate(messages):
891
+ is_user = msg["role"] == "user"
892
+
893
+ # Role label
894
+ pdf.set_font("Helvetica", "B", 10)
895
+ if is_user:
896
+ pdf.set_text_color(100, 100, 120)
897
+ pdf.cell(0, 6, f"Tu ({i + 1})", new_x="LMARGIN", new_y="NEXT")
898
+ else:
899
+ pdf.set_text_color(88, 60, 200)
900
+ pdf.cell(0, 6, f"CareerAI ({i + 1})", new_x="LMARGIN", new_y="NEXT")
901
+
902
+ # Content
903
+ clean = _sanitize_for_pdf(clean_markdown(msg["content"]))
904
+ pdf.set_font("Helvetica", "", 10)
905
+ pdf.set_text_color(40, 40, 50)
906
+
907
+ for paragraph in clean.split('\n'):
908
+ paragraph = paragraph.strip()
909
+ if not paragraph:
910
+ pdf.ln(2)
911
+ continue
912
+ if paragraph.startswith('β€’'):
913
+ pdf.multi_cell(0, 5, paragraph)
914
+ pdf.ln(1)
915
+ else:
916
+ pdf.multi_cell(0, 5, paragraph)
917
+ pdf.ln(1)
918
+
919
+ pdf.ln(4)
920
+
921
+ # Separator between messages
922
+ if i < len(messages) - 1:
923
+ y = pdf.get_y()
924
+ pdf.set_draw_color(220, 220, 230)
925
+ pdf.set_line_width(0.2)
926
+ pdf.line(30, y, 180, y)
927
+ pdf.ln(5)
928
+
929
+ # Footer
930
+ pdf.ln(8)
931
+ pdf.set_draw_color(88, 60, 200)
932
+ pdf.line(60, pdf.get_y(), 150, pdf.get_y())
933
+ pdf.ln(6)
934
+ pdf.set_font("Helvetica", "I", 8)
935
+ pdf.set_text_color(160, 160, 175)
936
+ pdf.cell(0, 5, "CareerAI - Asistente de Carrera con IA", align="C")
937
+
938
+ # Bottom band
939
+ pdf.set_fill_color(88, 60, 200)
940
+ pdf.rect(0, 294, 210, 3, 'F')
941
+
942
+ return pdf.output()
943
+
944
+
945
+ def export_conversation_to_docx(messages: List[Dict], title: str = "ConversaciΓ³n CareerAI") -> bytes:
946
+ """Export full conversation history to DOCX (Word)."""
947
+ try:
948
+ from docx import Document
949
+ from docx.shared import Pt, Cm, RGBColor
950
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
951
+ except ImportError:
952
+ raise ValueError("Instala python-docx: pip install python-docx")
953
+
954
+ doc = Document()
955
+ for section in doc.sections:
956
+ section.top_margin = Cm(1.5)
957
+ section.bottom_margin = Cm(1.5)
958
+ section.left_margin = Cm(2)
959
+ section.right_margin = Cm(2)
960
+
961
+ # Style
962
+ style = doc.styles['Normal']
963
+ style.font.name = 'Calibri'
964
+ style.font.size = Pt(11)
965
+ style.font.color.rgb = RGBColor(40, 40, 50)
966
+
967
+ # Title
968
+ title_para = doc.add_paragraph()
969
+ title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
970
+ title_para.space_after = Pt(4)
971
+ title_run = title_para.add_run(title)
972
+ title_run.font.size = Pt(20)
973
+ title_run.font.bold = True
974
+ title_run.font.color.rgb = RGBColor(88, 60, 200)
975
+
976
+ # Date & stats
977
+ date_str = datetime.now().strftime("%d de %B, %Y β€’ %H:%M")
978
+ user_msgs = sum(1 for m in messages if m["role"] == "user")
979
+ ai_msgs = sum(1 for m in messages if m["role"] == "assistant")
980
+ date_para = doc.add_paragraph()
981
+ date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
982
+ date_para.space_after = Pt(2)
983
+ date_run = date_para.add_run(f"Exportado el {date_str}")
984
+ date_run.font.size = Pt(9)
985
+ date_run.font.color.rgb = RGBColor(140, 140, 150)
986
+
987
+ stats_para = doc.add_paragraph()
988
+ stats_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
989
+ stats_para.space_after = Pt(12)
990
+ stats_run = stats_para.add_run(f"{user_msgs} preguntas Β· {ai_msgs} respuestas Β· {len(messages)} mensajes")
991
+ stats_run.font.size = Pt(8)
992
+ stats_run.font.color.rgb = RGBColor(160, 160, 175)
993
+
994
+ # Divider
995
+ div_para = doc.add_paragraph()
996
+ div_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
997
+ div_para.space_after = Pt(12)
998
+ div_run = div_para.add_run("─" * 50)
999
+ div_run.font.color.rgb = RGBColor(200, 200, 215)
1000
+ div_run.font.size = Pt(8)
1001
+
1002
+ # Messages
1003
+ for i, msg in enumerate(messages):
1004
+ is_user = msg["role"] == "user"
1005
+ role_label = f"TΓΊ (#{i + 1})" if is_user else f"CareerAI (#{i + 1})"
1006
+
1007
+ role_para = doc.add_paragraph()
1008
+ role_para.space_before = Pt(14)
1009
+ role_para.space_after = Pt(4)
1010
+ role_run = role_para.add_run(role_label)
1011
+ role_run.font.bold = True
1012
+ role_run.font.size = Pt(11)
1013
+ if is_user:
1014
+ role_run.font.color.rgb = RGBColor(80, 80, 100)
1015
+ else:
1016
+ role_run.font.color.rgb = RGBColor(88, 60, 200)
1017
+
1018
+ clean = clean_markdown(msg["content"])
1019
+ for line in clean.split("\n"):
1020
+ line = line.strip()
1021
+ if not line:
1022
+ doc.add_paragraph()
1023
+ continue
1024
+ p = doc.add_paragraph(line)
1025
+ p.paragraph_format.line_spacing = Pt(15)
1026
+ p.paragraph_format.space_after = Pt(4)
1027
+
1028
+ if i < len(messages) - 1:
1029
+ sep = doc.add_paragraph()
1030
+ sep.space_after = Pt(6)
1031
+ sep_run = sep.add_run("─" * 40)
1032
+ sep_run.font.color.rgb = RGBColor(220, 220, 230)
1033
+ sep_run.font.size = Pt(6)
1034
+
1035
+ # Footer
1036
+ doc.add_paragraph()
1037
+ footer_para = doc.add_paragraph()
1038
+ footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
1039
+ footer_run = footer_para.add_run("CareerAI β€” Asistente de Carrera con IA")
1040
+ footer_run.font.size = Pt(8)
1041
+ footer_run.font.italic = True
1042
+ footer_run.font.color.rgb = RGBColor(160, 160, 175)
1043
+
1044
+ buffer = io.BytesIO()
1045
+ doc.save(buffer)
1046
+ buffer.seek(0)
1047
+ return buffer.getvalue()
1048
+
1049
+
1050
+ def export_conversation_to_html(messages: List[Dict], title: str = "ConversaciΓ³n CareerAI") -> bytes:
1051
+ """Export full conversation as a beautifully styled HTML file."""
1052
+ import html as html_lib
1053
+
1054
+ date_str = datetime.now().strftime("%d de %B, %Y β€’ %H:%M")
1055
+ user_msgs = sum(1 for m in messages if m["role"] == "user")
1056
+ ai_msgs = sum(1 for m in messages if m["role"] == "assistant")
1057
+
1058
+ messages_html = ""
1059
+ for i, msg in enumerate(messages):
1060
+ is_user = msg["role"] == "user"
1061
+ role_class = "user-msg" if is_user else "ai-msg"
1062
+ role_label = "TΓΊ" if is_user else "CareerAI"
1063
+ avatar = "πŸ‘€" if is_user else "πŸ€–"
1064
+ clean = html_lib.escape(clean_markdown(msg["content"]))
1065
+ # Convert newlines to <br>
1066
+ clean = clean.replace('\n', '<br>')
1067
+
1068
+ messages_html += f"""
1069
+ <div class="message {role_class}">
1070
+ <div class="message-header">
1071
+ <span class="avatar">{avatar}</span>
1072
+ <span class="role">{role_label}</span>
1073
+ <span class="msg-num">#{i + 1}</span>
1074
+ </div>
1075
+ <div class="message-body">{clean}</div>
1076
+ </div>
1077
+ """
1078
+
1079
+ html_content = f"""<!DOCTYPE html>
1080
+ <html lang="es">
1081
+ <head>
1082
+ <meta charset="UTF-8">
1083
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1084
+ <title>{html_lib.escape(title)} β€” CareerAI</title>
1085
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
1086
+ <style>
1087
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
1088
+ body {{
1089
+ font-family: 'Inter', sans-serif;
1090
+ background: linear-gradient(135deg, #0f0f23, #1a1a2e, #16213e, #0f0f23);
1091
+ color: #e4e4e7;
1092
+ min-height: 100vh;
1093
+ line-height: 1.6;
1094
+ }}
1095
+ .container {{ max-width: 800px; margin: 0 auto; padding: 40px 24px; }}
1096
+ .header {{
1097
+ text-align: center;
1098
+ margin-bottom: 32px;
1099
+ padding-bottom: 24px;
1100
+ border-bottom: 1px solid rgba(139, 92, 246, 0.2);
1101
+ }}
1102
+ .brand {{ font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.15em; color: #8b5cf6; margin-bottom: 10px; }}
1103
+ h1 {{
1104
+ font-size: 1.8rem; font-weight: 700;
1105
+ background: linear-gradient(135deg, #a78bfa, #c084fc, #e879f9);
1106
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
1107
+ margin-bottom: 6px;
1108
+ }}
1109
+ .meta {{ color: #71717a; font-size: 0.85rem; }}
1110
+ .stats {{ color: #52525b; font-size: 0.8rem; margin-top: 6px; }}
1111
+
1112
+ .message {{
1113
+ margin-bottom: 16px;
1114
+ border-radius: 16px;
1115
+ padding: 18px 22px;
1116
+ border: 1px solid rgba(63, 63, 70, 0.3);
1117
+ }}
1118
+ .user-msg {{
1119
+ background: rgba(24, 24, 27, 0.4);
1120
+ }}
1121
+ .ai-msg {{
1122
+ background: rgba(88, 60, 200, 0.06);
1123
+ border-color: rgba(139, 92, 246, 0.15);
1124
+ }}
1125
+ .message-header {{
1126
+ display: flex;
1127
+ align-items: center;
1128
+ gap: 8px;
1129
+ margin-bottom: 10px;
1130
+ }}
1131
+ .avatar {{ font-size: 1.2rem; }}
1132
+ .role {{ font-weight: 600; font-size: 0.85rem; color: #a1a1aa; }}
1133
+ .ai-msg .role {{ color: #a78bfa; }}
1134
+ .msg-num {{ font-size: 0.72rem; color: #52525b; margin-left: auto; }}
1135
+ .message-body {{ font-size: 0.92rem; color: #d4d4d8; line-height: 1.7; }}
1136
+
1137
+ .footer {{
1138
+ text-align: center;
1139
+ margin-top: 32px;
1140
+ padding-top: 20px;
1141
+ border-top: 1px solid rgba(63, 63, 70, 0.2);
1142
+ }}
1143
+ .footer p {{ color: #52525b; font-size: 0.78rem; }}
1144
+
1145
+ @media print {{
1146
+ body {{ background: white; color: #1a1a2e; }}
1147
+ .message {{ border: 1px solid #e5e7eb; }}
1148
+ .ai-msg {{ background: #f8f5ff; }}
1149
+ .message-body {{ color: #374151; }}
1150
+ }}
1151
+ </style>
1152
+ </head>
1153
+ <body>
1154
+ <div class="container">
1155
+ <div class="header">
1156
+ <div class="brand">CareerAI</div>
1157
+ <h1>{html_lib.escape(title)}</h1>
1158
+ <div class="meta">{date_str}</div>
1159
+ <div class="stats">{user_msgs} preguntas Β· {ai_msgs} respuestas</div>
1160
+ </div>
1161
+
1162
+ {messages_html}
1163
+
1164
+ <div class="footer">
1165
+ <p>Generado por CareerAI β€” Asistente de Carrera con IA</p>
1166
+ </div>
1167
+ </div>
1168
+ </body>
1169
+ </html>"""
1170
+
1171
+ return html_content.encode("utf-8")
src/models.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime
3
+ from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey, JSON
4
+ from sqlalchemy.orm import declarative_base, sessionmaker, relationship
5
+
6
+ DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "careerai.db")
7
+ engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
8
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
9
+
10
+ Base = declarative_base()
11
+
12
+ class User(Base):
13
+ __tablename__ = "users"
14
+
15
+ id = Column(Integer, primary_key=True, index=True)
16
+ email = Column(String, unique=True, index=True, nullable=False)
17
+ name = Column(String, nullable=False)
18
+ picture = Column(String, nullable=True)
19
+ hashed_password = Column(String, nullable=True)
20
+ google_id = Column(String, unique=True, index=True, nullable=True)
21
+ created_at = Column(DateTime, default=datetime.utcnow)
22
+
23
+ conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
24
+
25
+ class Conversation(Base):
26
+ __tablename__ = "conversations"
27
+
28
+ id = Column(String, primary_key=True, index=True) # UUID string from frontend
29
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
30
+ title = Column(String, nullable=False)
31
+ messages = Column(JSON, nullable=False, default=list) # Store messages as JSON
32
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
33
+
34
+ user = relationship("User", back_populates="conversations")
35
+
36
+ # Create all tables
37
+ Base.metadata.create_all(bind=engine)
38
+
39
+ # Dependency to get DB session
40
+ def get_db():
41
+ db = SessionLocal()
42
+ try:
43
+ yield db
44
+ finally:
45
+ db.close()
src/profile_extractor.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Profile Extractor - Uses LLM (Groq) to extract structured skills and experience from document text.
3
+ Returns JSON for dashboard: skills (by category/level) and experience (timeline).
4
+ """
5
+ import json
6
+ import re
7
+ from typing import List, Dict, Any
8
+
9
+
10
+ EXTRACT_PROMPT = """Analiza el siguiente texto de CV/perfil profesional y extrae SOLO informaciΓ³n que aparezca explΓ­citamente.
11
+
12
+ Responde ÚNICAMENTE con un bloque JSON vÑlido (sin markdown, sin texto antes o después), con esta estructura exacta:
13
+
14
+ {{
15
+ "summary": {{
16
+ "headline": "titular corto del perfil (opcional)",
17
+ "estimated_seniority": "junior|mid|senior|lead|unknown",
18
+ "total_years_experience": 0
19
+ }},
20
+ "skills": [
21
+ {{ "name": "nombre del skill", "category": "technical" | "soft" | "tools" | "language", "level": "basic" | "intermediate" | "advanced", "evidence": "frase corta del documento (opcional)" }}
22
+ ],
23
+ "experience": [
24
+ {{ "company": "nombre empresa", "role": "puesto", "start_date": "YYYY-MM o aΓ±o", "end_date": "YYYY-MM o null si actual", "current": true/false, "location": "opcional", "description": "breve descripciΓ³n opcional", "highlights": ["logro 1", "logro 2"] }}
25
+ ]
26
+ }}
27
+
28
+ Reglas:
29
+ - skills: category "technical" = lenguajes, frameworks, bases de datos; "soft" = comunicaciΓ³n, liderazgo; "tools" = Herramientas (Git, Jira); "language" = idiomas.
30
+ - experience: start_date y end_date en formato "YYYY" o "YYYY-MM" si se puede inferir. Si es el trabajo actual, end_date puede ser null y current true.
31
+ - Extrae SOLO lo que estΓ© en el texto. No inventes datos.
32
+ - Si no hay informaciΓ³n para skills o experience, devuelve listas vacΓ­as [].
33
+ - El JSON debe ser vΓ‘lido (comillas dobles, sin comas finales).
34
+ - Si no puedes determinar seniority o aΓ±os, usa \"unknown\" y 0.
35
+
36
+ TEXTO DEL DOCUMENTO:
37
+ ---
38
+ {text}
39
+ ---
40
+ Responde solo con el JSON, nada mΓ‘s."""
41
+
42
+ def _extract_json_candidate(text: str) -> str:
43
+ """Best-effort: pull a JSON object from model output."""
44
+ if not text:
45
+ return ""
46
+ s = text.strip()
47
+ fence = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", s)
48
+ if fence:
49
+ s = fence.group(1).strip()
50
+
51
+ # If there's extra text, keep the first {...} block.
52
+ start = s.find("{")
53
+ end = s.rfind("}")
54
+ if start != -1 and end != -1 and end > start:
55
+ s = s[start : end + 1]
56
+
57
+ # Remove trailing commas (common LLM issue)
58
+ s = re.sub(r",\s*([}\]])", r"\1", s)
59
+ return s.strip()
60
+
61
+
62
+ def extract_profile_from_text(text: str, llm) -> Dict[str, Any]:
63
+ """
64
+ Call LLM to extract structured profile (skills + experience) from document text.
65
+ llm: LangChain ChatGroq instance (e.g. from CareerAssistant.llm).
66
+ Returns dict with "skills" and "experience" lists; on error returns empty structure.
67
+ """
68
+ if not text or not text.strip():
69
+ return {"skills": [], "experience": []}
70
+
71
+ # Limit size to avoid token limits (keep first ~12k chars)
72
+ text_trimmed = text.strip()[:12000]
73
+ prompt = EXTRACT_PROMPT.format(text=text_trimmed)
74
+
75
+ try:
76
+ from langchain_core.messages import HumanMessage
77
+ response = llm.invoke([HumanMessage(content=prompt)])
78
+ content = response.content if hasattr(response, "content") else str(response)
79
+ candidate = _extract_json_candidate(content)
80
+ data = json.loads(candidate)
81
+ skills = data.get("skills") or []
82
+ experience = data.get("experience") or []
83
+ summary = data.get("summary") or {}
84
+ # Normalize
85
+ if not isinstance(skills, list):
86
+ skills = []
87
+ if not isinstance(experience, list):
88
+ experience = []
89
+ if not isinstance(summary, dict):
90
+ summary = {}
91
+ return {"summary": summary, "skills": skills, "experience": experience}
92
+ except (json.JSONDecodeError, Exception):
93
+ return {"summary": {}, "skills": [], "experience": []}
94
+
95
+ INSIGHTS_PROMPT = """Eres un analista de carrera. Te paso un perfil ya extraído de documentos reales (skills + experiencia).\n\nTu tarea: generar insights accionables SIN inventar información.\n\nResponde ÚNICAMENTE JSON vÑlido (sin markdown), con esta estructura exacta:\n\n{\n \"strengths\": [\"...\"],\n \"potential_gaps\": [\"...\"],\n \"role_suggestions\": [\"...\"],\n \"next_actions\": [\"...\"]\n}\n\nReglas:\n- Todo debe derivarse SOLO del perfil que recibes. Si falta info, dilo en el texto del insight (ej: \"No hay evidencia de X en los documentos\").\n- Sé concreto y breve (bullets de 1 línea).\n- No menciones que eres una IA.\n\nPERFIL (JSON):\n{profile_json}\n"""
96
+
97
+
98
+ def generate_dashboard_insights(profile: Dict[str, Any], llm) -> Dict[str, Any]:
99
+ """Generate 'smart' insights based on extracted profile JSON."""
100
+ try:
101
+ from langchain_core.messages import HumanMessage
102
+ profile_json = json.dumps(profile or {}, ensure_ascii=False)[:12000]
103
+ prompt = INSIGHTS_PROMPT.format(profile_json=profile_json)
104
+ resp = llm.invoke([HumanMessage(content=prompt)])
105
+ content = resp.content if hasattr(resp, "content") else str(resp)
106
+ candidate = _extract_json_candidate(content)
107
+ data = json.loads(candidate)
108
+ out = {
109
+ "strengths": data.get("strengths") or [],
110
+ "potential_gaps": data.get("potential_gaps") or [],
111
+ "role_suggestions": data.get("role_suggestions") or [],
112
+ "next_actions": data.get("next_actions") or [],
113
+ }
114
+ for k in list(out.keys()):
115
+ if not isinstance(out[k], list):
116
+ out[k] = []
117
+ out[k] = [str(x).strip() for x in out[k] if str(x).strip()][:12]
118
+ return out
119
+ except Exception:
120
+ return {"strengths": [], "potential_gaps": [], "role_suggestions": [], "next_actions": []}
121
+
122
+
123
+ def skills_by_category(skills: List[Dict]) -> Dict[str, int]:
124
+ """Count skills per category for bar chart."""
125
+ counts = {}
126
+ for s in skills:
127
+ if not isinstance(s, dict):
128
+ continue
129
+ cat = (s.get("category") or "other").lower()
130
+ counts[cat] = counts.get(cat, 0) + 1
131
+ return counts
132
+
133
+
134
+ def skills_by_level(skills: List[Dict]) -> Dict[str, int]:
135
+ """Count skills per level for chart."""
136
+ counts = {"basic": 0, "intermediate": 0, "advanced": 0}
137
+ for s in skills:
138
+ if not isinstance(s, dict):
139
+ continue
140
+ level = (s.get("level") or "intermediate").lower()
141
+ if level in counts:
142
+ counts[level] += 1
143
+ else:
144
+ counts["intermediate"] += 1
145
+ return counts
146
+
147
+
148
+ def experience_for_timeline(experience: List[Dict]) -> List[Dict]:
149
+ """
150
+ Normalize experience entries for timeline: ensure start_date/end_date for plotting.
151
+ Returns list of dicts with company, role, start_date, end_date, current, description.
152
+ """
153
+ out = []
154
+ for e in experience:
155
+ if not isinstance(e, dict):
156
+ continue
157
+ start = (e.get("start_date") or "").strip() or "Unknown"
158
+ end = e.get("end_date")
159
+ if end is None and e.get("current"):
160
+ end = "Actualidad"
161
+ elif not end:
162
+ end = "?"
163
+ out.append({
164
+ "company": (e.get("company") or "?").strip(),
165
+ "role": (e.get("role") or "?").strip(),
166
+ "start_date": start,
167
+ "end_date": end,
168
+ "current": bool(e.get("current")),
169
+ "description": (e.get("description") or "").strip()[:200],
170
+ })
171
+ return out
src/rag_engine.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG Engine v2.0 - Advanced retrieval with:
3
+ β€’ Multilingual embeddings (BGE-M3 / gte-multilingual / multilingual-e5)
4
+ β€’ Hybrid search (Vector + BM25 keyword via Reciprocal Rank Fusion)
5
+ β€’ Reranking with BGE-Reranker-v2
6
+ β€’ Metadata filtering by document type (CV vs Job Offer vs LinkedIn)
7
+ All 100% free & local.
8
+ """
9
+ import os
10
+ import hashlib
11
+ import logging
12
+ from typing import List, Tuple, Optional, Dict
13
+
14
+ from langchain_huggingface import HuggingFaceEmbeddings
15
+ from langchain_chroma import Chroma
16
+ from langchain_core.documents import Document
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ======================== EMBEDDING MODEL CATALOG ========================
21
+
22
+ EMBEDDING_MODELS = {
23
+ "bge-m3": {
24
+ "name": "BAAI/bge-m3",
25
+ "display": "🌍 BGE-M3 (Multilingual · Recomendado)",
26
+ "description": "Mejor modelo multilingual 2025. Dense+sparse, 100+ idiomas, ideal para RAG.",
27
+ "size": "~2.3 GB",
28
+ "languages": "100+",
29
+ "performance": "⭐⭐⭐⭐⭐",
30
+ },
31
+ "gte-multilingual": {
32
+ "name": "Alibaba-NLP/gte-multilingual-base",
33
+ "display": "πŸš€ GTE Multilingual (Ligero Β· 70+ idiomas)",
34
+ "description": "Excelente balance tamaΓ±o/calidad. 70+ idiomas, encoder-only.",
35
+ "size": "~580 MB",
36
+ "languages": "70+",
37
+ "performance": "⭐⭐⭐⭐",
38
+ },
39
+ "multilingual-e5": {
40
+ "name": "intfloat/multilingual-e5-base",
41
+ "display": "πŸ“ Multilingual E5 Base (EstΓ‘ndar)",
42
+ "description": "Modelo estΓ‘ndar multilingual para retrieval y similitud semΓ‘ntica.",
43
+ "size": "~1.1 GB",
44
+ "languages": "100+",
45
+ "performance": "⭐⭐⭐⭐",
46
+ },
47
+ "minilm-v2": {
48
+ "name": "sentence-transformers/all-MiniLM-L6-v2",
49
+ "display": "⚑ MiniLM v2 (Ultra-ligero · Solo inglés)",
50
+ "description": "Modelo original, muy rΓ‘pido pero solo inglΓ©s. Ideal para pruebas.",
51
+ "size": "~90 MB",
52
+ "languages": "InglΓ©s",
53
+ "performance": "⭐⭐⭐",
54
+ },
55
+ }
56
+
57
+ DEFAULT_EMBEDDING = "bge-m3"
58
+
59
+
60
+ # ======================== BM25 KEYWORD INDEX ========================
61
+
62
+ class BM25Index:
63
+ """Lightweight BM25 keyword index for hybrid search."""
64
+
65
+ def __init__(self):
66
+ self._documents: List[str] = []
67
+ self._metadatas: List[dict] = []
68
+ self._index = None
69
+
70
+ @property
71
+ def is_ready(self) -> bool:
72
+ return self._index is not None and len(self._documents) > 0
73
+
74
+ def add(self, texts: List[str], metadatas: List[dict]):
75
+ """Add documents to the BM25 index."""
76
+ self._documents.extend(texts)
77
+ self._metadatas.extend(metadatas)
78
+ self._rebuild()
79
+
80
+ def _rebuild(self):
81
+ """Rebuild the BM25 index from scratch."""
82
+ try:
83
+ from rank_bm25 import BM25Okapi
84
+ tokenized = [doc.lower().split() for doc in self._documents]
85
+ if tokenized:
86
+ self._index = BM25Okapi(tokenized)
87
+ except ImportError:
88
+ logger.warning("rank_bm25 not installed – keyword search disabled. pip install rank_bm25")
89
+ self._index = None
90
+
91
+ def search(
92
+ self, query: str, k: int = 10, filter_dict: Optional[dict] = None,
93
+ ) -> List[Tuple[str, dict, float]]:
94
+ """Search using BM25 keyword matching."""
95
+ if not self.is_ready:
96
+ return []
97
+
98
+ tokenized_query = query.lower().split()
99
+ scores = self._index.get_scores(tokenized_query)
100
+
101
+ # Pair with metadata and filter
102
+ results = []
103
+ for idx, score in enumerate(scores):
104
+ if score <= 0:
105
+ continue
106
+ meta = self._metadatas[idx] if idx < len(self._metadatas) else {}
107
+ # Apply metadata filter
108
+ if filter_dict:
109
+ if not all(meta.get(k_f) == v_f for k_f, v_f in filter_dict.items()):
110
+ continue
111
+ results.append((self._documents[idx], meta, float(score)))
112
+
113
+ # Sort by score descending and return top-k
114
+ results.sort(key=lambda x: x[2], reverse=True)
115
+ return results[:k]
116
+
117
+ def clear(self):
118
+ """Clear the BM25 index."""
119
+ self._documents.clear()
120
+ self._metadatas.clear()
121
+ self._index = None
122
+
123
+ def rebuild_from_chroma(self, chroma_collection):
124
+ """Rebuild BM25 index from existing ChromaDB collection."""
125
+ try:
126
+ data = chroma_collection.get()
127
+ if data and data.get("documents"):
128
+ self._documents = list(data["documents"])
129
+ self._metadatas = list(data.get("metadatas", [{}] * len(self._documents)))
130
+ self._rebuild()
131
+ logger.info(f"BM25 index rebuilt with {len(self._documents)} documents")
132
+ except Exception as e:
133
+ logger.warning(f"Failed to rebuild BM25 index: {e}")
134
+
135
+
136
+ # ======================== RERANKER ========================
137
+
138
+ class Reranker:
139
+ """Cross-encoder reranker using BGE-Reranker-v2-m3 (free, local, multilingual)."""
140
+
141
+ def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
142
+ self.model_name = model_name
143
+ self._model = None
144
+
145
+ @property
146
+ def is_ready(self) -> bool:
147
+ return self._model is not None
148
+
149
+ def load(self):
150
+ """Lazy-load the reranker model."""
151
+ if self._model is not None:
152
+ return
153
+ try:
154
+ from sentence_transformers import CrossEncoder
155
+ self._model = CrossEncoder(self.model_name, max_length=512)
156
+ logger.info(f"Reranker loaded: {self.model_name}")
157
+ except ImportError:
158
+ logger.warning("sentence-transformers not installed for reranking")
159
+ except Exception as e:
160
+ logger.warning(f"Failed to load reranker: {e}")
161
+
162
+ def rerank(
163
+ self,
164
+ query: str,
165
+ results: List[Tuple[str, dict, float]],
166
+ top_k: int = 5,
167
+ ) -> List[Tuple[str, dict, float]]:
168
+ """Rerank results using cross-encoder scoring."""
169
+ if not self.is_ready or not results:
170
+ return results[:top_k]
171
+
172
+ try:
173
+ pairs = [(query, content) for content, _, _ in results]
174
+ scores = self._model.predict(pairs)
175
+
176
+ reranked = []
177
+ for i, (content, meta, _) in enumerate(results):
178
+ reranked.append((content, meta, float(scores[i])))
179
+
180
+ reranked.sort(key=lambda x: x[2], reverse=True)
181
+ return reranked[:top_k]
182
+ except Exception as e:
183
+ logger.warning(f"Reranking failed, returning original order: {e}")
184
+ return results[:top_k]
185
+
186
+
187
+ # ======================== RECIPROCAL RANK FUSION ========================
188
+
189
+ def reciprocal_rank_fusion(
190
+ results_list: List[List[Tuple[str, dict, float]]],
191
+ k: int = 60,
192
+ top_n: int = 15,
193
+ ) -> List[Tuple[str, dict, float]]:
194
+ """
195
+ Merge multiple ranked result lists using Reciprocal Rank Fusion (RRF).
196
+ Each result is identified by content hash. Final score = sum(1 / (k + rank)).
197
+ """
198
+ fused_scores: Dict[str, float] = {}
199
+ content_map: Dict[str, Tuple[str, dict]] = {}
200
+
201
+ for results in results_list:
202
+ for rank, (content, meta, _) in enumerate(results):
203
+ key = hashlib.md5(content[:200].encode()).hexdigest()
204
+ fused_scores[key] = fused_scores.get(key, 0.0) + 1.0 / (k + rank + 1)
205
+ if key not in content_map:
206
+ content_map[key] = (content, meta)
207
+
208
+ sorted_keys = sorted(fused_scores.keys(), key=lambda x: fused_scores[x], reverse=True)
209
+
210
+ merged = []
211
+ for key in sorted_keys[:top_n]:
212
+ content, meta = content_map[key]
213
+ merged.append((content, meta, fused_scores[key]))
214
+
215
+ return merged
216
+
217
+
218
+ # ======================== RAG ENGINE v2 ========================
219
+
220
+ class RAGEngine:
221
+ """
222
+ Advanced RAG Engine v2.0 with:
223
+ - Selectable multilingual embeddings
224
+ - Hybrid search (vector + BM25 keyword)
225
+ - Cross-encoder reranking
226
+ - Metadata filtering
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ persist_directory: str = None,
232
+ embedding_key: str = DEFAULT_EMBEDDING,
233
+ enable_reranking: bool = True,
234
+ enable_hybrid: bool = True,
235
+ ):
236
+ if persist_directory is None:
237
+ persist_directory = os.path.join(
238
+ os.path.dirname(os.path.dirname(__file__)), "data", "vectordb"
239
+ )
240
+ self.persist_directory = persist_directory
241
+ os.makedirs(persist_directory, exist_ok=True)
242
+
243
+ # ---- Embeddings ----
244
+ self.embedding_key = embedding_key
245
+ model_info = EMBEDDING_MODELS.get(embedding_key, EMBEDDING_MODELS[DEFAULT_EMBEDDING])
246
+ model_name = model_info["name"]
247
+
248
+ self.embeddings = HuggingFaceEmbeddings(
249
+ model_name=model_name,
250
+ model_kwargs={"device": "cpu", "trust_remote_code": True},
251
+ encode_kwargs={"normalize_embeddings": True},
252
+ )
253
+
254
+ # ---- ChromaDB Vector Store ----
255
+ # Use collection name based on embedding to avoid dimension conflicts
256
+ collection_name = f"career_docs_{embedding_key.replace('-', '_')}"
257
+ self.vectorstore = Chroma(
258
+ collection_name=collection_name,
259
+ embedding_function=self.embeddings,
260
+ persist_directory=persist_directory,
261
+ )
262
+
263
+ # ---- BM25 Keyword Index (hybrid search) ----
264
+ self.enable_hybrid = enable_hybrid
265
+ self.bm25 = BM25Index()
266
+ if enable_hybrid:
267
+ try:
268
+ self.bm25.rebuild_from_chroma(self.vectorstore._collection)
269
+ except Exception:
270
+ pass
271
+
272
+ # ---- Reranker (lazy-loaded on first use) ----
273
+ self.enable_reranking = enable_reranking
274
+ self.reranker = Reranker() if enable_reranking else None
275
+
276
+ # ======================== DOCUMENT OPS ========================
277
+
278
+ def add_document(self, chunks: List[str], metadata: dict, user_id: str = "anonymous") -> int:
279
+ """Add document chunks to vector store + BM25 index."""
280
+ if not chunks:
281
+ return 0
282
+
283
+ docs = []
284
+ chunk_metas = []
285
+ for i, chunk in enumerate(chunks):
286
+ doc_id = hashlib.md5(
287
+ f"{metadata.get('filename', 'unknown')}_{i}_{chunk[:50]}".encode()
288
+ ).hexdigest()
289
+
290
+ doc_metadata = {
291
+ **metadata,
292
+ "user_id": user_id,
293
+ "chunk_index": i,
294
+ "total_chunks": len(chunks),
295
+ "doc_id": doc_id,
296
+ }
297
+ docs.append(Document(page_content=chunk, metadata=doc_metadata))
298
+ chunk_metas.append(doc_metadata)
299
+
300
+ # Add to vector store
301
+ self.vectorstore.add_documents(docs)
302
+
303
+ # Add to BM25 index
304
+ if self.enable_hybrid:
305
+ self.bm25.add(chunks, chunk_metas)
306
+
307
+ logger.info(f"Added {len(docs)} chunks for '{metadata.get('filename', '?')}'")
308
+ return len(docs)
309
+
310
+ def delete_document(self, filename: str, user_id: str = "anonymous"):
311
+ """Delete all chunks for a specific document considering user_id."""
312
+ try:
313
+ collection = self.vectorstore._collection
314
+
315
+ if user_id == "anonymous":
316
+ # Try getting explicitly labeled anonymous docs
317
+ results = collection.get(where={"$and": [{"filename": filename}, {"user_id": user_id}]})
318
+
319
+ # If none found, fallback to legacy docs that have no user_id
320
+ if not results or not results.get("ids"):
321
+ all_file_docs = collection.get(where={"filename": filename})
322
+ if all_file_docs and all_file_docs.get("ids"):
323
+ legacy_ids = [ids for i, ids in enumerate(all_file_docs["ids"]) if "user_id" not in all_file_docs["metadatas"][i]]
324
+ results = {"ids": legacy_ids}
325
+ else:
326
+ results = collection.get(where={"$and": [{"filename": filename}, {"user_id": user_id}]})
327
+
328
+ if results and results.get("ids"):
329
+ collection.delete(ids=results["ids"])
330
+ # Rebuild BM25 index after deletion
331
+ if self.enable_hybrid:
332
+ self.bm25.rebuild_from_chroma(collection)
333
+ return True
334
+ return False
335
+ except Exception as e:
336
+ logger.error(f"Error deleting document: {e}")
337
+ return False
338
+
339
+ # ======================== SEARCH ========================
340
+
341
+ def search(
342
+ self,
343
+ query: str,
344
+ k: int = 5,
345
+ filter_dict: Optional[dict] = None,
346
+ user_id: str = "anonymous"
347
+ ) -> List[Tuple[str, dict, float]]:
348
+ """
349
+ Advanced search pipeline:
350
+ 1. Vector similarity search (semantic)
351
+ 2. BM25 keyword search (lexical) β€” if hybrid enabled
352
+ 3. Reciprocal Rank Fusion to merge results
353
+ 4. Reranking with cross-encoder β€” if enabled
354
+ """
355
+ # Build ChromaDB-compatible filter with user_id
356
+ filter_dict = filter_dict or {}
357
+ if "user_id" not in filter_dict:
358
+ filter_dict["user_id"] = user_id
359
+
360
+ # ChromaDB requires $and for multiple filter keys
361
+ if len(filter_dict) > 1:
362
+ chroma_filter = {"$and": [{k: v} for k, v in filter_dict.items()]}
363
+ else:
364
+ chroma_filter = filter_dict
365
+
366
+ # Step 1: Vector search
367
+ vector_results = self._vector_search(query, k=k * 2, filter_dict=chroma_filter)
368
+
369
+ # Step 2: BM25 keyword search (if enabled)
370
+ if self.enable_hybrid and self.bm25.is_ready:
371
+ bm25_results = self.bm25.search(query, k=k * 2, filter_dict=chroma_filter)
372
+
373
+ # Step 3: Fuse results with RRF
374
+ merged = reciprocal_rank_fusion(
375
+ [vector_results, bm25_results],
376
+ top_n=k * 2,
377
+ )
378
+ else:
379
+ merged = vector_results
380
+
381
+ # Step 4: Rerank (if enabled and model loaded)
382
+ if self.enable_reranking and self.reranker is not None:
383
+ if not self.reranker.is_ready:
384
+ self.reranker.load()
385
+ if self.reranker.is_ready:
386
+ merged = self.reranker.rerank(query, merged, top_k=k)
387
+ else:
388
+ merged = merged[:k]
389
+ else:
390
+ merged = merged[:k]
391
+
392
+ return merged
393
+
394
+ def _vector_search(
395
+ self, query: str, k: int = 10, filter_dict: Optional[dict] = None,
396
+ ) -> List[Tuple[str, dict, float]]:
397
+ """Pure vector similarity search."""
398
+ try:
399
+ results = self.vectorstore.similarity_search_with_score(
400
+ query, k=k, filter=filter_dict
401
+ )
402
+ return [
403
+ (doc.page_content, doc.metadata, score) for doc, score in results
404
+ ]
405
+ except Exception as e:
406
+ logger.warning(f"Vector search failed: {e}")
407
+ return []
408
+
409
+ def search_by_type(
410
+ self,
411
+ query: str,
412
+ doc_type: str,
413
+ k: int = 5,
414
+ ) -> List[Tuple[str, dict, float]]:
415
+ """Search filtered by document type (cv, job_offer, linkedin, other)."""
416
+ return self.search(query, k=k, filter_dict={"doc_type": doc_type})
417
+
418
+ # ======================== CONTEXT BUILDING ========================
419
+
420
+ def get_context(
421
+ self,
422
+ query: str,
423
+ k: int = 8,
424
+ filter_type: Optional[str] = None,
425
+ user_id: str = "anonymous"
426
+ ) -> str:
427
+ """Get formatted context string for LLM consumption."""
428
+ filter_dict = {"doc_type": filter_type} if filter_type else {}
429
+ # user_id will be injected by search() if not already present
430
+ results = self.search(query, k=k, filter_dict=filter_dict, user_id=user_id)
431
+
432
+ if not results:
433
+ return "⚠️ No se encontraron documentos relevantes. Por favor, sube tu CV u otros documentos primero."
434
+
435
+ context_parts = []
436
+ seen_content = set()
437
+
438
+ for content, metadata, score in results:
439
+ # Deduplicate similar chunks
440
+ content_hash = hashlib.md5(content[:100].encode()).hexdigest()
441
+ if content_hash in seen_content:
442
+ continue
443
+ seen_content.add(content_hash)
444
+
445
+ source = metadata.get("filename", "Desconocido")
446
+ doc_type = metadata.get("doc_type", "documento")
447
+
448
+ type_labels = {
449
+ "cv": "πŸ“‹ CV/Resume",
450
+ "job_offer": "πŸ’Ό Oferta de Trabajo",
451
+ "linkedin": "πŸ‘€ LinkedIn",
452
+ "other": "πŸ“„ Documento",
453
+ }
454
+ type_label = type_labels.get(doc_type, "πŸ“„ Documento")
455
+
456
+ # Score display depends on search mode
457
+ score_str = f"{score:.3f}"
458
+
459
+ context_parts.append(
460
+ f"[{type_label} | Fuente: {source} | Score: {score_str}]\n{content}"
461
+ )
462
+
463
+ return "\n\n" + "─" * 50 + "\n\n".join(context_parts)
464
+
465
+ # ======================== STATS & UTILS ========================
466
+
467
+ def get_document_list(self, user_id: str = "anonymous") -> List[str]:
468
+ """Get list of all indexed document filenames for a user."""
469
+ try:
470
+ collection = self.vectorstore._collection
471
+ if user_id == "anonymous":
472
+ # For anonymous users, we get everything but could restrict it later.
473
+ # For now, if "anonymous", just get the ones explicitly marked "anonymous"
474
+ results = collection.get(where={"user_id": user_id})
475
+ # If nothing found, it might be legacy (no user_id set), so get those too
476
+ if not results.get("ids"):
477
+ all_docs = collection.get()
478
+ results = {"metadatas": [m for m in all_docs.get("metadatas", []) if "user_id" not in m]}
479
+ else:
480
+ results = collection.get(where={"user_id": user_id})
481
+
482
+ filenames = set()
483
+ for meta in results.get("metadatas", []):
484
+ if meta and "filename" in meta:
485
+ filenames.add(meta["filename"])
486
+ return sorted(list(filenames))
487
+ except Exception:
488
+ return []
489
+
490
+ def get_stats(self, user_id: str = "anonymous") -> dict:
491
+ """Get vector store statistics for a user."""
492
+ try:
493
+ collection = self.vectorstore._collection
494
+ if user_id == "anonymous":
495
+ results = collection.get(where={"user_id": user_id})
496
+ if not results.get("ids"):
497
+ all_docs = collection.get()
498
+ results = {"ids": [ids for i, ids in enumerate(all_docs.get("ids", [])) if "user_id" not in all_docs.get("metadatas", [])[i]]}
499
+ else:
500
+ results = collection.get(where={"user_id": user_id})
501
+
502
+ count = len(results["ids"]) if results and results.get("ids") else 0
503
+ docs = self.get_document_list(user_id=user_id)
504
+ return {
505
+ "total_chunks": count,
506
+ "total_documents": len(docs),
507
+ "documents": docs,
508
+ "embedding_model": self.embedding_key,
509
+ "hybrid_search": self.enable_hybrid and self.bm25.is_ready,
510
+ "reranking": self.enable_reranking,
511
+ }
512
+ except Exception:
513
+ return {
514
+ "total_chunks": 0,
515
+ "total_documents": 0,
516
+ "documents": [],
517
+ "embedding_model": self.embedding_key,
518
+ "hybrid_search": False,
519
+ "reranking": False,
520
+ }
521
+
522
+ def get_all_text(self, user_id: str = "anonymous") -> str:
523
+ """Get all document text for a specific user (for full-context queries)."""
524
+ try:
525
+ collection = self.vectorstore._collection
526
+ results = collection.get(where={"user_id": user_id})
527
+ if results and results["documents"]:
528
+ return "\n\n".join(results["documents"])
529
+ except Exception:
530
+ pass
531
+ return ""
532
+
533
+ def get_documents_by_type(self) -> Dict[str, List[str]]:
534
+ """Get documents grouped by type."""
535
+ try:
536
+ collection = self.vectorstore._collection
537
+ results = collection.get()
538
+ by_type: Dict[str, List[str]] = {}
539
+ for meta in results.get("metadatas", []):
540
+ if meta:
541
+ doc_type = meta.get("doc_type", "other")
542
+ filename = meta.get("filename", "?")
543
+ if doc_type not in by_type:
544
+ by_type[doc_type] = []
545
+ if filename not in by_type[doc_type]:
546
+ by_type[doc_type].append(filename)
547
+ return by_type
548
+ except Exception:
549
+ return {}