Commit Β·
b7934cd
0
Parent(s):
Deploy CareerAI to HuggingFace Spaces
Browse files- .gitattributes +1 -0
- .gitignore +46 -0
- .streamlit/config.toml +13 -0
- Dockerfile +31 -0
- README.md +484 -0
- api.py +713 -0
- frontend/app.js +1841 -0
- frontend/favicon.png +3 -0
- frontend/icon-flash.png +3 -0
- frontend/icon-pro.png +3 -0
- frontend/index.html +627 -0
- frontend/styles.css +1695 -0
- render.yaml +27 -0
- requirements.txt +38 -0
- src/__init__.py +1 -0
- src/auth.py +326 -0
- src/career_assistant.py +330 -0
- src/document_processor.py +383 -0
- src/exporter.py +1171 -0
- src/models.py +45 -0
- src/profile_extractor.py +171 -0
- src/rag_engine.py +549 -0
.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 |
+
|
| 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 |
+
|
| 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()">×</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(/^> (.+)$/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
|
frontend/icon-flash.png
ADDED
|
|
Git LFS Details
|
frontend/icon-pro.png
ADDED
|
|
Git LFS Details
|
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"></></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">×</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;">×</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;">×</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;">×</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 {}
|