Mr-Haseeb786 commited on
Commit ·
88da18c
0
Parent(s):
Clean deployment build
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +21 -0
- .env.local +76 -0
- .gitignore +68 -0
- ARCHITECTURE.md +540 -0
- Automatic CV and Cover Letter with API/README.md +131 -0
- Automatic CV and Cover Letter with API/linkedin_url_instructions.md +84 -0
- Automatic CV and Cover Letter with API/notebooks/cv_system.ipynb +532 -0
- Automatic CV and Cover Letter with API/requirements.txt +5 -0
- Automatic CV and Cover Letter with API/src/parsers/document_parser.py +314 -0
- Automatic CV and Cover Letter with API/src/updaters/document_updater.py +427 -0
- Automatic CV and Cover Letter with API/src/utils/openai_integration.py +358 -0
- Automatic CV and Cover Letter with API/test_document_updater.py +75 -0
- Automatic CV and Cover Letter with API/test_openai_integration.py +37 -0
- CHANGES.md +418 -0
- CV maker/2222cvCreate.ipynb +394 -0
- CV_Tailor/ai_cv_tailor.py +127 -0
- DEPLOYMENT_GUIDE.md +292 -0
- DOCUMENTATION.md +292 -0
- Dockerfile +42 -0
- QUICK_START.md +366 -0
- README.md +278 -0
- SAAS_FEATURES.md +367 -0
- TESTING_GUIDE.md +134 -0
- config.py +28 -0
- f_requirements.txt +141 -0
- frontend/.env.example +3 -0
- frontend/.eslintignore +6 -0
- frontend/.gitignore +34 -0
- frontend/README.md +363 -0
- frontend/index.html +26 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +37 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.tsx +89 -0
- frontend/src/components/common/Badge.tsx +58 -0
- frontend/src/components/common/Button.tsx +91 -0
- frontend/src/components/common/Card.tsx +49 -0
- frontend/src/components/common/Input.tsx +76 -0
- frontend/src/components/common/Modal.tsx +89 -0
- frontend/src/components/common/ProgressBar.tsx +57 -0
- frontend/src/components/common/Select.tsx +93 -0
- frontend/src/components/common/Spinner.tsx +30 -0
- frontend/src/components/common/Tabs.tsx +47 -0
- frontend/src/components/common/Toast.tsx +119 -0
- frontend/src/components/common/index.ts +11 -0
- frontend/src/components/pages/HomePage.tsx +200 -0
- frontend/src/components/pages/JobListPage.tsx +528 -0
- frontend/src/components/pages/SettingsModal.tsx +271 -0
- frontend/src/components/pages/WorkflowPage.tsx +271 -0
- frontend/src/components/pages/index.ts +5 -0
.dockerignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore local Python environments and cache
|
| 2 |
+
|
| 3 |
+
venv/
|
| 4 |
+
env/
|
| 5 |
+
**pycache**/
|
| 6 |
+
\*.pyc
|
| 7 |
+
|
| 8 |
+
# Ignore environment variables (Secrets go in Hugging Face UI, not the image)
|
| 9 |
+
|
| 10 |
+
.env
|
| 11 |
+
.env.local
|
| 12 |
+
|
| 13 |
+
# Ignore frontend build caches (Vercel will handle the frontend)
|
| 14 |
+
|
| 15 |
+
.npm-cache/
|
| 16 |
+
frontend/node_modules/
|
| 17 |
+
|
| 18 |
+
# Ignore local runtime data so the cloud server starts fresh
|
| 19 |
+
|
| 20 |
+
.runtime/
|
| 21 |
+
uploads/
|
.env.local
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Job Application AI Agent Configuration
|
| 2 |
+
|
| 3 |
+
# ============================================================================
|
| 4 |
+
# LLM PROVIDER SETTINGS
|
| 5 |
+
# ============================================================================
|
| 6 |
+
# Options: "ollama" (free, local), "groq" (API), "grok" (xAI API), "openai" (premium)
|
| 7 |
+
LLM_PROVIDER=groq
|
| 8 |
+
|
| 9 |
+
# Groq API Configuration
|
| 10 |
+
# Get your API key from: https://console.groq.com/keys
|
| 11 |
+
GROQ_API_KEY=
|
| 12 |
+
GROQ_MODEL=llama-3.3-70b-versatile
|
| 13 |
+
GROQ_BASE_URL=https://api.groq.com/openai/v1
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# OpenAI Configuration (Optional - if you prefer OpenAI)
|
| 18 |
+
# OPENAI_API_KEY=your_openai_key_here
|
| 19 |
+
# OPENAI_MODEL=gpt-4o-mini
|
| 20 |
+
|
| 21 |
+
# Ollama Configuration (Optional - for local model)
|
| 22 |
+
# OLLAMA_API_KEY=ollama
|
| 23 |
+
# OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
|
| 24 |
+
# OLLAMA_MODEL=llama3.1:8b
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
JOB_APPLY_AI_DATA_DIR=.runtime
|
| 28 |
+
TMP=.local_state/temp
|
| 29 |
+
TEMP=.local_state/temp
|
| 30 |
+
PIP_CACHE_DIR=.local_state/pip-cache
|
| 31 |
+
PYTHONPYCACHEPREFIX=.local_state/pycache
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# Set to your Chrome major version (check: chrome://version/)
|
| 35 |
+
# Examples: 146, 147, 148
|
| 36 |
+
UC_CHROME_VERSION_MAIN=146
|
| 37 |
+
|
| 38 |
+
# Chrome launch path (uncomment if Chrome is not found automatically)
|
| 39 |
+
CHROME_BINARY_PATH=C:\Program Files\Google\Chrome\Application\chrome.exe
|
| 40 |
+
|
| 41 |
+
# Disable GPU (helps with Chrome connection issues)
|
| 42 |
+
# Set to 1 to disable, 0 to enable
|
| 43 |
+
CHROME_DISABLE_GPU=1
|
| 44 |
+
|
| 45 |
+
# Disable sandbox (helpful for some systems)
|
| 46 |
+
# Set to 1 to disable, 0 to enable
|
| 47 |
+
CHROME_DISABLE_SANDBOX=0
|
| 48 |
+
|
| 49 |
+
# LinkedIn scraping mode: 1 = browser (Selenium), 0 = HTTP fallback (recommended on this machine)
|
| 50 |
+
LINKEDIN_USE_BROWSER=0
|
| 51 |
+
|
| 52 |
+
# CV tailoring mode for main UI:
|
| 53 |
+
# local = built-in rule/NLP tailoring (default)
|
| 54 |
+
# api = use "Automatic CV and Cover Letter with API" engine from same UI
|
| 55 |
+
CV_TAILORING_MODE=local
|
| 56 |
+
|
| 57 |
+
# Enable/disable profile summary rewriting in local mode
|
| 58 |
+
CV_ENABLE_SUMMARY_TAILORING=1
|
| 59 |
+
|
| 60 |
+
# API provider options for api mode (choose one provider)
|
| 61 |
+
# LLM_PROVIDER=groq
|
| 62 |
+
# GROQ_API_KEY=your_groq_api_key_here
|
| 63 |
+
# GROQ_MODEL=llama-3.3-70b-versatile
|
| 64 |
+
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
| 65 |
+
|
| 66 |
+
# Optional cover letter template path required by API mode updater class
|
| 67 |
+
# API_COVER_LETTER_TEMPLATE_PATH=Automatic CV and Cover Letter with API/data/Cover Letter_Imon .docx
|
| 68 |
+
|
| 69 |
+
# ============================================================================
|
| 70 |
+
# FLASK/WEB INTERFACE SETTINGS
|
| 71 |
+
# ============================================================================
|
| 72 |
+
SECRET_KEY=dev_key_for_testing
|
| 73 |
+
FLASK_ENV=development
|
| 74 |
+
FLASK_DEBUG=0
|
| 75 |
+
|
| 76 |
+
|
.gitignore
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual Environment
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
ENV/
|
| 27 |
+
|
| 28 |
+
# IDE
|
| 29 |
+
.idea/
|
| 30 |
+
.vscode/
|
| 31 |
+
*.swp
|
| 32 |
+
*.swo
|
| 33 |
+
|
| 34 |
+
# OS specific
|
| 35 |
+
.DS_Store
|
| 36 |
+
Thumbs.db
|
| 37 |
+
|
| 38 |
+
# Project specific
|
| 39 |
+
*.xlsx
|
| 40 |
+
*.docx
|
| 41 |
+
*.pdf
|
| 42 |
+
*.log
|
| 43 |
+
*.tmp
|
| 44 |
+
temp/
|
| 45 |
+
output/
|
| 46 |
+
.env
|
| 47 |
+
.env.*.local
|
| 48 |
+
|
| 49 |
+
# Flask
|
| 50 |
+
instance/
|
| 51 |
+
.webassets-cache
|
| 52 |
+
flask_session/
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
.local_state
|
| 56 |
+
.npm-cache/
|
| 57 |
+
.runtime/
|
| 58 |
+
.tools
|
| 59 |
+
|
| 60 |
+
# Models
|
| 61 |
+
trained_models/
|
| 62 |
+
|
| 63 |
+
.tools/
|
| 64 |
+
.runtime/
|
| 65 |
+
|
| 66 |
+
*.exe
|
| 67 |
+
*.zip
|
| 68 |
+
frontend/assets/skillsync_logo.png
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Technical Architecture
|
| 2 |
+
|
| 3 |
+
Complete technical documentation for the modern Job Apply AI React SaaS application.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
The application uses a **monolithic architecture** with:
|
| 8 |
+
- **Frontend**: React 18 + TypeScript + Vite
|
| 9 |
+
- **Backend**: Flask REST API
|
| 10 |
+
- **Communication**: JSON over HTTP
|
| 11 |
+
- **State**: Zustand (frontend), Session (backend)
|
| 12 |
+
- **Styling**: Tailwind CSS + Framer Motion
|
| 13 |
+
|
| 14 |
+
## Application Flow
|
| 15 |
+
|
| 16 |
+
```
|
| 17 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 18 |
+
│ USER BROWSER │
|
| 19 |
+
├─────────────────────────────────────────────────────────────────┤
|
| 20 |
+
│ React App (Port 3000) │
|
| 21 |
+
│ ├── HomePage (landing page) │
|
| 22 |
+
│ ├── WorkflowPage (3-step wizard) │
|
| 23 |
+
│ ├── JobListPage (job browsing + selection) │
|
| 24 |
+
│ └── SettingsModal (configuration) │
|
| 25 |
+
│ │
|
| 26 |
+
│ State: Zustand Store │
|
| 27 |
+
│ ├── jobs: Job[] │
|
| 28 |
+
│ ├── cvTemplate: CVTemplate │
|
| 29 |
+
│ ├── tailoringMode: 'local' | 'api' │
|
| 30 |
+
│ └── notifications: Toast[] │
|
| 31 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 32 |
+
↓ (REST API Calls)
|
| 33 |
+
↓ (JSON requests/responses)
|
| 34 |
+
↓
|
| 35 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 36 |
+
│ FLASK SERVER (Port 5050) │
|
| 37 |
+
├─────────────────────────────────────────────────────────────────┤
|
| 38 |
+
│ Route Handlers │
|
| 39 |
+
│ ├── GET /api/health │
|
| 40 |
+
│ ├── GET /api/config │
|
| 41 |
+
│ ├── POST /api/upload-cv │
|
| 42 |
+
│ ├── POST /api/search │
|
| 43 |
+
│ ├── GET /api/jobs │
|
| 44 |
+
│ ├── POST /api/generate-cv/<id> │
|
| 45 |
+
│ ├── POST /api/generate-all-cvs │
|
| 46 |
+
│ └── GET /api/download/<filename> │
|
| 47 |
+
│ │
|
| 48 |
+
│ Business Logic │
|
| 49 |
+
│ ├── LinkedInScraper (job collection) │
|
| 50 |
+
│ ├── CVAnalyzer (skill extraction) │
|
| 51 |
+
│ └── CVModifier (CV customization) │
|
| 52 |
+
│ │
|
| 53 |
+
│ Data Storage │
|
| 54 |
+
│ └── .runtime/ (local filesystem) │
|
| 55 |
+
│ ├── uploads/cv_templates/ │
|
| 56 |
+
│ ├── uploads/cvs/generated_cvs/ │
|
| 57 |
+
│ ├── uploads/jobs/excel_exports/ │
|
| 58 |
+
│ └── uploads/session_state/json_files/ │
|
| 59 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Component Hierarchy
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
App
|
| 66 |
+
├── HomePage
|
| 67 |
+
│ ├── Header
|
| 68 |
+
│ ├── HeroSection
|
| 69 |
+
│ ├── FeaturesGrid
|
| 70 |
+
│ └── Footer
|
| 71 |
+
│
|
| 72 |
+
├── WorkflowPage
|
| 73 |
+
│ ├── Header
|
| 74 |
+
│ ├── ProgressSteps
|
| 75 |
+
│ ├── CVUpload (step 1)
|
| 76 |
+
│ ├── JobSearch (step 2)
|
| 77 |
+
│ └── ReviewSection (step 3)
|
| 78 |
+
│
|
| 79 |
+
├── JobListPage
|
| 80 |
+
│ ├── JobListHeader
|
| 81 |
+
│ ├── SelectionControls
|
| 82 |
+
│ ├── JobCard (repeated)
|
| 83 |
+
│ │ ├── JobHeader
|
| 84 |
+
│ │ ├── SkillBadges
|
| 85 |
+
│ │ ├── ExpandedDetails
|
| 86 |
+
│ │ └── GenerateButton
|
| 87 |
+
│ └── BatchProgress (floating)
|
| 88 |
+
│
|
| 89 |
+
├── SettingsModal
|
| 90 |
+
│ ├── TailoringModeSelector
|
| 91 |
+
│ ├── LLMProviderSelector
|
| 92 |
+
│ └── AdvancedOptions
|
| 93 |
+
│
|
| 94 |
+
└── Toast (notification)
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Data Models
|
| 98 |
+
|
| 99 |
+
### Frontend (TypeScript)
|
| 100 |
+
|
| 101 |
+
```typescript
|
| 102 |
+
interface Job {
|
| 103 |
+
id: string;
|
| 104 |
+
title: string;
|
| 105 |
+
company: string;
|
| 106 |
+
link: string;
|
| 107 |
+
source: 'LinkedIn' | 'Indeed' | 'Other';
|
| 108 |
+
posted_days_ago: number;
|
| 109 |
+
description: string;
|
| 110 |
+
matched_skills: string[];
|
| 111 |
+
matched_categories: Record<string, string[]>;
|
| 112 |
+
salary?: string;
|
| 113 |
+
location?: string;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
interface CVTemplate {
|
| 117 |
+
filename: string;
|
| 118 |
+
uploadedAt: string;
|
| 119 |
+
size: number;
|
| 120 |
+
content?: ArrayBuffer;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
interface GeneratedCV {
|
| 124 |
+
jobId: string;
|
| 125 |
+
jobTitle: string;
|
| 126 |
+
company: string;
|
| 127 |
+
filename: string;
|
| 128 |
+
url: string;
|
| 129 |
+
generatedAt: string;
|
| 130 |
+
status: 'success' | 'failed';
|
| 131 |
+
error?: string;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
interface AppState {
|
| 135 |
+
jobs: Job[];
|
| 136 |
+
cvTemplate: CVTemplate | null;
|
| 137 |
+
tailoringMode: 'local' | 'api';
|
| 138 |
+
isSearching: boolean;
|
| 139 |
+
isGenerating: boolean;
|
| 140 |
+
selectedJobIds: Set<string>;
|
| 141 |
+
batchProgress: BatchProgress;
|
| 142 |
+
notification: Toast | null;
|
| 143 |
+
// ... setters and methods
|
| 144 |
+
}
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### Backend (Python/JSON)
|
| 148 |
+
|
| 149 |
+
```python
|
| 150 |
+
# Job response from /api/search
|
| 151 |
+
{
|
| 152 |
+
"jobs": [
|
| 153 |
+
{
|
| 154 |
+
"id": "job_0",
|
| 155 |
+
"title": "Senior React Developer",
|
| 156 |
+
"company": "Tech Corp",
|
| 157 |
+
"link": "https://...",
|
| 158 |
+
"source": "LinkedIn",
|
| 159 |
+
"posted_days_ago": 3,
|
| 160 |
+
"description": "Full job description...",
|
| 161 |
+
"location": "San Francisco, CA",
|
| 162 |
+
"matched_skills": ["React", "TypeScript", "REST API"],
|
| 163 |
+
"matched_categories": {
|
| 164 |
+
"Frameworks & Libraries": ["React"],
|
| 165 |
+
"Programming Languages": ["TypeScript"],
|
| 166 |
+
"Tools & Platforms": ["REST API"]
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
],
|
| 170 |
+
"excel_file": "linkedin_jobs_2024-01-15_1705353600.xlsx"
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
# CV generation response from /api/generate-cv/<id>
|
| 174 |
+
{
|
| 175 |
+
"success": true,
|
| 176 |
+
"filename": "CV_20240115_120530_TechCorp_ReactDeveloper.docx",
|
| 177 |
+
"job_title": "Senior React Developer",
|
| 178 |
+
"company": "Tech Corp",
|
| 179 |
+
"message": "CV generated successfully"
|
| 180 |
+
}
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
## Request/Response Examples
|
| 184 |
+
|
| 185 |
+
### Upload CV
|
| 186 |
+
|
| 187 |
+
**Request:**
|
| 188 |
+
```
|
| 189 |
+
POST /api/upload-cv
|
| 190 |
+
Content-Type: multipart/form-data
|
| 191 |
+
|
| 192 |
+
file: (binary .docx file)
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
**Response:**
|
| 196 |
+
```json
|
| 197 |
+
{
|
| 198 |
+
"success": true,
|
| 199 |
+
"filename": "resume.docx",
|
| 200 |
+
"message": "CV template uploaded successfully"
|
| 201 |
+
}
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### Search Jobs
|
| 205 |
+
|
| 206 |
+
**Request:**
|
| 207 |
+
```json
|
| 208 |
+
POST /api/search
|
| 209 |
+
{
|
| 210 |
+
"keyword": "React Developer",
|
| 211 |
+
"location": "San Francisco",
|
| 212 |
+
"max_jobs": 10,
|
| 213 |
+
"max_days_old": 14,
|
| 214 |
+
"tailoring_mode": "local"
|
| 215 |
+
}
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
**Response:**
|
| 219 |
+
```json
|
| 220 |
+
{
|
| 221 |
+
"success": true,
|
| 222 |
+
"jobs": [/* job objects */],
|
| 223 |
+
"excel_file": "linkedin_jobs_2024-01-15_1234567890.xlsx",
|
| 224 |
+
"message": "Found 10 jobs"
|
| 225 |
+
}
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
### Generate All CVs
|
| 229 |
+
|
| 230 |
+
**Request:**
|
| 231 |
+
```json
|
| 232 |
+
POST /api/generate-all-cvs
|
| 233 |
+
{
|
| 234 |
+
"job_indices": [0, 2, 5] // null for all
|
| 235 |
+
}
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
**Response:**
|
| 239 |
+
```json
|
| 240 |
+
{
|
| 241 |
+
"success": true,
|
| 242 |
+
"successful": [
|
| 243 |
+
{
|
| 244 |
+
"job_index": 0,
|
| 245 |
+
"filename": "CV_..._TechCorp_ReactDev.docx",
|
| 246 |
+
"job_title": "Senior React Developer",
|
| 247 |
+
"company": "Tech Corp"
|
| 248 |
+
}
|
| 249 |
+
],
|
| 250 |
+
"failed": [
|
| 251 |
+
{
|
| 252 |
+
"job_index": 2,
|
| 253 |
+
"error": "File processing error",
|
| 254 |
+
"job_title": "Mid-level Engineer"
|
| 255 |
+
}
|
| 256 |
+
],
|
| 257 |
+
"zip_filename": "CVs_20240115_120530.zip",
|
| 258 |
+
"total_generated": 2,
|
| 259 |
+
"total_failed": 1
|
| 260 |
+
}
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
## State Management Flow
|
| 264 |
+
|
| 265 |
+
### Zustand Store Pattern
|
| 266 |
+
|
| 267 |
+
```typescript
|
| 268 |
+
// 1. Define store with getter/setter/action methods
|
| 269 |
+
export const useJobStore = create<AppState>()(
|
| 270 |
+
persist((set, get) => ({
|
| 271 |
+
jobs: [],
|
| 272 |
+
setJobs: (jobs) => set({ jobs }),
|
| 273 |
+
addJob: (job) => set((state) => ({
|
| 274 |
+
jobs: [...state.jobs, job]
|
| 275 |
+
})),
|
| 276 |
+
toggleJobSelection: (jobId) => set((state) => ({
|
| 277 |
+
selectedJobIds: new Set(...)
|
| 278 |
+
})),
|
| 279 |
+
// ... more methods
|
| 280 |
+
}), {
|
| 281 |
+
name: 'job-apply-store', // localStorage key
|
| 282 |
+
partialize: (state) => ({ // what to persist
|
| 283 |
+
tailoringMode: state.tailoringMode,
|
| 284 |
+
llmProvider: state.llmProvider,
|
| 285 |
+
// Don't persist large data
|
| 286 |
+
})
|
| 287 |
+
})
|
| 288 |
+
);
|
| 289 |
+
|
| 290 |
+
// 2. Use in components
|
| 291 |
+
const MyComponent = () => {
|
| 292 |
+
const { jobs, setJobs, isSearching } = useJobStore();
|
| 293 |
+
// Component re-renders when state changes
|
| 294 |
+
};
|
| 295 |
+
|
| 296 |
+
// 3. Update state
|
| 297 |
+
await handleSearch(); // calls setJobs()
|
| 298 |
+
// Component automatically re-renders
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
### Session Flow
|
| 302 |
+
|
| 303 |
+
```
|
| 304 |
+
1. Browser → Upload CV → Flask saves to .runtime/uploads/
|
| 305 |
+
2. Browser → Search → Flask queries LinkedIn
|
| 306 |
+
3. Flask → Processes results → Saves to .runtime/session_state/{uuid}.json
|
| 307 |
+
4. Browser ← Get jobs from session
|
| 308 |
+
5. Browser → Generate CV → Flask reads job from session state
|
| 309 |
+
6. Flask → Modifies document → Saves to .runtime/uploads/cvs/
|
| 310 |
+
7. Browser ← Download CV
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
## File System Structure
|
| 314 |
+
|
| 315 |
+
```
|
| 316 |
+
.runtime/
|
| 317 |
+
├── uploads/
|
| 318 |
+
│ ├── cv_template_1705353600.docx
|
| 319 |
+
│ │
|
| 320 |
+
│ ├── cvs/
|
| 321 |
+
│ │ ├── CV_20240115_120530_TechCorp_ReactDeveloper.docx
|
| 322 |
+
│ │ ├── CV_20240115_120531_DataCorp_Engineer.docx
|
| 323 |
+
│ │ └── CVs_20240115_120530.zip
|
| 324 |
+
│ │
|
| 325 |
+
│ ├── jobs/
|
| 326 |
+
�� │ └── linkedin_jobs_2024-01-15_1705353600.xlsx
|
| 327 |
+
│ │
|
| 328 |
+
│ └── session_state/
|
| 329 |
+
│ └── c8f9d2e1-4b3c-5a7f-8e9c-2d4f6a8b9c1d.json
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
## API Error Handling
|
| 333 |
+
|
| 334 |
+
### Client-Side
|
| 335 |
+
|
| 336 |
+
```typescript
|
| 337 |
+
try {
|
| 338 |
+
const result = await jobsAPI.searchJobs(filters);
|
| 339 |
+
setJobs(result.jobs);
|
| 340 |
+
} catch (error) {
|
| 341 |
+
setNotification({
|
| 342 |
+
type: 'error',
|
| 343 |
+
message: error.message
|
| 344 |
+
});
|
| 345 |
+
}
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
### Server-Side
|
| 349 |
+
|
| 350 |
+
```python
|
| 351 |
+
@app.route('/api/search', methods=['POST'])
|
| 352 |
+
def api_search_jobs():
|
| 353 |
+
try:
|
| 354 |
+
# Validate input
|
| 355 |
+
if not keyword: return jsonify({...}, 400)
|
| 356 |
+
|
| 357 |
+
# Process
|
| 358 |
+
jobs = scraper.scrape_job_listings(...)
|
| 359 |
+
|
| 360 |
+
# Return
|
| 361 |
+
return jsonify({'success': True, 'jobs': jobs})
|
| 362 |
+
except Exception as e:
|
| 363 |
+
logger.error(str(e))
|
| 364 |
+
return jsonify({'success': False, 'error': str(e)}, 500)
|
| 365 |
+
```
|
| 366 |
+
|
| 367 |
+
### Error Types
|
| 368 |
+
|
| 369 |
+
| Type | HTTPCode | User Message |
|
| 370 |
+
|------|----------|--------------|
|
| 371 |
+
| Missing required field | 400 | "Keyword and location are required" |
|
| 372 |
+
| Invalid file type | 400 | "Only .docx files are supported" |
|
| 373 |
+
| File not found | 404 | "CV template not found" |
|
| 374 |
+
| Server error | 500 | "An error occurred. Please try again" |
|
| 375 |
+
|
| 376 |
+
## Performance Considerations
|
| 377 |
+
|
| 378 |
+
### Frontend
|
| 379 |
+
|
| 380 |
+
- **Code Splitting**: Route-based chunk splitting via Vite
|
| 381 |
+
- **Lazy Loading**: Components load on demand
|
| 382 |
+
- **Memoization**: React.memo for expensive components
|
| 383 |
+
- **Debouncing**: Search input debounced
|
| 384 |
+
- **CSS**: Tailwind purges unused styles
|
| 385 |
+
|
| 386 |
+
### Backend
|
| 387 |
+
|
| 388 |
+
- **Batch Operations**: Process multiple CVs in single request
|
| 389 |
+
- **Session Caching**: Job data cached in .runtime/
|
| 390 |
+
- **Connection Pooling**: Selenium reuses browser window
|
| 391 |
+
- **Async**: Non-blocking operations where possible
|
| 392 |
+
|
| 393 |
+
### Network
|
| 394 |
+
|
| 395 |
+
- **JSON": Compact data format vs XML/Form
|
| 396 |
+
- **Compression**: Gzip enabled by default
|
| 397 |
+
- **Caching": Etag headers for static assets
|
| 398 |
+
- **Streaming**: Files streamed for download
|
| 399 |
+
|
| 400 |
+
## Security Measures
|
| 401 |
+
|
| 402 |
+
### Input Validation
|
| 403 |
+
|
| 404 |
+
```python
|
| 405 |
+
# File type check
|
| 406 |
+
if not file.filename.endswith('.docx'):
|
| 407 |
+
return error
|
| 408 |
+
|
| 409 |
+
# Size limit
|
| 410 |
+
if file.size > 10_000_000:
|
| 411 |
+
return error
|
| 412 |
+
|
| 413 |
+
# Path traversal prevention
|
| 414 |
+
if '..' in filename or '/' in filename:
|
| 415 |
+
return error
|
| 416 |
+
```
|
| 417 |
+
|
| 418 |
+
### Session Security
|
| 419 |
+
|
| 420 |
+
```python
|
| 421 |
+
# Unique session key per run
|
| 422 |
+
app.config['SESSION_COOKIE_NAME'] = f"job_apply_ai_{int(time.time())}"
|
| 423 |
+
|
| 424 |
+
# Secure path storage
|
| 425 |
+
_session_state_path(state_id) # Safe path construction
|
| 426 |
+
|
| 427 |
+
# CORS configuration
|
| 428 |
+
CORS(app, resources={r"/api/*":{"origins":"*"}})
|
| 429 |
+
```
|
| 430 |
+
|
| 431 |
+
### Data Protection
|
| 432 |
+
|
| 433 |
+
- No passwords stored
|
| 434 |
+
- No PII logged
|
| 435 |
+
- Uploaded files deleted after processing (optional)
|
| 436 |
+
- Session files cleaned up
|
| 437 |
+
|
| 438 |
+
## Scaling Architecture
|
| 439 |
+
|
| 440 |
+
### Horizontal Scaling
|
| 441 |
+
|
| 442 |
+
```
|
| 443 |
+
User → Load Balancer
|
| 444 |
+
├── Flask Server 1 → Shared Session Store (Redis)
|
| 445 |
+
├── Flask Server 2 → Shared File Storage (S3/NFS)
|
| 446 |
+
└── Flask Server 3
|
| 447 |
+
```
|
| 448 |
+
|
| 449 |
+
### Vertical Scaling
|
| 450 |
+
|
| 451 |
+
```
|
| 452 |
+
Single Machine
|
| 453 |
+
├── Increase Worker Processes
|
| 454 |
+
├── Add RAM for larger job batches
|
| 455 |
+
├── SSD for faster CV processing
|
| 456 |
+
└── Dedicated GPU for future AI tasks
|
| 457 |
+
```
|
| 458 |
+
|
| 459 |
+
## Deployment Targets
|
| 460 |
+
|
| 461 |
+
### Development
|
| 462 |
+
- Vite dev server on :3000
|
| 463 |
+
- Flask dev server on :5050
|
| 464 |
+
- Hot reload enabled
|
| 465 |
+
|
| 466 |
+
### Staging
|
| 467 |
+
- Docker containers
|
| 468 |
+
- Cloud platform (AWS/GCP/Azure)
|
| 469 |
+
- Full testing suite
|
| 470 |
+
|
| 471 |
+
### Production
|
| 472 |
+
- Built React app on :3000 (or CDN)
|
| 473 |
+
- Gunicorn server on :5050
|
| 474 |
+
- Database for sessions
|
| 475 |
+
- Cloud storage for files
|
| 476 |
+
|
| 477 |
+
## Future Architectural Improvements
|
| 478 |
+
|
| 479 |
+
### Phase 2
|
| 480 |
+
- [ ] Authentication/Authorization system
|
| 481 |
+
- [ ] Database (PostgreSQL) for persistent storage
|
| 482 |
+
- [ ] Redis for session caching and queue
|
| 483 |
+
- [ ] Celery for async job processing
|
| 484 |
+
- [ ] WebSocket for real-time progress
|
| 485 |
+
|
| 486 |
+
### Phase 3
|
| 487 |
+
- [ ] Microservices architecture
|
| 488 |
+
- [ ] Kubernetes orchestration
|
| 489 |
+
- [ ] Message queue (RabbitMQ)
|
| 490 |
+
- [ ] API rate limiting
|
| 491 |
+
- [ ] Advanced caching
|
| 492 |
+
|
| 493 |
+
### Phase 4
|
| 494 |
+
- [ ] GraphQL API option
|
| 495 |
+
- [ ] Event streaming (Kafka)
|
| 496 |
+
- [ ] Service mesh (Istio)
|
| 497 |
+
- [ ] Distributed tracing
|
| 498 |
+
- [ ] Advanced analytics
|
| 499 |
+
|
| 500 |
+
## Technology Decision Rationale
|
| 501 |
+
|
| 502 |
+
| Choice | Why |
|
| 503 |
+
|--------|-----|
|
| 504 |
+
| React | Large ecosystem, component reusability, strong community |
|
| 505 |
+
| Zustand | Simple, no boilerplate compared to Redux |
|
| 506 |
+
| Tailwind | Fast development, consistent design system |
|
| 507 |
+
| Framer Motion | Smooth animations, good perf, learning curve |
|
| 508 |
+
| Vite | Fast builds, excellent DX, modern tooling |
|
| 509 |
+
| Flask | Lightweight, Pythonic, good for API + rendering |
|
| 510 |
+
| pandas | Data processing, Excel export |
|
| 511 |
+
| Selenium | Web automation, JS-heavy site support |
|
| 512 |
+
| spaCy | NLP, good performance, pre-trained models |
|
| 513 |
+
|
| 514 |
+
## Monitoring & Observability
|
| 515 |
+
|
| 516 |
+
### Logging
|
| 517 |
+
```python
|
| 518 |
+
logger.info("Job search initiated")
|
| 519 |
+
logger.warning("CV generation slow")
|
| 520 |
+
logger.error("Scraping failed")
|
| 521 |
+
```
|
| 522 |
+
|
| 523 |
+
### Metrics to Track
|
| 524 |
+
- Page load times
|
| 525 |
+
- API response times
|
| 526 |
+
- CV generation duration
|
| 527 |
+
- Success/failure rates
|
| 528 |
+
- File upload sizes
|
| 529 |
+
|
| 530 |
+
### Debugging
|
| 531 |
+
```
|
| 532 |
+
Browser DevTools → Network → Check API responses
|
| 533 |
+
Browser Console → Check React errors
|
| 534 |
+
Flask Terminal → Check server logs
|
| 535 |
+
Browser Storage → Check localStorage/state
|
| 536 |
+
```
|
| 537 |
+
|
| 538 |
+
---
|
| 539 |
+
|
| 540 |
+
This architecture provides a solid foundation for growth and future enhancements while maintaining simplicity and ease of development.
|
Automatic CV and Cover Letter with API/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CV and Cover Letter Tailoring System
|
| 2 |
+
|
| 3 |
+
This project provides an automated system to tailor your CV and cover letter based on job descriptions using an LLM API. By default it uses free local Ollama (no paid API required), and it can also be configured for Groq, Grok, or OpenAI.
|
| 4 |
+
|
| 5 |
+
## Project Structure
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
cv_tailoring_project/
|
| 9 |
+
├── data/ # Directory for storing CV and cover letter templates
|
| 10 |
+
│ ├── Cover Letter_Imon .docx
|
| 11 |
+
│ └── Imon Hosen - Resume_Template_ATS.docx
|
| 12 |
+
├── notebooks/ # Jupyter notebooks for interactive usage
|
| 13 |
+
│ └── cv_tailoring_system.ipynb
|
| 14 |
+
├── output/ # Directory for storing generated documents
|
| 15 |
+
├── src/ # Source code
|
| 16 |
+
│ ├── parsers/ # Document parsing modules
|
| 17 |
+
│ │ └── document_parser.py
|
| 18 |
+
│ ├── updaters/ # Document updating modules
|
| 19 |
+
│ │ └── document_updater.py
|
| 20 |
+
│ └── utils/ # Utility modules
|
| 21 |
+
│ └── openai_integration.py
|
| 22 |
+
└── README.md # Project documentation
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Features
|
| 26 |
+
|
| 27 |
+
- Parse and analyze Word document CV and cover letter templates
|
| 28 |
+
- Extract sections, personal information, and content structure
|
| 29 |
+
- Analyze job descriptions using a configurable LLM provider (Ollama/Groq/Grok/OpenAI)
|
| 30 |
+
- Generate tailored CV with updated profile summary, skills, and experience highlights
|
| 31 |
+
- Create customized cover letter that addresses specific job requirements
|
| 32 |
+
- Interactive Jupyter notebook interface for easy usage
|
| 33 |
+
- Support for batch processing multiple job applications
|
| 34 |
+
|
| 35 |
+
## Requirements
|
| 36 |
+
|
| 37 |
+
- Python 3.6+
|
| 38 |
+
- python-docx
|
| 39 |
+
- openai
|
| 40 |
+
- Jupyter Notebook/Lab
|
| 41 |
+
- Ollama (default, free/local) or a Groq/Grok/OpenAI API key
|
| 42 |
+
|
| 43 |
+
## Getting Started
|
| 44 |
+
|
| 45 |
+
1. Clone this repository or download the project files
|
| 46 |
+
2. Install the required dependencies:
|
| 47 |
+
```
|
| 48 |
+
pip install python-docx openai jupyter
|
| 49 |
+
```
|
| 50 |
+
3. (Recommended, free) Install and run Ollama, then pull a model:
|
| 51 |
+
```
|
| 52 |
+
ollama pull llama3.1:8b
|
| 53 |
+
```
|
| 54 |
+
4. Place your CV and cover letter templates in the `data` directory
|
| 55 |
+
5. Launch the Jupyter notebook:
|
| 56 |
+
```
|
| 57 |
+
jupyter notebook notebooks/cv_tailoring_system.ipynb
|
| 58 |
+
```
|
| 59 |
+
6. Follow the step-by-step instructions in the notebook
|
| 60 |
+
|
| 61 |
+
## Using the System
|
| 62 |
+
|
| 63 |
+
The Jupyter notebook provides a user-friendly interface with the following steps:
|
| 64 |
+
|
| 65 |
+
1. **Setup and Configuration**: Import necessary libraries and set up the environment
|
| 66 |
+
2. **Set up LLM provider**: Use Ollama (default) or set Groq/Grok/OpenAI variables
|
| 67 |
+
3. **Load Your CV and Cover Letter**: Load and analyze your existing documents
|
| 68 |
+
4. **Enter Job Description**: Paste the job description you're applying for
|
| 69 |
+
5. **Analyze Job Description**: Identify key requirements and skills
|
| 70 |
+
6. **Generate Tailored Documents**: Create customized versions of your CV and cover letter
|
| 71 |
+
7. **Customization Options**: Modify parameters to customize the tailoring process
|
| 72 |
+
8. **Process Multiple Job Applications**: Batch process multiple job descriptions
|
| 73 |
+
|
| 74 |
+
## Customization
|
| 75 |
+
|
| 76 |
+
You can customize the system by:
|
| 77 |
+
|
| 78 |
+
- Modifying the document parser to handle different CV/cover letter formats
|
| 79 |
+
- Adjusting the prompts in the integration module
|
| 80 |
+
- Changing the model used for analysis and generation
|
| 81 |
+
- Implementing additional document updating strategies
|
| 82 |
+
|
| 83 |
+
## Provider Configuration
|
| 84 |
+
|
| 85 |
+
### Free local mode (default)
|
| 86 |
+
|
| 87 |
+
No paid API key is required when running with Ollama.
|
| 88 |
+
|
| 89 |
+
Optional environment variables:
|
| 90 |
+
- `LLM_PROVIDER=ollama`
|
| 91 |
+
- `OLLAMA_BASE_URL=http://127.0.0.1:11434/v1`
|
| 92 |
+
- `OLLAMA_MODEL=llama3.1:8b`
|
| 93 |
+
|
| 94 |
+
### Grok mode (API-based, fast & affordable)
|
| 95 |
+
|
| 96 |
+
Set:
|
| 97 |
+
- `LLM_PROVIDER=grok`
|
| 98 |
+
- `GROK_API_KEY=<your xAI API key>`
|
| 99 |
+
- Optional: `GROK_MODEL=grok-2` (default) or `grok-3`
|
| 100 |
+
- Optional: `GROK_BASE_URL=https://api.x.ai/v1`
|
| 101 |
+
|
| 102 |
+
You can obtain a Grok API key from [xAI's website](https://console.x.ai/) (requires free account).
|
| 103 |
+
|
| 104 |
+
### Groq mode (API-based, fast and cost-effective)
|
| 105 |
+
|
| 106 |
+
Set:
|
| 107 |
+
- `LLM_PROVIDER=groq`
|
| 108 |
+
- `GROQ_API_KEY=<your Groq API key>`
|
| 109 |
+
- Optional: `GROQ_MODEL=llama-3.3-70b-versatile`
|
| 110 |
+
- Optional: `GROQ_BASE_URL=https://api.groq.com/openai/v1`
|
| 111 |
+
|
| 112 |
+
You can obtain a Groq API key from [Groq Console](https://console.groq.com/keys).
|
| 113 |
+
|
| 114 |
+
### OpenAI mode (paid, premium quality)
|
| 115 |
+
|
| 116 |
+
Set:
|
| 117 |
+
- `LLM_PROVIDER=openai`
|
| 118 |
+
- `OPENAI_API_KEY=<your key>`
|
| 119 |
+
- Optional: `OPENAI_MODEL=gpt-4o-mini`
|
| 120 |
+
|
| 121 |
+
You can obtain an OpenAI API key from [OpenAI's website](https://platform.openai.com/account/api-keys) if you choose OpenAI mode.
|
| 122 |
+
|
| 123 |
+
## Notes
|
| 124 |
+
|
| 125 |
+
- The system defaults to local Ollama with `llama3.1:8b`, and you can change providers via environment variables
|
| 126 |
+
- Document parsing is based on a simplified approach and may need adjustments for different document formats
|
| 127 |
+
- The system creates new documents rather than modifying the originals to preserve your templates
|
| 128 |
+
|
| 129 |
+
## License
|
| 130 |
+
|
| 131 |
+
This project is provided for personal use.
|
Automatic CV and Cover Letter with API/linkedin_url_instructions.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# How to Update the CV System to Use LinkedIn URLs
|
| 2 |
+
|
| 3 |
+
Follow these steps to update your notebook to support both LinkedIn URLs and job descriptions.
|
| 4 |
+
|
| 5 |
+
## Step 1: Install Required Dependencies
|
| 6 |
+
|
| 7 |
+
Run this in your terminal:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
pip install requests beautifulsoup4
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
## Step 2: Replace the Job Description Input Cell
|
| 14 |
+
|
| 15 |
+
In `notebooks/cv_system.ipynb`, find this cell:
|
| 16 |
+
|
| 17 |
+
```python
|
| 18 |
+
# Input area for job description
|
| 19 |
+
job_description = """
|
| 20 |
+
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
# Display the job description for confirmation
|
| 24 |
+
print("Job Description Preview (first 300 characters):")
|
| 25 |
+
print(job_description[:300] + "..." if len(job_description) > 300 else job_description)
|
| 26 |
+
print(f"\nTotal length: {len(job_description)} characters")
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Replace it with this new version:
|
| 30 |
+
|
| 31 |
+
```python
|
| 32 |
+
# Choose input method
|
| 33 |
+
print("Choose input method:")
|
| 34 |
+
print("1. LinkedIn Job URL")
|
| 35 |
+
print("2. Job Description Text")
|
| 36 |
+
input_method = input("Enter 1 or 2: ")
|
| 37 |
+
|
| 38 |
+
# Input area for job description or LinkedIn URL
|
| 39 |
+
if input_method == "1":
|
| 40 |
+
linkedin_url = input("Paste LinkedIn job URL: ")
|
| 41 |
+
print("LinkedIn URL detected. Extracting job description...")
|
| 42 |
+
try:
|
| 43 |
+
job_description = openai_integration.extract_job_description_from_url(linkedin_url.strip())
|
| 44 |
+
print("✓ Successfully extracted job description from LinkedIn")
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"Error extracting job description from URL: {str(e)}")
|
| 47 |
+
job_description = input("Please paste the job description text directly instead: ")
|
| 48 |
+
else:
|
| 49 |
+
print("Enter job description (paste below):")
|
| 50 |
+
job_description = input()
|
| 51 |
+
|
| 52 |
+
# Display the job description for confirmation
|
| 53 |
+
print("\nJob Description Preview (first 300 characters):")
|
| 54 |
+
print(job_description[:300] + "..." if len(job_description) > 300 else job_description)
|
| 55 |
+
print(f"\nTotal length: {len(job_description)} characters")
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Step 3: How to Use
|
| 59 |
+
|
| 60 |
+
1. **Using a LinkedIn URL:**
|
| 61 |
+
- Run the cell
|
| 62 |
+
- Enter 1 when prompted
|
| 63 |
+
- Paste the LinkedIn job URL (e.g., https://www.linkedin.com/jobs/view/your-job-id)
|
| 64 |
+
- The system will automatically extract the job description
|
| 65 |
+
|
| 66 |
+
2. **Using a Job Description:**
|
| 67 |
+
- Run the cell
|
| 68 |
+
- Enter 2 when prompted
|
| 69 |
+
- Paste the job description text
|
| 70 |
+
- Continue with the rest of the notebook
|
| 71 |
+
|
| 72 |
+
## Example LinkedIn URL Format
|
| 73 |
+
|
| 74 |
+
LinkedIn job URLs typically look like:
|
| 75 |
+
```
|
| 76 |
+
https://www.linkedin.com/jobs/view/working-student-at-company-name-3824699765
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## Troubleshooting
|
| 80 |
+
|
| 81 |
+
If you encounter errors with LinkedIn URL extraction:
|
| 82 |
+
- Make sure the URL is correct and publicly accessible
|
| 83 |
+
- Some LinkedIn job postings might require authentication
|
| 84 |
+
- As a fallback, you can always copy and paste the job description text directly
|
Automatic CV and Cover Letter with API/notebooks/cv_system.ipynb
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# CV and Cover Letter Automation System\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"This notebook provides a step-by-step interface for tailoring your CV and cover letter based on job descriptions using an LLM provider (Ollama, Groq, Grok, or OpenAI).\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"## How it works:\n",
|
| 12 |
+
"\n",
|
| 13 |
+
"1. Set up your LLM provider and API key (or local Ollama)\n",
|
| 14 |
+
"2. Paste a job description\n",
|
| 15 |
+
"3. The system analyzes your existing CV and cover letter\n",
|
| 16 |
+
"4. The selected LLM suggests tailored content based on the job description\n",
|
| 17 |
+
"5. New tailored documents are created in the output folder\n",
|
| 18 |
+
"\n",
|
| 19 |
+
"Let's get started!"
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"cell_type": "markdown",
|
| 24 |
+
"metadata": {},
|
| 25 |
+
"source": [
|
| 26 |
+
"## 1. Setup and Configuration\n",
|
| 27 |
+
"\n",
|
| 28 |
+
"First, let's import the necessary libraries and set up our environment."
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"cell_type": "code",
|
| 33 |
+
"execution_count": null,
|
| 34 |
+
"metadata": {},
|
| 35 |
+
"outputs": [
|
| 36 |
+
{
|
| 37 |
+
"name": "stdout",
|
| 38 |
+
"output_type": "stream",
|
| 39 |
+
"text": [
|
| 40 |
+
"Setup complete!\n"
|
| 41 |
+
]
|
| 42 |
+
}
|
| 43 |
+
],
|
| 44 |
+
"source": [
|
| 45 |
+
"import os\n",
|
| 46 |
+
"import sys\n",
|
| 47 |
+
"import json\n",
|
| 48 |
+
"from datetime import datetime\n",
|
| 49 |
+
"\n",
|
| 50 |
+
"\n",
|
| 51 |
+
"# Add the project root to the path\n",
|
| 52 |
+
"project_root = os.path.abspath(os.path.join(os.getcwd(), '..'))\n",
|
| 53 |
+
"if project_root not in sys.path:\n",
|
| 54 |
+
" sys.path.append(project_root)\n",
|
| 55 |
+
"\n",
|
| 56 |
+
"# Import our custom modules\n",
|
| 57 |
+
"from src.parsers.document_parser import CVParser, CoverLetterParser\n",
|
| 58 |
+
"from src.utils.openai_integration import OpenAIIntegration\n",
|
| 59 |
+
"from src.updaters.document_updater import DocumentUpdater\n",
|
| 60 |
+
"\n",
|
| 61 |
+
"# Define paths\n",
|
| 62 |
+
"DATA_DIR = os.path.join(project_root, 'data')\n",
|
| 63 |
+
"OUTPUT_DIR = os.path.join(project_root, 'output')\n",
|
| 64 |
+
"\n",
|
| 65 |
+
"# Create output directory if it doesn't exist\n",
|
| 66 |
+
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
|
| 67 |
+
"\n",
|
| 68 |
+
"print(\"Setup complete!\")"
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"cell_type": "code",
|
| 73 |
+
"execution_count": null,
|
| 74 |
+
"metadata": {},
|
| 75 |
+
"outputs": [],
|
| 76 |
+
"source": []
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"cell_type": "markdown",
|
| 80 |
+
"metadata": {},
|
| 81 |
+
"source": [
|
| 82 |
+
"## 2. Set up LLM Provider and API Key\n",
|
| 83 |
+
"\n",
|
| 84 |
+
"Choose one provider:\n",
|
| 85 |
+
"\n",
|
| 86 |
+
"- `ollama` (local/free, no paid key)\n",
|
| 87 |
+
"- `groq` (API key required)\n",
|
| 88 |
+
"- `grok` (xAI API key required)\n",
|
| 89 |
+
"- `openai` (OpenAI API key required)\n",
|
| 90 |
+
"\n",
|
| 91 |
+
"You can set provider variables in `.env` or enter a key directly below for API providers."
|
| 92 |
+
]
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"cell_type": "code",
|
| 96 |
+
"execution_count": null,
|
| 97 |
+
"metadata": {},
|
| 98 |
+
"outputs": [
|
| 99 |
+
{
|
| 100 |
+
"name": "stdout",
|
| 101 |
+
"output_type": "stream",
|
| 102 |
+
"text": [
|
| 103 |
+
"✅ API key successfully set!\n"
|
| 104 |
+
]
|
| 105 |
+
}
|
| 106 |
+
],
|
| 107 |
+
"source": [
|
| 108 |
+
"import getpass\n",
|
| 109 |
+
"import os\n",
|
| 110 |
+
"\n",
|
| 111 |
+
"provider = (os.environ.get(\"LLM_PROVIDER\", \"groq\") or \"groq\").lower()\n",
|
| 112 |
+
"print(f\"Selected provider: {provider}\")\n",
|
| 113 |
+
"\n",
|
| 114 |
+
"api_key = None\n",
|
| 115 |
+
"if provider in {\"openai\", \"grok\", \"groq\"}:\n",
|
| 116 |
+
" api_key = getpass.getpass(f\"Enter your {provider.upper()} API key (input hidden): \")\n",
|
| 117 |
+
"\n",
|
| 118 |
+
"openai_integration = OpenAIIntegration(api_key=api_key)\n",
|
| 119 |
+
"\n",
|
| 120 |
+
"if openai_integration.is_api_key_set():\n",
|
| 121 |
+
" print(f\"✅ Provider ready: {provider} | model={openai_integration.model}\")\n",
|
| 122 |
+
"else:\n",
|
| 123 |
+
" if provider == \"openai\":\n",
|
| 124 |
+
" print(\"❌ OPENAI_API_KEY missing. Set OPENAI_API_KEY or enter key above.\")\n",
|
| 125 |
+
" elif provider == \"grok\":\n",
|
| 126 |
+
" print(\"❌ GROK_API_KEY missing. Set GROK_API_KEY or enter key above.\")\n",
|
| 127 |
+
" elif provider == \"groq\":\n",
|
| 128 |
+
" print(\"❌ GROQ_API_KEY missing. Set GROQ_API_KEY or enter key above.\")\n",
|
| 129 |
+
" else:\n",
|
| 130 |
+
" print(\"❌ Provider not initialized. For Ollama, ensure OLLAMA_BASE_URL is reachable.\")"
|
| 131 |
+
]
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"cell_type": "markdown",
|
| 135 |
+
"metadata": {},
|
| 136 |
+
"source": [
|
| 137 |
+
"## 3. Load Your CV and Cover Letter\n",
|
| 138 |
+
"\n",
|
| 139 |
+
"The system will use your existing CV and cover letter as templates. Let's load them and see what they contain."
|
| 140 |
+
]
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"cell_type": "code",
|
| 144 |
+
"execution_count": 12,
|
| 145 |
+
"metadata": {},
|
| 146 |
+
"outputs": [
|
| 147 |
+
{
|
| 148 |
+
"name": "stdout",
|
| 149 |
+
"output_type": "stream",
|
| 150 |
+
"text": [
|
| 151 |
+
"Available documents:\n",
|
| 152 |
+
"1. Cover Letter_Imon .docx\n",
|
| 153 |
+
"2. Imon Hosen - Resume_Template_ATS.docx\n",
|
| 154 |
+
"\n",
|
| 155 |
+
"CV Information:\n",
|
| 156 |
+
"Name: Imon Hosen\n",
|
| 157 |
+
"Email: imonhosen99@gmail.com\n",
|
| 158 |
+
"Phone: +491606355807 \n",
|
| 159 |
+
"\n",
|
| 160 |
+
"CV Sections:\n",
|
| 161 |
+
"- HEADER\n",
|
| 162 |
+
"- PROFILE\n",
|
| 163 |
+
"- EDUCATION\n",
|
| 164 |
+
"- SKILLS\n",
|
| 165 |
+
"- CERTIFICATIONS\n",
|
| 166 |
+
"- EXPERIENCE\n",
|
| 167 |
+
"- PROJECTS\n",
|
| 168 |
+
"\n",
|
| 169 |
+
"Cover Letter Information:\n",
|
| 170 |
+
"Header: Imon Hosen\n",
|
| 171 |
+
"Greeting: Not found\n",
|
| 172 |
+
"Body length: 0 characters\n"
|
| 173 |
+
]
|
| 174 |
+
}
|
| 175 |
+
],
|
| 176 |
+
"source": [
|
| 177 |
+
"# List available documents in the data directory\n",
|
| 178 |
+
"available_documents = [f for f in os.listdir(DATA_DIR) if f.endswith('.docx')]\n",
|
| 179 |
+
"print(\"Available documents:\")\n",
|
| 180 |
+
"for i, doc in enumerate(available_documents):\n",
|
| 181 |
+
" print(f\"{i+1}. {doc}\")\n",
|
| 182 |
+
"\n",
|
| 183 |
+
"# Default documents (you can change these if needed)\n",
|
| 184 |
+
"cv_filename = \"Imon Hosen - Resume_Template_ATS.docx\"\n",
|
| 185 |
+
"cover_letter_filename = \"Cover Letter_Imon .docx\"\n",
|
| 186 |
+
"\n",
|
| 187 |
+
"# Construct full paths\n",
|
| 188 |
+
"cv_path = os.path.join(DATA_DIR, cv_filename)\n",
|
| 189 |
+
"cover_letter_path = os.path.join(DATA_DIR, cover_letter_filename)\n",
|
| 190 |
+
"\n",
|
| 191 |
+
"# Load documents\n",
|
| 192 |
+
"cv_parser = CVParser(cv_path)\n",
|
| 193 |
+
"cover_letter_parser = CoverLetterParser(cover_letter_path)\n",
|
| 194 |
+
"\n",
|
| 195 |
+
"# Display some information about the loaded documents\n",
|
| 196 |
+
"print(\"\\nCV Information:\")\n",
|
| 197 |
+
"personal_info = cv_parser.get_personal_info()\n",
|
| 198 |
+
"print(f\"Name: {personal_info.get('name', 'Not found')}\")\n",
|
| 199 |
+
"print(f\"Email: {personal_info.get('email', 'Not found')}\")\n",
|
| 200 |
+
"print(f\"Phone: {personal_info.get('phone', 'Not found')}\")\n",
|
| 201 |
+
"\n",
|
| 202 |
+
"print(\"\\nCV Sections:\")\n",
|
| 203 |
+
"for section in cv_parser.get_all_sections().keys():\n",
|
| 204 |
+
" print(f\"- {section}\")\n",
|
| 205 |
+
"\n",
|
| 206 |
+
"print(\"\\nCover Letter Information:\")\n",
|
| 207 |
+
"print(f\"Header: {cover_letter_parser.get_header() or 'Not found'}\")\n",
|
| 208 |
+
"print(f\"Greeting: {cover_letter_parser.get_greeting() or 'Not found'}\")\n",
|
| 209 |
+
"print(f\"Body length: {len(cover_letter_parser.get_body())} characters\")\n",
|
| 210 |
+
"\n",
|
| 211 |
+
"# Initialize document updater\n",
|
| 212 |
+
"document_updater = DocumentUpdater(cv_path, cover_letter_path, openai_integration)"
|
| 213 |
+
]
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"cell_type": "markdown",
|
| 217 |
+
"metadata": {},
|
| 218 |
+
"source": [
|
| 219 |
+
"## 4. Enter Job Description\n",
|
| 220 |
+
"\n",
|
| 221 |
+
"Now, paste the job description you want to tailor your CV and cover letter for:"
|
| 222 |
+
]
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"cell_type": "code",
|
| 226 |
+
"execution_count": 18,
|
| 227 |
+
"metadata": {},
|
| 228 |
+
"outputs": [
|
| 229 |
+
{
|
| 230 |
+
"name": "stdout",
|
| 231 |
+
"output_type": "stream",
|
| 232 |
+
"text": [
|
| 233 |
+
"Job Description Preview (first 300 characters):\n",
|
| 234 |
+
"\n",
|
| 235 |
+
"\n",
|
| 236 |
+
"\n",
|
| 237 |
+
"Working Student R&D - Material Master Data (all genders) \n",
|
| 238 |
+
"Hamburg, Hamburg, Germany · 1 week ago · 40 people clicked apply\n",
|
| 239 |
+
"Hybrid Full-time Internship\n",
|
| 240 |
+
"Skills: Communication, English, +8 more\n",
|
| 241 |
+
"Curious where you stand? See how you compare to 40 others who clicked apply. Try Premium for €0\n",
|
| 242 |
+
"\n",
|
| 243 |
+
"Apply\n",
|
| 244 |
+
"\n",
|
| 245 |
+
"...\n",
|
| 246 |
+
"\n",
|
| 247 |
+
"Total length: 2457 characters\n"
|
| 248 |
+
]
|
| 249 |
+
}
|
| 250 |
+
],
|
| 251 |
+
"source": [
|
| 252 |
+
"# Input area for job description\n",
|
| 253 |
+
"job_description = \"\"\"\n",
|
| 254 |
+
"\n",
|
| 255 |
+
"\n",
|
| 256 |
+
"\n",
|
| 257 |
+
"\"\"\"\n",
|
| 258 |
+
"\n",
|
| 259 |
+
"# Display the job description for confirmation\n",
|
| 260 |
+
"print(\"Job Description Preview (first 300 characters):\")\n",
|
| 261 |
+
"print(job_description[:300] + \"...\" if len(job_description) > 300 else job_description)\n",
|
| 262 |
+
"print(f\"\\nTotal length: {len(job_description)} characters\")"
|
| 263 |
+
]
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"cell_type": "markdown",
|
| 267 |
+
"metadata": {},
|
| 268 |
+
"source": [
|
| 269 |
+
"## 5. Analyze Job Description\n",
|
| 270 |
+
"\n",
|
| 271 |
+
"Now, let's analyze the job description to identify key requirements and skills that should be emphasized in your CV and cover letter."
|
| 272 |
+
]
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
"cell_type": "code",
|
| 276 |
+
"execution_count": 19,
|
| 277 |
+
"metadata": {},
|
| 278 |
+
"outputs": [
|
| 279 |
+
{
|
| 280 |
+
"name": "stdout",
|
| 281 |
+
"output_type": "stream",
|
| 282 |
+
"text": [
|
| 283 |
+
"Analyzing job description... (this may take a moment)\n",
|
| 284 |
+
"No analysis results returned.\n"
|
| 285 |
+
]
|
| 286 |
+
}
|
| 287 |
+
],
|
| 288 |
+
"source": [
|
| 289 |
+
"# Analyze job description\n",
|
| 290 |
+
"print(\"Analyzing job description... (this may take a moment)\")\n",
|
| 291 |
+
"analysis_result = document_updater.analyze_job_description(job_description)\n",
|
| 292 |
+
"\n",
|
| 293 |
+
"# Display analysis results\n",
|
| 294 |
+
"if \"error\" in analysis_result:\n",
|
| 295 |
+
" print(f\"Error: {analysis_result['error']}\")\n",
|
| 296 |
+
"elif \"raw_response\" in analysis_result:\n",
|
| 297 |
+
" try:\n",
|
| 298 |
+
" # Try to parse as JSON\n",
|
| 299 |
+
" suggestions = json.loads(analysis_result[\"raw_response\"])\n",
|
| 300 |
+
" \n",
|
| 301 |
+
" print(\"\\n📋 Analysis Results:\")\n",
|
| 302 |
+
" print(\"\\n🔹 Suggested Profile Summary:\")\n",
|
| 303 |
+
" print(suggestions.get(\"profile_summary\", \"No suggestions\"))\n",
|
| 304 |
+
" \n",
|
| 305 |
+
" print(\"\\n🔹 Key Skills to Emphasize:\")\n",
|
| 306 |
+
" for skill in suggestions.get(\"skills\", []):\n",
|
| 307 |
+
" print(f\"- {skill}\")\n",
|
| 308 |
+
" \n",
|
| 309 |
+
" print(\"\\n🔹 Experience Highlights:\")\n",
|
| 310 |
+
" for highlight in suggestions.get(\"experience_highlights\", []):\n",
|
| 311 |
+
" print(f\"- {highlight}\")\n",
|
| 312 |
+
" \n",
|
| 313 |
+
" print(\"\\n🔹 Keywords to Emphasize:\")\n",
|
| 314 |
+
" for keyword in suggestions.get(\"keywords_to_emphasize\", []):\n",
|
| 315 |
+
" print(f\"- {keyword}\")\n",
|
| 316 |
+
" except json.JSONDecodeError:\n",
|
| 317 |
+
" # If not valid JSON, display raw text\n",
|
| 318 |
+
" print(\"\\n📋 Analysis Results (raw):\")\n",
|
| 319 |
+
" print(analysis_result[\"raw_response\"])\n",
|
| 320 |
+
"else:\n",
|
| 321 |
+
" print(\"No analysis results returned.\")"
|
| 322 |
+
]
|
| 323 |
+
},
|
| 324 |
+
{
|
| 325 |
+
"cell_type": "markdown",
|
| 326 |
+
"metadata": {},
|
| 327 |
+
"source": [
|
| 328 |
+
"## 6. Generate Tailored Documents\n",
|
| 329 |
+
"\n",
|
| 330 |
+
"Now, let's generate tailored versions of your CV and cover letter based on the job description analysis."
|
| 331 |
+
]
|
| 332 |
+
},
|
| 333 |
+
{
|
| 334 |
+
"cell_type": "code",
|
| 335 |
+
"execution_count": null,
|
| 336 |
+
"metadata": {},
|
| 337 |
+
"outputs": [
|
| 338 |
+
{
|
| 339 |
+
"name": "stdout",
|
| 340 |
+
"output_type": "stream",
|
| 341 |
+
"text": [
|
| 342 |
+
"Generating tailored CV...\n",
|
| 343 |
+
"✅ Tailored CV saved to: Job_Application_CV_20250417_003015.docx\n",
|
| 344 |
+
"\n",
|
| 345 |
+
"Generating tailored cover letter...\n",
|
| 346 |
+
"✅ Tailored cover letter saved to: Job_Application_CoverLetter_20250417_003015.docx\n"
|
| 347 |
+
]
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"data": {
|
| 351 |
+
"text/html": [
|
| 352 |
+
"\n",
|
| 353 |
+
"<div style=\"background-color: #e6f7e6; padding: 15px; border-radius: 5px; margin-top: 20px;\">\n",
|
| 354 |
+
" <h3 style=\"color: #2e8b57;\">✅ Documents Successfully Generated!</h3>\n",
|
| 355 |
+
" <p>Your tailored documents have been saved to the following locations:</p>\n",
|
| 356 |
+
" <ul>\n",
|
| 357 |
+
" <li><strong>CV:</strong> /Users/eimon/Desktop/cv_tailoring_project_updated/output/Job_Application_CV_20250417_003015.docx</li>\n",
|
| 358 |
+
" <li><strong>Cover Letter:</strong> /Users/eimon/Desktop/cv_tailoring_project_updated/output/Job_Application_CoverLetter_20250417_003015.docx</li>\n",
|
| 359 |
+
" </ul>\n",
|
| 360 |
+
" <p>You can now review and further customize these documents before submitting your application.</p>\n",
|
| 361 |
+
"</div>\n"
|
| 362 |
+
],
|
| 363 |
+
"text/plain": [
|
| 364 |
+
"<IPython.core.display.HTML object>"
|
| 365 |
+
]
|
| 366 |
+
},
|
| 367 |
+
"metadata": {},
|
| 368 |
+
"output_type": "display_data"
|
| 369 |
+
}
|
| 370 |
+
],
|
| 371 |
+
"source": [
|
| 372 |
+
"# Generate a timestamp for the output files\n",
|
| 373 |
+
"timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n",
|
| 374 |
+
"job_title = \"Job_Application\" # You can customize this\n",
|
| 375 |
+
"\n",
|
| 376 |
+
"# Define output paths\n",
|
| 377 |
+
"tailored_cv_path = os.path.join(OUTPUT_DIR, f\"{job_title}_CV_{timestamp}.docx\")\n",
|
| 378 |
+
"tailored_cover_letter_path = os.path.join(OUTPUT_DIR, f\"{job_title}_CoverLetter_{timestamp}.docx\")\n",
|
| 379 |
+
"\n",
|
| 380 |
+
"# Generate tailored CV\n",
|
| 381 |
+
"print(\"Generating tailored CV...\")\n",
|
| 382 |
+
"cv_result = document_updater.update_cv(job_description, tailored_cv_path)\n",
|
| 383 |
+
"print(f\"✅ Tailored CV saved to: {os.path.basename(cv_result)}\")\n",
|
| 384 |
+
"\n",
|
| 385 |
+
"# Generate tailored cover letter\n",
|
| 386 |
+
"print(\"\\nGenerating tailored cover letter...\")\n",
|
| 387 |
+
"cover_letter_result = document_updater.update_cover_letter(job_description, tailored_cover_letter_path)\n",
|
| 388 |
+
"print(f\"✅ Tailored cover letter saved to: {os.path.basename(cover_letter_result)}\")\n",
|
| 389 |
+
"\n",
|
| 390 |
+
"# Display success message with file paths\n",
|
| 391 |
+
"print(\"\\n✅ Documents Successfully Generated!\")\n",
|
| 392 |
+
"print(\"Your tailored documents have been saved to:\")\n",
|
| 393 |
+
"print(f\"- CV: {tailored_cv_path}\")\n",
|
| 394 |
+
"print(f\"- Cover Letter: {tailored_cover_letter_path}\")\n",
|
| 395 |
+
"print(\"You can now review and further customize these documents before submitting your application.\")"
|
| 396 |
+
]
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"cell_type": "markdown",
|
| 400 |
+
"metadata": {},
|
| 401 |
+
"source": [
|
| 402 |
+
"## 7. (next feature)Customization Options\n",
|
| 403 |
+
"\n",
|
| 404 |
+
"You can customize various aspects of the tailoring process by modifying the parameters below:"
|
| 405 |
+
]
|
| 406 |
+
},
|
| 407 |
+
{
|
| 408 |
+
"cell_type": "code",
|
| 409 |
+
"execution_count": 21,
|
| 410 |
+
"metadata": {},
|
| 411 |
+
"outputs": [],
|
| 412 |
+
"source": [
|
| 413 |
+
"# OpenAI model options\n",
|
| 414 |
+
"def change_openai_model(model_name=\"gpt-3.5-turbo\"):\n",
|
| 415 |
+
" \"\"\"Change the OpenAI model used for analysis and generation.\n",
|
| 416 |
+
" \n",
|
| 417 |
+
" Args:\n",
|
| 418 |
+
" model_name: Name of the OpenAI model to use (e.g., \"gpt-3.5-turbo\", \"gpt-4\")\n",
|
| 419 |
+
" \"\"\"\n",
|
| 420 |
+
" # This would require modifying the OpenAIIntegration class to accept model name as a parameter\n",
|
| 421 |
+
" print(f\"Model changed to: {model_name}\")\n",
|
| 422 |
+
" print(\"Note: This functionality requires updating the OpenAIIntegration class.\")\n",
|
| 423 |
+
"\n",
|
| 424 |
+
"# Example usage:\n",
|
| 425 |
+
"# change_openai_model(\"gpt-4\")"
|
| 426 |
+
]
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"cell_type": "markdown",
|
| 430 |
+
"metadata": {},
|
| 431 |
+
"source": [
|
| 432 |
+
" ## 8. (next feature)Process Multiple Job Applications\n",
|
| 433 |
+
"\n",
|
| 434 |
+
"If you want to apply for multiple positions, you can use the function below to process multiple job descriptions in batch:"
|
| 435 |
+
]
|
| 436 |
+
},
|
| 437 |
+
{
|
| 438 |
+
"cell_type": "code",
|
| 439 |
+
"execution_count": 22,
|
| 440 |
+
"metadata": {},
|
| 441 |
+
"outputs": [
|
| 442 |
+
{
|
| 443 |
+
"data": {
|
| 444 |
+
"text/plain": [
|
| 445 |
+
"'\\njob_descriptions = [\\n \"First job description...\",\\n \"Second job description...\"\\n]\\n\\njob_titles = [\\n \"Data_Analyst\",\\n \"Software_Engineer\"\\n]\\n\\nresults = process_multiple_jobs(job_descriptions, job_titles)\\n'"
|
| 446 |
+
]
|
| 447 |
+
},
|
| 448 |
+
"execution_count": 22,
|
| 449 |
+
"metadata": {},
|
| 450 |
+
"output_type": "execute_result"
|
| 451 |
+
}
|
| 452 |
+
],
|
| 453 |
+
"source": [
|
| 454 |
+
"def process_multiple_jobs(job_descriptions, job_titles):\n",
|
| 455 |
+
" \"\"\"Process multiple job applications at once.\n",
|
| 456 |
+
" \n",
|
| 457 |
+
" Args:\n",
|
| 458 |
+
" job_descriptions: List of job description texts\n",
|
| 459 |
+
" job_titles: List of job titles (used for file naming)\n",
|
| 460 |
+
" \n",
|
| 461 |
+
" Returns:\n",
|
| 462 |
+
" Dictionary mapping job titles to output file paths\n",
|
| 463 |
+
" \"\"\"\n",
|
| 464 |
+
" results = {}\n",
|
| 465 |
+
" \n",
|
| 466 |
+
" for i, (description, title) in enumerate(zip(job_descriptions, job_titles)):\n",
|
| 467 |
+
" print(f\"Processing job {i+1}/{len(job_descriptions)}: {title}\")\n",
|
| 468 |
+
" \n",
|
| 469 |
+
" # Generate timestamp\n",
|
| 470 |
+
" timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n",
|
| 471 |
+
" \n",
|
| 472 |
+
" # Define output paths\n",
|
| 473 |
+
" cv_path = os.path.join(OUTPUT_DIR, f\"{title.replace(' ', '_')}_CV_{timestamp}.docx\")\n",
|
| 474 |
+
" cover_letter_path = os.path.join(OUTPUT_DIR, f\"{title.replace(' ', '_')}_CoverLetter_{timestamp}.docx\")\n",
|
| 475 |
+
" \n",
|
| 476 |
+
" # Generate tailored documents\n",
|
| 477 |
+
" cv_result = document_updater.update_cv(description, cv_path)\n",
|
| 478 |
+
" cover_letter_result = document_updater.update_cover_letter(description, cover_letter_path)\n",
|
| 479 |
+
" \n",
|
| 480 |
+
" results[title] = {\n",
|
| 481 |
+
" \"cv\": cv_result,\n",
|
| 482 |
+
" \"cover_letter\": cover_letter_result\n",
|
| 483 |
+
" }\n",
|
| 484 |
+
" \n",
|
| 485 |
+
" print(f\"✅ Completed: {title}\\n\")\n",
|
| 486 |
+
" \n",
|
| 487 |
+
" return results\n",
|
| 488 |
+
"\n",
|
| 489 |
+
"# Example usage:\n",
|
| 490 |
+
"'''\n",
|
| 491 |
+
"job_descriptions = [\n",
|
| 492 |
+
" \"First job description...\",\n",
|
| 493 |
+
" \"Second job description...\"\n",
|
| 494 |
+
"]\n",
|
| 495 |
+
"\n",
|
| 496 |
+
"job_titles = [\n",
|
| 497 |
+
" \"Data_Analyst\",\n",
|
| 498 |
+
" \"Software_Engineer\"\n",
|
| 499 |
+
"]\n",
|
| 500 |
+
"\n",
|
| 501 |
+
"results = process_multiple_jobs(job_descriptions, job_titles)\n",
|
| 502 |
+
"'''"
|
| 503 |
+
]
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"cell_type": "markdown",
|
| 507 |
+
"metadata": {},
|
| 508 |
+
"source": []
|
| 509 |
+
}
|
| 510 |
+
],
|
| 511 |
+
"metadata": {
|
| 512 |
+
"kernelspec": {
|
| 513 |
+
"display_name": "CV_R",
|
| 514 |
+
"language": "python",
|
| 515 |
+
"name": "python3"
|
| 516 |
+
},
|
| 517 |
+
"language_info": {
|
| 518 |
+
"codemirror_mode": {
|
| 519 |
+
"name": "ipython",
|
| 520 |
+
"version": 3
|
| 521 |
+
},
|
| 522 |
+
"file_extension": ".py",
|
| 523 |
+
"mimetype": "text/x-python",
|
| 524 |
+
"name": "python",
|
| 525 |
+
"nbconvert_exporter": "python",
|
| 526 |
+
"pygments_lexer": "ipython3",
|
| 527 |
+
"version": "3.11.11"
|
| 528 |
+
}
|
| 529 |
+
},
|
| 530 |
+
"nbformat": 4,
|
| 531 |
+
"nbformat_minor": 4
|
| 532 |
+
}
|
Automatic CV and Cover Letter with API/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai>=1.0.0
|
| 2 |
+
python-docx>=0.8.11
|
| 3 |
+
beautifulsoup4>=4.9.3
|
| 4 |
+
requests>=2.25.1
|
| 5 |
+
python-dotenv>=0.19.0
|
Automatic CV and Cover Letter with API/src/parsers/document_parser.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Document parser module for extracting content from Word documents.
|
| 3 |
+
This module provides functionality to parse CV and cover letter documents.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import docx
|
| 7 |
+
from docx import Document
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
from typing import Dict, List, Tuple, Any
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class DocumentParser:
|
| 14 |
+
"""Base class for document parsing."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, file_path: str):
|
| 17 |
+
"""Initialize with document file path.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
file_path: Path to the Word document
|
| 21 |
+
"""
|
| 22 |
+
self.file_path = file_path
|
| 23 |
+
self.document = Document(file_path)
|
| 24 |
+
|
| 25 |
+
def get_all_text(self) -> str:
|
| 26 |
+
"""Extract all text from the document.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
String containing all text from the document
|
| 30 |
+
"""
|
| 31 |
+
return "\n".join([para.text for para in self.document.paragraphs if para.text.strip()])
|
| 32 |
+
|
| 33 |
+
def save_document(self, output_path: str) -> None:
|
| 34 |
+
"""Save the document to a new file.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
output_path: Path where the document should be saved
|
| 38 |
+
"""
|
| 39 |
+
self.document.save(output_path)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class CVParser(DocumentParser):
|
| 43 |
+
"""Parser specifically for CV/Resume documents."""
|
| 44 |
+
|
| 45 |
+
def __init__(self, file_path: str):
|
| 46 |
+
"""Initialize CV parser.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
file_path: Path to the CV document
|
| 50 |
+
"""
|
| 51 |
+
super().__init__(file_path)
|
| 52 |
+
self.sections = self._extract_sections()
|
| 53 |
+
|
| 54 |
+
def _extract_sections(self) -> Dict[str, List[str]]:
|
| 55 |
+
"""Extract different sections from the CV.
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
Dictionary with section names as keys and content as values
|
| 59 |
+
"""
|
| 60 |
+
sections = {}
|
| 61 |
+
current_section = "HEADER"
|
| 62 |
+
sections[current_section] = []
|
| 63 |
+
|
| 64 |
+
for para in self.document.paragraphs:
|
| 65 |
+
text = para.text.strip()
|
| 66 |
+
if not text:
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
# Check if this is a section header (all caps, short text)
|
| 70 |
+
if text.isupper() and len(text) < 30 and text not in ["HEADER"]:
|
| 71 |
+
current_section = text
|
| 72 |
+
sections[current_section] = []
|
| 73 |
+
else:
|
| 74 |
+
sections[current_section].append(text)
|
| 75 |
+
|
| 76 |
+
return sections
|
| 77 |
+
|
| 78 |
+
def get_personal_info(self) -> Dict[str, str]:
|
| 79 |
+
"""Extract personal information from the CV.
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Dictionary containing personal information
|
| 83 |
+
"""
|
| 84 |
+
personal_info = {}
|
| 85 |
+
|
| 86 |
+
if "HEADER" in self.sections and self.sections["HEADER"]:
|
| 87 |
+
# First line is typically the name
|
| 88 |
+
if len(self.sections["HEADER"]) > 0:
|
| 89 |
+
personal_info["name"] = self.sections["HEADER"][0]
|
| 90 |
+
|
| 91 |
+
# Second line typically contains contact information
|
| 92 |
+
if len(self.sections["HEADER"]) > 1:
|
| 93 |
+
contact_line = self.sections["HEADER"][1]
|
| 94 |
+
|
| 95 |
+
# Extract email using regex
|
| 96 |
+
email_match = re.search(r'[\w.+-]+@[\w-]+\.[\w.-]+', contact_line)
|
| 97 |
+
if email_match:
|
| 98 |
+
personal_info["email"] = email_match.group(0)
|
| 99 |
+
|
| 100 |
+
# Extract phone number
|
| 101 |
+
phone_match = re.search(r'[\+\d][\d\s\(\)-]{8,}', contact_line)
|
| 102 |
+
if phone_match:
|
| 103 |
+
personal_info["phone"] = phone_match.group(0)
|
| 104 |
+
|
| 105 |
+
# Extract LinkedIn
|
| 106 |
+
linkedin_match = re.search(r'linkedin\.com/\S+', contact_line)
|
| 107 |
+
if linkedin_match:
|
| 108 |
+
personal_info["linkedin"] = linkedin_match.group(0)
|
| 109 |
+
|
| 110 |
+
return personal_info
|
| 111 |
+
|
| 112 |
+
def get_profile_summary(self) -> str:
|
| 113 |
+
"""Extract profile summary from the CV.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
String containing the profile summary
|
| 117 |
+
"""
|
| 118 |
+
if "PROFILE" in self.sections:
|
| 119 |
+
return " ".join(self.sections["PROFILE"])
|
| 120 |
+
return ""
|
| 121 |
+
|
| 122 |
+
def get_education(self) -> List[str]:
|
| 123 |
+
"""Extract education information.
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
List of education entries
|
| 127 |
+
"""
|
| 128 |
+
if "EDUCATION" in self.sections:
|
| 129 |
+
return self.sections["EDUCATION"]
|
| 130 |
+
return []
|
| 131 |
+
|
| 132 |
+
def get_experience(self) -> List[str]:
|
| 133 |
+
"""Extract work experience information.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
List of experience entries
|
| 137 |
+
"""
|
| 138 |
+
if "EXPERIENCE" in self.sections:
|
| 139 |
+
return self.sections["EXPERIENCE"]
|
| 140 |
+
elif "WORK EXPERIENCE" in self.sections:
|
| 141 |
+
return self.sections["WORK EXPERIENCE"]
|
| 142 |
+
return []
|
| 143 |
+
|
| 144 |
+
def get_skills(self) -> List[str]:
|
| 145 |
+
"""Extract skills information.
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
List of skills entries
|
| 149 |
+
"""
|
| 150 |
+
if "SKILLS" in self.sections:
|
| 151 |
+
return self.sections["SKILLS"]
|
| 152 |
+
elif "TECHNICAL SKILLS" in self.sections:
|
| 153 |
+
return self.sections["TECHNICAL SKILLS"]
|
| 154 |
+
return []
|
| 155 |
+
|
| 156 |
+
def get_all_sections(self) -> Dict[str, List[str]]:
|
| 157 |
+
"""Get all extracted sections.
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
Dictionary with all sections
|
| 161 |
+
"""
|
| 162 |
+
return self.sections
|
| 163 |
+
|
| 164 |
+
def update_section(self, section_name: str, new_content: List[str]) -> None:
|
| 165 |
+
"""Update a specific section with new content.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
section_name: Name of the section to update
|
| 169 |
+
new_content: New content for the section
|
| 170 |
+
"""
|
| 171 |
+
if section_name in self.sections:
|
| 172 |
+
# Find the paragraphs that belong to this section
|
| 173 |
+
section_start = None
|
| 174 |
+
section_end = None
|
| 175 |
+
|
| 176 |
+
for i, para in enumerate(self.document.paragraphs):
|
| 177 |
+
if para.text.strip() == section_name:
|
| 178 |
+
section_start = i
|
| 179 |
+
break
|
| 180 |
+
|
| 181 |
+
if section_start is not None:
|
| 182 |
+
# Find the end of this section (next section header or end of document)
|
| 183 |
+
for i in range(section_start + 1, len(self.document.paragraphs)):
|
| 184 |
+
if self.document.paragraphs[i].text.strip().isupper() and len(self.document.paragraphs[i].text.strip()) < 30:
|
| 185 |
+
section_end = i
|
| 186 |
+
break
|
| 187 |
+
|
| 188 |
+
if section_end is None:
|
| 189 |
+
section_end = len(self.document.paragraphs)
|
| 190 |
+
|
| 191 |
+
# Clear existing paragraphs in this section
|
| 192 |
+
for i in range(section_start + 1, section_end):
|
| 193 |
+
if self.document.paragraphs[i].text.strip():
|
| 194 |
+
self.document.paragraphs[i].clear()
|
| 195 |
+
|
| 196 |
+
# Add new content
|
| 197 |
+
# This is a simplified approach - in a real implementation, you'd need to handle
|
| 198 |
+
# formatting, styles, and potentially add new paragraphs if needed
|
| 199 |
+
current_para = section_start + 1
|
| 200 |
+
for content in new_content:
|
| 201 |
+
if current_para < section_end:
|
| 202 |
+
self.document.paragraphs[current_para].add_run(content)
|
| 203 |
+
current_para += 1
|
| 204 |
+
else:
|
| 205 |
+
# Would need to insert new paragraphs here
|
| 206 |
+
# This is simplified for demonstration
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class CoverLetterParser(DocumentParser):
|
| 211 |
+
"""Parser specifically for cover letter documents."""
|
| 212 |
+
|
| 213 |
+
def __init__(self, file_path: str):
|
| 214 |
+
"""Initialize cover letter parser.
|
| 215 |
+
|
| 216 |
+
Args:
|
| 217 |
+
file_path: Path to the cover letter document
|
| 218 |
+
"""
|
| 219 |
+
super().__init__(file_path)
|
| 220 |
+
self.sections = self._extract_sections()
|
| 221 |
+
|
| 222 |
+
def _extract_sections(self) -> Dict[str, str]:
|
| 223 |
+
"""Extract different parts of the cover letter.
|
| 224 |
+
|
| 225 |
+
Returns:
|
| 226 |
+
Dictionary with section names as keys and content as values
|
| 227 |
+
"""
|
| 228 |
+
sections = {
|
| 229 |
+
"header": "",
|
| 230 |
+
"greeting": "",
|
| 231 |
+
"body": "",
|
| 232 |
+
"closing": ""
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
# Simple approach based on paragraph position
|
| 236 |
+
# In a real implementation, you'd want more sophisticated parsing
|
| 237 |
+
non_empty_paragraphs = [p for p in self.document.paragraphs if p.text.strip()]
|
| 238 |
+
|
| 239 |
+
if len(non_empty_paragraphs) >= 1:
|
| 240 |
+
sections["header"] = non_empty_paragraphs[0].text
|
| 241 |
+
|
| 242 |
+
if len(non_empty_paragraphs) >= 2:
|
| 243 |
+
# Assuming the second paragraph might be a greeting
|
| 244 |
+
if "Dear" in non_empty_paragraphs[1].text or "To" in non_empty_paragraphs[1].text:
|
| 245 |
+
sections["greeting"] = non_empty_paragraphs[1].text
|
| 246 |
+
body_start = 2
|
| 247 |
+
else:
|
| 248 |
+
body_start = 1
|
| 249 |
+
|
| 250 |
+
# Assuming the last paragraph is the closing
|
| 251 |
+
if len(non_empty_paragraphs) > body_start:
|
| 252 |
+
body_paragraphs = non_empty_paragraphs[body_start:-1] if len(non_empty_paragraphs) > body_start + 1 else []
|
| 253 |
+
sections["body"] = "\n".join([p.text for p in body_paragraphs])
|
| 254 |
+
sections["closing"] = non_empty_paragraphs[-1].text
|
| 255 |
+
|
| 256 |
+
return sections
|
| 257 |
+
|
| 258 |
+
def get_header(self) -> str:
|
| 259 |
+
"""Get the header section of the cover letter.
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
String containing the header
|
| 263 |
+
"""
|
| 264 |
+
return self.sections.get("header", "")
|
| 265 |
+
|
| 266 |
+
def get_greeting(self) -> str:
|
| 267 |
+
"""Get the greeting section of the cover letter.
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
String containing the greeting
|
| 271 |
+
"""
|
| 272 |
+
return self.sections.get("greeting", "")
|
| 273 |
+
|
| 274 |
+
def get_body(self) -> str:
|
| 275 |
+
"""Get the main body of the cover letter.
|
| 276 |
+
|
| 277 |
+
Returns:
|
| 278 |
+
String containing the body
|
| 279 |
+
"""
|
| 280 |
+
return self.sections.get("body", "")
|
| 281 |
+
|
| 282 |
+
def get_closing(self) -> str:
|
| 283 |
+
"""Get the closing section of the cover letter.
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
String containing the closing
|
| 287 |
+
"""
|
| 288 |
+
return self.sections.get("closing", "")
|
| 289 |
+
|
| 290 |
+
def update_body(self, new_body: str) -> None:
|
| 291 |
+
"""Update the body of the cover letter.
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
new_body: New content for the body section
|
| 295 |
+
"""
|
| 296 |
+
# Find paragraphs that contain the body
|
| 297 |
+
non_empty_paragraphs = [i for i, p in enumerate(self.document.paragraphs) if p.text.strip()]
|
| 298 |
+
|
| 299 |
+
if len(non_empty_paragraphs) >= 3:
|
| 300 |
+
# Assuming body starts at the third paragraph
|
| 301 |
+
body_start = non_empty_paragraphs[2]
|
| 302 |
+
|
| 303 |
+
# Assuming body ends before the last paragraph
|
| 304 |
+
if len(non_empty_paragraphs) > 3:
|
| 305 |
+
body_end = non_empty_paragraphs[-2]
|
| 306 |
+
else:
|
| 307 |
+
body_end = non_empty_paragraphs[-1]
|
| 308 |
+
|
| 309 |
+
# Clear existing body paragraphs
|
| 310 |
+
for i in range(body_start, body_end + 1):
|
| 311 |
+
self.document.paragraphs[i].clear()
|
| 312 |
+
|
| 313 |
+
# Add new body content to the first body paragraph
|
| 314 |
+
self.document.paragraphs[body_start].add_run(new_body)
|
Automatic CV and Cover Letter with API/src/updaters/document_updater.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Document updater module for tailoring CV and cover letter based on job descriptions.
|
| 3 |
+
This module combines document parsing and OpenAI integration to create tailored documents.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import docx
|
| 9 |
+
from docx import Document
|
| 10 |
+
from typing import Dict, List, Tuple, Any, Optional
|
| 11 |
+
import sys
|
| 12 |
+
import re
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
# Add the project root to the path to import our modules
|
| 16 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 17 |
+
|
| 18 |
+
from parsers.document_parser import CVParser, CoverLetterParser
|
| 19 |
+
from utils.openai_integration import OpenAIIntegration
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class DocumentUpdater:
|
| 23 |
+
"""Class for updating CV and cover letter documents based on job descriptions."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, cv_path: str, cover_letter_path: str, openai_integration: OpenAIIntegration):
|
| 26 |
+
"""Initialize document updater.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
cv_path: Path to the CV document
|
| 30 |
+
cover_letter_path: Path to the cover letter document
|
| 31 |
+
openai_integration: Initialized OpenAIIntegration instance
|
| 32 |
+
"""
|
| 33 |
+
if not os.path.exists(cv_path):
|
| 34 |
+
raise FileNotFoundError(f"CV file not found: {cv_path}")
|
| 35 |
+
if not os.path.exists(cover_letter_path):
|
| 36 |
+
raise FileNotFoundError(f"Cover letter file not found: {cover_letter_path}")
|
| 37 |
+
|
| 38 |
+
self.cv_parser = CVParser(cv_path)
|
| 39 |
+
self.cover_letter_parser = CoverLetterParser(cover_letter_path)
|
| 40 |
+
self.openai_integration = openai_integration
|
| 41 |
+
self.cv_path = cv_path
|
| 42 |
+
self.cover_letter_path = cover_letter_path
|
| 43 |
+
|
| 44 |
+
def analyze_job_description(self, job_description: str) -> Dict[str, Any]:
|
| 45 |
+
"""Analyze job description and get tailoring suggestions.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
job_description: Text of the job description
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Dictionary with analysis results and suggestions
|
| 52 |
+
"""
|
| 53 |
+
# Get current CV content
|
| 54 |
+
cv_content = self.cv_parser.get_all_text()
|
| 55 |
+
|
| 56 |
+
# Use OpenAI to analyze job description and suggest modifications
|
| 57 |
+
analysis_result = self.openai_integration.analyze_job_description(job_description, cv_content)
|
| 58 |
+
|
| 59 |
+
return analysis_result
|
| 60 |
+
|
| 61 |
+
def update_cv(self, job_description: str, output_path: str) -> str:
|
| 62 |
+
"""Create a tailored CV based on job description.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
job_description: Text of the job description
|
| 66 |
+
output_path: Path where the tailored CV should be saved
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Path to the tailored CV document
|
| 70 |
+
"""
|
| 71 |
+
try:
|
| 72 |
+
# Analyze job description
|
| 73 |
+
analysis = self.analyze_job_description(job_description)
|
| 74 |
+
|
| 75 |
+
if "error" in analysis:
|
| 76 |
+
raise ValueError(f"Error analyzing job description: {analysis['error']}")
|
| 77 |
+
|
| 78 |
+
# Parse the raw response if it exists
|
| 79 |
+
suggestions = {}
|
| 80 |
+
if "raw_response" in analysis:
|
| 81 |
+
try:
|
| 82 |
+
# Try to parse as JSON
|
| 83 |
+
suggestions = json.loads(analysis["raw_response"])
|
| 84 |
+
except json.JSONDecodeError:
|
| 85 |
+
# If not valid JSON, extract using regex
|
| 86 |
+
suggestions = self._extract_suggestions_from_text(analysis["raw_response"])
|
| 87 |
+
else:
|
| 88 |
+
suggestions = analysis # Already parsed JSON
|
| 89 |
+
|
| 90 |
+
# Create output directory if it doesn't exist
|
| 91 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
| 92 |
+
|
| 93 |
+
# Create a new document by copying the original
|
| 94 |
+
new_doc = Document(self.cv_path)
|
| 95 |
+
|
| 96 |
+
# Update profile summary if suggested
|
| 97 |
+
if "profile_summary" in suggestions and suggestions["profile_summary"]:
|
| 98 |
+
self._update_profile_summary(new_doc, suggestions["profile_summary"])
|
| 99 |
+
|
| 100 |
+
# Update skills if suggested
|
| 101 |
+
if "skills" in suggestions and suggestions["skills"]:
|
| 102 |
+
self._update_skills(new_doc, suggestions["skills"])
|
| 103 |
+
|
| 104 |
+
# Update experience highlights if suggested
|
| 105 |
+
if "experience_highlights" in suggestions and suggestions["experience_highlights"]:
|
| 106 |
+
self._update_experience(new_doc, suggestions["experience_highlights"])
|
| 107 |
+
|
| 108 |
+
# Save the updated document
|
| 109 |
+
new_doc.save(output_path)
|
| 110 |
+
|
| 111 |
+
return output_path
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
raise Exception(f"Error updating CV: {str(e)}")
|
| 115 |
+
|
| 116 |
+
def update_cover_letter(self, job_description: str, output_path: str) -> str:
|
| 117 |
+
"""Create a tailored cover letter based on job description.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
job_description: Text of the job description
|
| 121 |
+
output_path: Path where the tailored cover letter should be saved
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Path to the tailored cover letter document
|
| 125 |
+
"""
|
| 126 |
+
try:
|
| 127 |
+
# Get current cover letter and CV content
|
| 128 |
+
cover_letter_content = self.cover_letter_parser.get_all_text()
|
| 129 |
+
cv_content = self.cv_parser.get_all_text()
|
| 130 |
+
|
| 131 |
+
# Use OpenAI to generate tailored cover letter body
|
| 132 |
+
tailored_body = self.openai_integration.tailor_cover_letter(
|
| 133 |
+
job_description, cover_letter_content, cv_content
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
if tailored_body.startswith("Error generating cover letter:"):
|
| 137 |
+
raise ValueError(tailored_body)
|
| 138 |
+
|
| 139 |
+
# Create output directory if it doesn't exist
|
| 140 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
| 141 |
+
|
| 142 |
+
# Create a new document by copying the original
|
| 143 |
+
new_doc = Document(self.cover_letter_path)
|
| 144 |
+
|
| 145 |
+
# Update the body of the cover letter
|
| 146 |
+
self._update_cover_letter_body(new_doc, tailored_body)
|
| 147 |
+
|
| 148 |
+
# Save the updated document
|
| 149 |
+
new_doc.save(output_path)
|
| 150 |
+
|
| 151 |
+
return output_path
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
raise Exception(f"Error updating cover letter: {str(e)}")
|
| 155 |
+
|
| 156 |
+
def _extract_suggestions_from_text(self, text: str) -> Dict[str, Any]:
|
| 157 |
+
"""Extract suggestions from text when JSON parsing fails.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
text: Raw text response from OpenAI
|
| 161 |
+
|
| 162 |
+
Returns:
|
| 163 |
+
Dictionary with extracted suggestions
|
| 164 |
+
"""
|
| 165 |
+
suggestions = {
|
| 166 |
+
"profile_summary": "",
|
| 167 |
+
"skills": [],
|
| 168 |
+
"experience_highlights": [],
|
| 169 |
+
"keywords_to_emphasize": []
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
# Extract profile summary
|
| 173 |
+
profile_match = re.search(r'"profile_summary":\s*"([^"]+)"', text)
|
| 174 |
+
if profile_match:
|
| 175 |
+
suggestions["profile_summary"] = profile_match.group(1)
|
| 176 |
+
|
| 177 |
+
# Extract skills
|
| 178 |
+
skills_match = re.search(r'"skills":\s*\[(.*?)\]', text, re.DOTALL)
|
| 179 |
+
if skills_match:
|
| 180 |
+
skills_text = skills_match.group(1)
|
| 181 |
+
skills = re.findall(r'"([^"]+)"', skills_text)
|
| 182 |
+
suggestions["skills"] = skills
|
| 183 |
+
|
| 184 |
+
# Extract experience highlights
|
| 185 |
+
exp_match = re.search(r'"experience_highlights":\s*\[(.*?)\]', text, re.DOTALL)
|
| 186 |
+
if exp_match:
|
| 187 |
+
exp_text = exp_match.group(1)
|
| 188 |
+
experiences = re.findall(r'"([^"]+)"', exp_text)
|
| 189 |
+
suggestions["experience_highlights"] = experiences
|
| 190 |
+
|
| 191 |
+
# Extract keywords
|
| 192 |
+
keywords_match = re.search(r'"keywords_to_emphasize":\s*\[(.*?)\]', text, re.DOTALL)
|
| 193 |
+
if keywords_match:
|
| 194 |
+
keywords_text = keywords_match.group(1)
|
| 195 |
+
keywords = re.findall(r'"([^"]+)"', keywords_text)
|
| 196 |
+
suggestions["keywords_to_emphasize"] = keywords
|
| 197 |
+
|
| 198 |
+
return suggestions
|
| 199 |
+
|
| 200 |
+
def _update_profile_summary(self, doc: Document, new_summary: str) -> None:
|
| 201 |
+
"""Update the profile summary in the document.
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
doc: Document object to update
|
| 205 |
+
new_summary: New profile summary text
|
| 206 |
+
"""
|
| 207 |
+
# Find the PROFILE section
|
| 208 |
+
profile_index = None
|
| 209 |
+
for i, para in enumerate(doc.paragraphs):
|
| 210 |
+
if para.text.strip() == "PROFILE":
|
| 211 |
+
profile_index = i
|
| 212 |
+
break
|
| 213 |
+
|
| 214 |
+
if profile_index is not None:
|
| 215 |
+
# Clear existing profile paragraphs
|
| 216 |
+
next_section_index = None
|
| 217 |
+
for i in range(profile_index + 1, len(doc.paragraphs)):
|
| 218 |
+
if doc.paragraphs[i].text.strip().isupper() and len(doc.paragraphs[i].text.strip()) < 30:
|
| 219 |
+
next_section_index = i
|
| 220 |
+
break
|
| 221 |
+
|
| 222 |
+
if next_section_index is None:
|
| 223 |
+
next_section_index = len(doc.paragraphs)
|
| 224 |
+
|
| 225 |
+
# Update the first paragraph after PROFILE
|
| 226 |
+
if profile_index + 1 < len(doc.paragraphs):
|
| 227 |
+
# Clear existing text
|
| 228 |
+
for i in range(profile_index + 1, next_section_index):
|
| 229 |
+
doc.paragraphs[i].clear()
|
| 230 |
+
|
| 231 |
+
# Add new summary to the first paragraph after PROFILE
|
| 232 |
+
doc.paragraphs[profile_index + 1].add_run(new_summary)
|
| 233 |
+
|
| 234 |
+
def _update_skills(self, doc: Document, new_skills: List[str]) -> None:
|
| 235 |
+
"""Update the skills section in the document.
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
doc: Document object to update
|
| 239 |
+
new_skills: List of new skills to include
|
| 240 |
+
"""
|
| 241 |
+
# Find the SKILLS section (could be SKILLS or TECHNICAL SKILLS)
|
| 242 |
+
skills_index = None
|
| 243 |
+
for i, para in enumerate(doc.paragraphs):
|
| 244 |
+
if para.text.strip() in ["SKILLS", "TECHNICAL SKILLS"]:
|
| 245 |
+
skills_index = i
|
| 246 |
+
break
|
| 247 |
+
|
| 248 |
+
if skills_index is not None:
|
| 249 |
+
# Clear existing skills paragraphs
|
| 250 |
+
next_section_index = None
|
| 251 |
+
for i in range(skills_index + 1, len(doc.paragraphs)):
|
| 252 |
+
if doc.paragraphs[i].text.strip().isupper() and len(doc.paragraphs[i].text.strip()) < 30:
|
| 253 |
+
next_section_index = i
|
| 254 |
+
break
|
| 255 |
+
|
| 256 |
+
if next_section_index is None:
|
| 257 |
+
next_section_index = len(doc.paragraphs)
|
| 258 |
+
|
| 259 |
+
# Update the first paragraph after SKILLS
|
| 260 |
+
if skills_index + 1 < len(doc.paragraphs):
|
| 261 |
+
# Clear existing text
|
| 262 |
+
for i in range(skills_index + 1, next_section_index):
|
| 263 |
+
doc.paragraphs[i].clear()
|
| 264 |
+
|
| 265 |
+
# Add new skills to the first paragraph after SKILLS
|
| 266 |
+
skills_text = ", ".join(new_skills)
|
| 267 |
+
doc.paragraphs[skills_index + 1].add_run(skills_text)
|
| 268 |
+
|
| 269 |
+
def _update_experience(self, doc: Document, new_highlights: List[str]) -> None:
|
| 270 |
+
"""Update the experience section in the document to emphasize certain points.
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
doc: Document object to update
|
| 274 |
+
new_highlights: List of experience highlights to emphasize
|
| 275 |
+
"""
|
| 276 |
+
# This is a simplified implementation
|
| 277 |
+
# In a real implementation, you would need to carefully modify the experience section
|
| 278 |
+
# while preserving formatting and structure
|
| 279 |
+
|
| 280 |
+
# Find the EXPERIENCE section (could be EXPERIENCE or WORK EXPERIENCE)
|
| 281 |
+
experience_index = None
|
| 282 |
+
for i, para in enumerate(doc.paragraphs):
|
| 283 |
+
if para.text.strip() in ["EXPERIENCE", "WORK EXPERIENCE"]:
|
| 284 |
+
experience_index = i
|
| 285 |
+
break
|
| 286 |
+
|
| 287 |
+
if experience_index is not None:
|
| 288 |
+
# Add a note about the highlights at the beginning of the section
|
| 289 |
+
if experience_index + 1 < len(doc.paragraphs):
|
| 290 |
+
highlight_note = "Key highlights relevant to this position: " + "; ".join(new_highlights)
|
| 291 |
+
|
| 292 |
+
# Insert a new paragraph for the highlights
|
| 293 |
+
p = doc.paragraphs[experience_index]
|
| 294 |
+
run = p.add_run()
|
| 295 |
+
run.add_break()
|
| 296 |
+
run.add_text(highlight_note)
|
| 297 |
+
|
| 298 |
+
def _update_cover_letter_body(self, doc: Document, new_body: str) -> None:
|
| 299 |
+
"""Update the body of the cover letter with new content while preserving formatting.
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
doc: Document object to update
|
| 303 |
+
new_body: New body text for the cover letter
|
| 304 |
+
"""
|
| 305 |
+
# Find the start of the body (after greeting) and end (before closing)
|
| 306 |
+
body_start = None
|
| 307 |
+
body_end = None
|
| 308 |
+
|
| 309 |
+
# Store paragraph formatting for reuse
|
| 310 |
+
format_info = []
|
| 311 |
+
|
| 312 |
+
# First pass: identify body section and store formatting
|
| 313 |
+
for i, para in enumerate(doc.paragraphs):
|
| 314 |
+
text = para.text.strip()
|
| 315 |
+
|
| 316 |
+
# Store formatting information for each paragraph
|
| 317 |
+
format_info.append({
|
| 318 |
+
'style': para.style,
|
| 319 |
+
'alignment': para.alignment,
|
| 320 |
+
'runs': [(run.bold, run.italic, run.underline, run.font.name, run.font.size)
|
| 321 |
+
for run in para.runs]
|
| 322 |
+
})
|
| 323 |
+
|
| 324 |
+
# Skip empty paragraphs but preserve them
|
| 325 |
+
if not text:
|
| 326 |
+
continue
|
| 327 |
+
|
| 328 |
+
# Look for greeting more carefully
|
| 329 |
+
if any(text.startswith(greeting) for greeting in [
|
| 330 |
+
"Dear ", "To ", "Hi ", "Hello ", "Dear Sir", "Dear Madam",
|
| 331 |
+
"Dear Hiring", "Dear Recruitment", "Dear HR"
|
| 332 |
+
]):
|
| 333 |
+
body_start = i + 1
|
| 334 |
+
|
| 335 |
+
# Look for closing more carefully
|
| 336 |
+
elif any(text.lower().startswith(closing.lower()) for closing in [
|
| 337 |
+
"Sincerely", "Best regards", "Kind regards", "Yours sincerely",
|
| 338 |
+
"Best", "Regards", "Thank you", "Yours faithfully", "Yours truly"
|
| 339 |
+
]):
|
| 340 |
+
body_end = i
|
| 341 |
+
break
|
| 342 |
+
|
| 343 |
+
# If we couldn't find the body section, try to make a best guess
|
| 344 |
+
if body_start is None:
|
| 345 |
+
# Look for the first non-header paragraph
|
| 346 |
+
for i, para in enumerate(doc.paragraphs):
|
| 347 |
+
if not any(para.text.strip().lower().startswith(header) for header in [
|
| 348 |
+
"name:", "address:", "phone:", "email:", "date:"
|
| 349 |
+
]):
|
| 350 |
+
body_start = i
|
| 351 |
+
break
|
| 352 |
+
|
| 353 |
+
if body_start is None:
|
| 354 |
+
body_start = 0
|
| 355 |
+
|
| 356 |
+
if body_end is None:
|
| 357 |
+
# Look for signature block
|
| 358 |
+
for i in range(len(doc.paragraphs) - 1, -1, -1):
|
| 359 |
+
text = doc.paragraphs[i].text.strip().lower()
|
| 360 |
+
if text and not any(text.startswith(sig) for sig in [
|
| 361 |
+
"phone", "email", "address", "mobile", "tel", "website"
|
| 362 |
+
]):
|
| 363 |
+
body_end = i
|
| 364 |
+
break
|
| 365 |
+
|
| 366 |
+
if body_end is None or body_end <= body_start:
|
| 367 |
+
body_end = len(doc.paragraphs) - 1
|
| 368 |
+
|
| 369 |
+
# Split new body text into paragraphs
|
| 370 |
+
new_paragraphs = [p.strip() for p in new_body.strip().split("\n\n") if p.strip()]
|
| 371 |
+
|
| 372 |
+
# Store paragraphs that come before and after the body
|
| 373 |
+
before_body = [para.text for para in doc.paragraphs[:body_start]]
|
| 374 |
+
after_body = [para.text for para in doc.paragraphs[body_end:]]
|
| 375 |
+
|
| 376 |
+
# Clear the document
|
| 377 |
+
for _ in range(len(doc.paragraphs)):
|
| 378 |
+
if len(doc.paragraphs) > 0: # Check if there are any paragraphs left
|
| 379 |
+
p = doc.paragraphs[0]._element
|
| 380 |
+
p.getparent().remove(p)
|
| 381 |
+
|
| 382 |
+
# Rebuild the document
|
| 383 |
+
# Add paragraphs before body
|
| 384 |
+
for i, text in enumerate(before_body):
|
| 385 |
+
p = doc.add_paragraph(text)
|
| 386 |
+
if i < len(format_info):
|
| 387 |
+
self._apply_paragraph_format(p, format_info[i])
|
| 388 |
+
|
| 389 |
+
# Add new body paragraphs
|
| 390 |
+
for text in new_paragraphs:
|
| 391 |
+
p = doc.add_paragraph(text)
|
| 392 |
+
# Apply a default professional format
|
| 393 |
+
p.style = 'Normal'
|
| 394 |
+
p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.LEFT
|
| 395 |
+
|
| 396 |
+
# Add paragraphs after body
|
| 397 |
+
for i, text in enumerate(after_body):
|
| 398 |
+
p = doc.add_paragraph(text)
|
| 399 |
+
if body_end + i < len(format_info):
|
| 400 |
+
self._apply_paragraph_format(p, format_info[body_end + i])
|
| 401 |
+
|
| 402 |
+
def _apply_paragraph_format(self, paragraph, format_info):
|
| 403 |
+
"""Apply stored formatting to a paragraph.
|
| 404 |
+
|
| 405 |
+
Args:
|
| 406 |
+
paragraph: The paragraph to format
|
| 407 |
+
format_info: Dictionary containing formatting information
|
| 408 |
+
"""
|
| 409 |
+
try:
|
| 410 |
+
paragraph.style = format_info['style']
|
| 411 |
+
paragraph.alignment = format_info['alignment']
|
| 412 |
+
|
| 413 |
+
# If there's text in the paragraph, apply run formatting
|
| 414 |
+
if paragraph.runs and format_info['runs']:
|
| 415 |
+
for run, (bold, italic, underline, font_name, font_size) in zip(
|
| 416 |
+
paragraph.runs, format_info['runs']
|
| 417 |
+
):
|
| 418 |
+
run.bold = bold
|
| 419 |
+
run.italic = italic
|
| 420 |
+
run.underline = underline
|
| 421 |
+
if font_name:
|
| 422 |
+
run.font.name = font_name
|
| 423 |
+
if font_size:
|
| 424 |
+
run.font.size = font_size
|
| 425 |
+
except Exception:
|
| 426 |
+
# If any formatting fails, keep going with what we can apply
|
| 427 |
+
pass
|
Automatic CV and Cover Letter with API/src/utils/openai_integration.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM integration module for analyzing job descriptions and tailoring CV/cover letter.
|
| 3 |
+
Supports OpenAI, Grok, Groq, and local Ollama via the OpenAI-compatible API format.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from openai import OpenAI
|
| 8 |
+
from typing import Dict, List, Tuple, Any, Optional
|
| 9 |
+
import json
|
| 10 |
+
import re
|
| 11 |
+
import requests
|
| 12 |
+
from bs4 import BeautifulSoup
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class OpenAIIntegration:
|
| 17 |
+
"""Class for handling LLM API interactions."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 20 |
+
"""Initialize LLM integration.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
api_key: API key for the configured provider. Optional for local Ollama.
|
| 24 |
+
"""
|
| 25 |
+
self.provider = (os.environ.get("LLM_PROVIDER", "ollama") or "ollama").lower()
|
| 26 |
+
|
| 27 |
+
if self.provider == "openai":
|
| 28 |
+
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
| 29 |
+
self.base_url = os.environ.get("OPENAI_BASE_URL")
|
| 30 |
+
self.model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
| 31 |
+
if self.api_key:
|
| 32 |
+
if self.base_url:
|
| 33 |
+
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
| 34 |
+
else:
|
| 35 |
+
self.client = OpenAI(api_key=self.api_key)
|
| 36 |
+
else:
|
| 37 |
+
self.client = None
|
| 38 |
+
elif self.provider == "grok":
|
| 39 |
+
# Grok uses xAI API with OpenAI-compatible endpoint
|
| 40 |
+
self.api_key = api_key or os.environ.get("GROK_API_KEY")
|
| 41 |
+
self.base_url = os.environ.get("GROK_BASE_URL", "https://api.x.ai/v1")
|
| 42 |
+
self.model = os.environ.get("GROK_MODEL", "grok-2")
|
| 43 |
+
if self.api_key:
|
| 44 |
+
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
| 45 |
+
else:
|
| 46 |
+
self.client = None
|
| 47 |
+
elif self.provider == "groq":
|
| 48 |
+
# Groq provides OpenAI-compatible chat completions.
|
| 49 |
+
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
| 50 |
+
self.base_url = os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
|
| 51 |
+
self.model = os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile")
|
| 52 |
+
if self.api_key:
|
| 53 |
+
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
| 54 |
+
else:
|
| 55 |
+
self.client = None
|
| 56 |
+
else:
|
| 57 |
+
# Default to free/local Ollama (OpenAI-compatible endpoint).
|
| 58 |
+
self.api_key = api_key or os.environ.get("OLLAMA_API_KEY", "ollama")
|
| 59 |
+
self.base_url = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434/v1")
|
| 60 |
+
self.model = os.environ.get("OLLAMA_MODEL", "llama3.1:8b")
|
| 61 |
+
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
| 62 |
+
|
| 63 |
+
def is_api_key_set(self) -> bool:
|
| 64 |
+
"""Check if API client is available.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
Boolean indicating if API client is ready
|
| 68 |
+
"""
|
| 69 |
+
if self.provider in ("openai", "grok", "groq"):
|
| 70 |
+
return bool(self.api_key) and bool(self.client)
|
| 71 |
+
return bool(self.client)
|
| 72 |
+
|
| 73 |
+
def set_api_key(self, api_key: str) -> None:
|
| 74 |
+
"""Set API key for the current provider.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
api_key: Provider API key
|
| 78 |
+
"""
|
| 79 |
+
self.api_key = api_key
|
| 80 |
+
if self.base_url:
|
| 81 |
+
self.client = OpenAI(api_key=api_key, base_url=self.base_url)
|
| 82 |
+
else:
|
| 83 |
+
self.client = OpenAI(api_key=api_key)
|
| 84 |
+
|
| 85 |
+
def _build_model_candidates(self) -> List[str]:
|
| 86 |
+
"""Build an ordered model candidate list for provider fallback."""
|
| 87 |
+
candidates: List[str] = [self.model]
|
| 88 |
+
|
| 89 |
+
# Optional manual fallback list from environment (comma-separated).
|
| 90 |
+
extra = os.environ.get("LLM_FALLBACK_MODELS", "")
|
| 91 |
+
if extra:
|
| 92 |
+
candidates.extend([m.strip() for m in extra.split(",") if m.strip()])
|
| 93 |
+
|
| 94 |
+
if self.provider == "grok":
|
| 95 |
+
candidates.extend([
|
| 96 |
+
"grok-3-mini",
|
| 97 |
+
"grok-3",
|
| 98 |
+
"grok-3-fast",
|
| 99 |
+
"grok-2-latest",
|
| 100 |
+
"grok-2",
|
| 101 |
+
"grok-beta",
|
| 102 |
+
])
|
| 103 |
+
elif self.provider == "groq":
|
| 104 |
+
candidates.extend([
|
| 105 |
+
"llama-3.3-70b-versatile",
|
| 106 |
+
"llama-3.1-8b-instant",
|
| 107 |
+
"mixtral-8x7b-32768",
|
| 108 |
+
])
|
| 109 |
+
elif self.provider == "openai":
|
| 110 |
+
candidates.extend([
|
| 111 |
+
"gpt-4o-mini",
|
| 112 |
+
"gpt-4.1-mini",
|
| 113 |
+
])
|
| 114 |
+
|
| 115 |
+
# Deduplicate while preserving order.
|
| 116 |
+
deduped: List[str] = []
|
| 117 |
+
seen = set()
|
| 118 |
+
for model in candidates:
|
| 119 |
+
if model and model not in seen:
|
| 120 |
+
deduped.append(model)
|
| 121 |
+
seen.add(model)
|
| 122 |
+
return deduped
|
| 123 |
+
|
| 124 |
+
def _chat_completion_with_fallback(self, messages: List[Dict[str, str]], temperature: float, max_tokens: int):
|
| 125 |
+
"""Run chat completion with model fallback on model-not-found errors."""
|
| 126 |
+
model_candidates = self._build_model_candidates()
|
| 127 |
+
last_error: Optional[Exception] = None
|
| 128 |
+
|
| 129 |
+
for model_name in model_candidates:
|
| 130 |
+
try:
|
| 131 |
+
response = self.client.chat.completions.create(
|
| 132 |
+
model=model_name,
|
| 133 |
+
messages=messages,
|
| 134 |
+
temperature=temperature,
|
| 135 |
+
max_tokens=max_tokens,
|
| 136 |
+
)
|
| 137 |
+
# Persist successful model so later requests are faster/stable.
|
| 138 |
+
self.model = model_name
|
| 139 |
+
return response
|
| 140 |
+
except Exception as e:
|
| 141 |
+
last_error = e
|
| 142 |
+
error_text = str(e).lower()
|
| 143 |
+
is_model_error = (
|
| 144 |
+
"model not found" in error_text
|
| 145 |
+
or "invalid model" in error_text
|
| 146 |
+
or "does not exist" in error_text
|
| 147 |
+
)
|
| 148 |
+
if is_model_error:
|
| 149 |
+
continue
|
| 150 |
+
raise
|
| 151 |
+
|
| 152 |
+
if last_error is not None:
|
| 153 |
+
raise ValueError(
|
| 154 |
+
f"All model candidates failed for provider '{self.provider}': {model_candidates}. "
|
| 155 |
+
f"Last error: {last_error}"
|
| 156 |
+
)
|
| 157 |
+
raise ValueError("No model candidates available for completion.")
|
| 158 |
+
|
| 159 |
+
def extract_job_description_from_url(self, url: str) -> str:
|
| 160 |
+
"""Extract job description from LinkedIn URL.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
url: LinkedIn job posting URL
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
Extracted job description text
|
| 167 |
+
"""
|
| 168 |
+
if not url.startswith(('http://', 'https://')):
|
| 169 |
+
raise ValueError("Invalid URL format")
|
| 170 |
+
|
| 171 |
+
if 'linkedin.com' not in url:
|
| 172 |
+
raise ValueError("URL must be from LinkedIn")
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
# Add headers to mimic a browser request
|
| 176 |
+
headers = {
|
| 177 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
# Make the request
|
| 181 |
+
response = requests.get(url, headers=headers)
|
| 182 |
+
response.raise_for_status()
|
| 183 |
+
|
| 184 |
+
# Parse the HTML
|
| 185 |
+
soup = BeautifulSoup(response.text, 'html.parser')
|
| 186 |
+
|
| 187 |
+
# Extract job title
|
| 188 |
+
job_title = ""
|
| 189 |
+
title_element = soup.find('h1', class_='top-card-layout__title')
|
| 190 |
+
if title_element:
|
| 191 |
+
job_title = title_element.get_text(strip=True)
|
| 192 |
+
|
| 193 |
+
# Extract company name
|
| 194 |
+
company = ""
|
| 195 |
+
company_element = soup.find('a', class_='topcard__org-name-link')
|
| 196 |
+
if company_element:
|
| 197 |
+
company = company_element.get_text(strip=True)
|
| 198 |
+
|
| 199 |
+
# Extract job description
|
| 200 |
+
description = ""
|
| 201 |
+
desc_element = soup.find('div', class_='show-more-less-html__markup')
|
| 202 |
+
if desc_element:
|
| 203 |
+
description = desc_element.get_text(strip=True)
|
| 204 |
+
|
| 205 |
+
# If we couldn't find the description in the expected place, try alternative selectors
|
| 206 |
+
if not description:
|
| 207 |
+
desc_element = soup.find('div', class_='description__text')
|
| 208 |
+
if desc_element:
|
| 209 |
+
description = desc_element.get_text(strip=True)
|
| 210 |
+
|
| 211 |
+
# Combine all information
|
| 212 |
+
full_description = f"Job Title: {job_title}\nCompany: {company}\n\nJob Description:\n{description}"
|
| 213 |
+
|
| 214 |
+
return full_description
|
| 215 |
+
|
| 216 |
+
except requests.RequestException as e:
|
| 217 |
+
raise ValueError(f"Error fetching job description: {str(e)}")
|
| 218 |
+
except Exception as e:
|
| 219 |
+
raise ValueError(f"Error parsing job description: {str(e)}")
|
| 220 |
+
|
| 221 |
+
def analyze_job_description(self, job_description_or_url: str, cv_content: str) -> Dict[str, Any]:
|
| 222 |
+
"""Analyze job description and suggest CV modifications.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
job_description_or_url: Text of the job description or LinkedIn URL
|
| 226 |
+
cv_content: Current content of the CV
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
Dictionary with suggested modifications for different CV sections
|
| 230 |
+
"""
|
| 231 |
+
if not self.is_api_key_set():
|
| 232 |
+
if self.provider == "openai":
|
| 233 |
+
raise ValueError("OpenAI API key is not set. Please set OPENAI_API_KEY or call set_api_key().")
|
| 234 |
+
elif self.provider == "grok":
|
| 235 |
+
raise ValueError("Grok API key is not set. Please set GROK_API_KEY or call set_api_key().")
|
| 236 |
+
elif self.provider == "groq":
|
| 237 |
+
raise ValueError("Groq API key is not set. Please set GROQ_API_KEY or call set_api_key().")
|
| 238 |
+
raise ValueError("LLM client is not initialized. Check local Ollama settings.")
|
| 239 |
+
|
| 240 |
+
# Check if input is a LinkedIn URL
|
| 241 |
+
if job_description_or_url.startswith(('http://', 'https://')) and 'linkedin.com' in job_description_or_url:
|
| 242 |
+
try:
|
| 243 |
+
job_description = self.extract_job_description_from_url(job_description_or_url)
|
| 244 |
+
except Exception as e:
|
| 245 |
+
raise ValueError(f"Error extracting job description from URL: {str(e)}")
|
| 246 |
+
else:
|
| 247 |
+
job_description = job_description_or_url
|
| 248 |
+
|
| 249 |
+
# Prepare the prompt for GPT
|
| 250 |
+
prompt = f"""
|
| 251 |
+
You are an expert CV and resume tailoring assistant. Your task is to analyze a job description
|
| 252 |
+
and suggest modifications to a CV to better match the job requirements.
|
| 253 |
+
|
| 254 |
+
JOB DESCRIPTION:
|
| 255 |
+
{job_description}
|
| 256 |
+
|
| 257 |
+
CURRENT CV CONTENT:
|
| 258 |
+
{cv_content}
|
| 259 |
+
|
| 260 |
+
Please analyze the job description and suggest specific modifications to the following sections of the CV:
|
| 261 |
+
1. Profile/Summary: Suggest a tailored professional summary that highlights relevant skills and experience.
|
| 262 |
+
2. Skills: Identify key skills from the job description that should be emphasized or added.
|
| 263 |
+
3. Experience: Suggest how to reframe or emphasize certain experiences to better match the job requirements.
|
| 264 |
+
|
| 265 |
+
Format your response as a JSON object with the following structure:
|
| 266 |
+
{{
|
| 267 |
+
"profile_summary": "Suggested profile summary text",
|
| 268 |
+
"skills": ["skill1", "skill2", "skill3"],
|
| 269 |
+
"experience_highlights": ["point1", "point2", "point3"],
|
| 270 |
+
"keywords_to_emphasize": ["keyword1", "keyword2", "keyword3"]
|
| 271 |
+
}}
|
| 272 |
+
"""
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
# Call LLM API with model fallback.
|
| 276 |
+
response = self._chat_completion_with_fallback(
|
| 277 |
+
messages=[
|
| 278 |
+
{"role": "system", "content": "You are an expert CV tailoring assistant that provides structured JSON responses."},
|
| 279 |
+
{"role": "user", "content": prompt}
|
| 280 |
+
],
|
| 281 |
+
temperature=0.5,
|
| 282 |
+
max_tokens=1000,
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# Extract and parse the response
|
| 286 |
+
result = response.choices[0].message.content
|
| 287 |
+
|
| 288 |
+
try:
|
| 289 |
+
# Try to parse as JSON
|
| 290 |
+
return json.loads(result)
|
| 291 |
+
except json.JSONDecodeError:
|
| 292 |
+
# If parsing fails, return raw response
|
| 293 |
+
return {"raw_response": result}
|
| 294 |
+
|
| 295 |
+
except Exception as e:
|
| 296 |
+
return {"error": str(e)}
|
| 297 |
+
|
| 298 |
+
def tailor_cover_letter(self, job_description: str, current_cover_letter: str, cv_content: str) -> str:
|
| 299 |
+
"""Generate a tailored cover letter based on job description and CV.
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
job_description: Text of the job description
|
| 303 |
+
current_cover_letter: Current content of the cover letter
|
| 304 |
+
cv_content: Content of the CV for reference
|
| 305 |
+
|
| 306 |
+
Returns:
|
| 307 |
+
Tailored cover letter text
|
| 308 |
+
"""
|
| 309 |
+
if not self.is_api_key_set():
|
| 310 |
+
if self.provider == "openai":
|
| 311 |
+
raise ValueError("OpenAI API key is not set. Please set OPENAI_API_KEY or call set_api_key().")
|
| 312 |
+
elif self.provider == "grok":
|
| 313 |
+
raise ValueError("Grok API key is not set. Please set GROK_API_KEY or call set_api_key().")
|
| 314 |
+
elif self.provider == "groq":
|
| 315 |
+
raise ValueError("Groq API key is not set. Please set GROQ_API_KEY or call set_api_key().")
|
| 316 |
+
raise ValueError("LLM client is not initialized. Check local Ollama settings.")
|
| 317 |
+
|
| 318 |
+
# Prepare the prompt for LLM
|
| 319 |
+
prompt = f"""
|
| 320 |
+
You are an expert cover letter writing assistant. Your task is to tailor a cover letter
|
| 321 |
+
to better match a specific job description, while maintaining the original structure and tone.
|
| 322 |
+
|
| 323 |
+
JOB DESCRIPTION:
|
| 324 |
+
{job_description}
|
| 325 |
+
|
| 326 |
+
CURRENT COVER LETTER:
|
| 327 |
+
{current_cover_letter}
|
| 328 |
+
|
| 329 |
+
CV CONTENT (for reference):
|
| 330 |
+
{cv_content}
|
| 331 |
+
|
| 332 |
+
Please rewrite the body of the cover letter to:
|
| 333 |
+
1. Address specific requirements mentioned in the job description
|
| 334 |
+
2. Highlight relevant skills and experiences from the CV
|
| 335 |
+
3. Demonstrate enthusiasm for the specific role and company
|
| 336 |
+
4. Maintain a professional tone similar to the original
|
| 337 |
+
5. Keep approximately the same length as the original
|
| 338 |
+
|
| 339 |
+
Return only the tailored body text of the cover letter, without greeting or closing.
|
| 340 |
+
"""
|
| 341 |
+
|
| 342 |
+
try:
|
| 343 |
+
# Call LLM API with model fallback.
|
| 344 |
+
response = self._chat_completion_with_fallback(
|
| 345 |
+
messages=[
|
| 346 |
+
{"role": "system", "content": "You are an expert cover letter writing assistant."},
|
| 347 |
+
{"role": "user", "content": prompt}
|
| 348 |
+
],
|
| 349 |
+
temperature=0.7,
|
| 350 |
+
max_tokens=1000,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
# Extract the response
|
| 354 |
+
result = response.choices[0].message.content
|
| 355 |
+
return result
|
| 356 |
+
|
| 357 |
+
except Exception as e:
|
| 358 |
+
return f"Error generating cover letter: {str(e)}"
|
Automatic CV and Cover Letter with API/test_document_updater.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test script for the document updater module.
|
| 3 |
+
This is a mock test since we don't want to make actual API calls during testing.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import docx
|
| 9 |
+
from unittest import mock
|
| 10 |
+
|
| 11 |
+
# Add the project root to the path
|
| 12 |
+
sys.path.append('/home/ubuntu/cv_tailoring_project')
|
| 13 |
+
|
| 14 |
+
from src.updaters.document_updater import DocumentUpdater
|
| 15 |
+
from src.utils.openai_integration import OpenAIIntegration
|
| 16 |
+
|
| 17 |
+
def test_document_updater():
|
| 18 |
+
"""Test the document updater module with mock responses."""
|
| 19 |
+
print("Testing document updater module...")
|
| 20 |
+
|
| 21 |
+
# Paths to test documents
|
| 22 |
+
cv_path = '/home/ubuntu/cv_tailoring_project/data/Imon Hosen - Resume_Template_ATS.docx'
|
| 23 |
+
cover_letter_path = '/home/ubuntu/cv_tailoring_project/data/Cover Letter_Imon .docx'
|
| 24 |
+
|
| 25 |
+
# Create a mock OpenAI integration
|
| 26 |
+
mock_openai = mock.MagicMock(spec=OpenAIIntegration)
|
| 27 |
+
|
| 28 |
+
# Set up mock responses
|
| 29 |
+
mock_openai.analyze_job_description.return_value = {
|
| 30 |
+
"raw_response": """
|
| 31 |
+
{
|
| 32 |
+
"profile_summary": "Information Engineering student at HAW Hamburg with experience in data analysis and project coordination, seeking a part-time working student role in IT Project Management.",
|
| 33 |
+
"skills": ["Project coordination", "MS Office", "Data analysis", "Documentation"],
|
| 34 |
+
"experience_highlights": ["Assisted in project tracking", "Created documentation", "Analyzed data"],
|
| 35 |
+
"keywords_to_emphasize": ["project management", "coordination", "documentation"]
|
| 36 |
+
}
|
| 37 |
+
"""
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
mock_openai.tailor_cover_letter.return_value = "This is a tailored cover letter body text for testing purposes."
|
| 41 |
+
|
| 42 |
+
# Initialize document updater with mock OpenAI integration
|
| 43 |
+
document_updater = DocumentUpdater(cv_path, cover_letter_path, mock_openai)
|
| 44 |
+
|
| 45 |
+
# Test analyze_job_description method
|
| 46 |
+
job_description = "This is a test job description for an IT Project Management position."
|
| 47 |
+
analysis = document_updater.analyze_job_description(job_description)
|
| 48 |
+
|
| 49 |
+
assert "raw_response" in analysis
|
| 50 |
+
print("✓ analyze_job_description method works correctly")
|
| 51 |
+
|
| 52 |
+
# Test output paths
|
| 53 |
+
output_cv_path = '/home/ubuntu/cv_tailoring_project/output/test_cv.docx'
|
| 54 |
+
output_cl_path = '/home/ubuntu/cv_tailoring_project/output/test_cover_letter.docx'
|
| 55 |
+
|
| 56 |
+
# Create output directory if it doesn't exist
|
| 57 |
+
os.makedirs(os.path.dirname(output_cv_path), exist_ok=True)
|
| 58 |
+
|
| 59 |
+
# Test update_cv method
|
| 60 |
+
cv_result = document_updater.update_cv(job_description, output_cv_path)
|
| 61 |
+
assert os.path.exists(cv_result)
|
| 62 |
+
print(f"✓ update_cv method works correctly, file created at {cv_result}")
|
| 63 |
+
|
| 64 |
+
# Test update_cover_letter method
|
| 65 |
+
cl_result = document_updater.update_cover_letter(job_description, output_cl_path)
|
| 66 |
+
assert os.path.exists(cl_result)
|
| 67 |
+
print(f"✓ update_cover_letter method works correctly, file created at {cl_result}")
|
| 68 |
+
|
| 69 |
+
print("\nDocument updater module tests completed successfully!")
|
| 70 |
+
return True
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
# Use patch to mock the OpenAI integration
|
| 74 |
+
with mock.patch('src.utils.openai_integration.OpenAIIntegration') as MockOpenAI:
|
| 75 |
+
test_document_updater()
|
Automatic CV and Cover Letter with API/test_openai_integration.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility script to test the OpenAI integration module.
|
| 3 |
+
This is a mock test since we don't want to make actual API calls during testing.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
# Add the project root to the path
|
| 11 |
+
sys.path.append('/home/ubuntu/cv_tailoring_project')
|
| 12 |
+
|
| 13 |
+
from src.utils.openai_integration import OpenAIIntegration
|
| 14 |
+
|
| 15 |
+
def test_openai_integration():
|
| 16 |
+
"""Test the OpenAI integration module with mock responses."""
|
| 17 |
+
print("Testing OpenAI integration module...")
|
| 18 |
+
|
| 19 |
+
# Initialize with a dummy API key
|
| 20 |
+
openai_integration = OpenAIIntegration(api_key="dummy_key_for_testing")
|
| 21 |
+
|
| 22 |
+
# Test API key setting
|
| 23 |
+
assert openai_integration.is_api_key_set() == True
|
| 24 |
+
print("✓ API key setting works correctly")
|
| 25 |
+
|
| 26 |
+
# Test changing API key
|
| 27 |
+
openai_integration.set_api_key("new_dummy_key")
|
| 28 |
+
assert openai_integration.api_key == "new_dummy_key"
|
| 29 |
+
print("✓ API key can be updated")
|
| 30 |
+
|
| 31 |
+
print("\nNote: Full API functionality would require actual OpenAI API calls.")
|
| 32 |
+
print("The OpenAI integration module structure has been verified.")
|
| 33 |
+
|
| 34 |
+
return True
|
| 35 |
+
|
| 36 |
+
if __name__ == "__main__":
|
| 37 |
+
test_openai_integration()
|
CHANGES.md
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Transformation Summary
|
| 2 |
+
|
| 3 |
+
## 🎉 Complete React SaaS Frontend Transformation
|
| 4 |
+
|
| 5 |
+
This document summarizes the major changes and new features added to Job Apply AI.
|
| 6 |
+
|
| 7 |
+
## What Changed
|
| 8 |
+
|
| 9 |
+
### ✅ Added Components
|
| 10 |
+
|
| 11 |
+
#### New React Frontendl
|
| 12 |
+
- **Location**: `frontend/` directory
|
| 13 |
+
- **Technology**: React 18 + TypeScript + Vite
|
| 14 |
+
- **Architecture**: Modern SaaS with animations
|
| 15 |
+
- **Design**: Black & emerald color scheme
|
| 16 |
+
|
| 17 |
+
#### New State Management
|
| 18 |
+
- **Zustand Store**: Reactive state management with persistence
|
| 19 |
+
- **File**: `frontend/src/store/appStore.ts`
|
| 20 |
+
- **Features**: Job data, UI state, user settings, notifications
|
| 21 |
+
|
| 22 |
+
#### New Component Library (25+ components)
|
| 23 |
+
|
| 24 |
+
**Common Components**
|
| 25 |
+
- `Button` - Multiple variants (primary, secondary, outline, ghost, danger)
|
| 26 |
+
- `Card` - Hover effects, glows, inner glows
|
| 27 |
+
- `Input` - With icons, error states, helpers
|
| 28 |
+
- `Select` - Dropdown with animations
|
| 29 |
+
- `Badge` - Tags for skills, status
|
| 30 |
+
- `Modal` - Dialog with animations
|
| 31 |
+
- `Toast` - Notifications (success, error, warning, info)
|
| 32 |
+
- `ProgressBar` - Animated progress tracking
|
| 33 |
+
- `Spinner` - Loading indicator
|
| 34 |
+
- `Tabs` - Tab switcher
|
| 35 |
+
|
| 36 |
+
**Page Components**
|
| 37 |
+
- `HomePage` - Landing page with features
|
| 38 |
+
- `WorkflowPage` - 3-step wizard (upload, search, review)
|
| 39 |
+
- `JobListPage` - Job browser with batch selection
|
| 40 |
+
- `SettingsModal` - Configuration dialog
|
| 41 |
+
|
| 42 |
+
**Section Components**
|
| 43 |
+
- `Header` - Navigation bar
|
| 44 |
+
- `Footer` - Footer with links
|
| 45 |
+
- `CVUpload` - CV file upload with drag-drop
|
| 46 |
+
- `JobSearch` - Job search form
|
| 47 |
+
|
| 48 |
+
#### New Styling System
|
| 49 |
+
- **Framework**: Tailwind CSS 3
|
| 50 |
+
- **Theme**: Custom emerald/black palette
|
| 51 |
+
- **Animations**: Framer Motion integration
|
| 52 |
+
- **Responsive**: Mobile-first design
|
| 53 |
+
|
| 54 |
+
#### New API Layer
|
| 55 |
+
- **File**: `frontend/src/utils/api.ts`
|
| 56 |
+
- **Purpose**: REST client for backend communication
|
| 57 |
+
- **Methods**:
|
| 58 |
+
- `searchJobs()` - LinkedIn job search
|
| 59 |
+
- `uploadCV()` - CV template upload
|
| 60 |
+
- `generateCV()` - Single CV generation
|
| 61 |
+
- `generateAllCVs()` - Batch generation
|
| 62 |
+
- `downloadFile()` - File download
|
| 63 |
+
- `healthCheck()` - Server status
|
| 64 |
+
|
| 65 |
+
#### Updated Flask Backend
|
| 66 |
+
- **File**: `job_apply_ai/ui/app_new.py` (new) and `app.py` (legacy)
|
| 67 |
+
- **NEW**: REST API endpoints
|
| 68 |
+
- **NEW**: CORS support
|
| 69 |
+
- **NEW**: React static file serving
|
| 70 |
+
- **KEPT**: All existing business logic
|
| 71 |
+
|
| 72 |
+
### 📁 New Files Created
|
| 73 |
+
|
| 74 |
+
```
|
| 75 |
+
frontend/
|
| 76 |
+
├── src/
|
| 77 |
+
│ ├── components/
|
| 78 |
+
│ │ ├── common/ (10 components)
|
| 79 |
+
│ │ ├── pages/ (4 pages)
|
| 80 |
+
│ │ └── sections/ (4 sections)
|
| 81 |
+
│ ├── store/
|
| 82 |
+
│ │ └── appStore.ts (Zustand store)
|
| 83 |
+
│ ├── types/
|
| 84 |
+
│ │ └── index.ts (TypeScript definitions)
|
| 85 |
+
│ ├── utils/
|
| 86 |
+
│ │ ├── api.ts (REST client)
|
| 87 |
+
│ │ └── helpers.ts (utilities)
|
| 88 |
+
│ ├── styles/
|
| 89 |
+
│ │ └── globals.css (global styles)
|
| 90 |
+
│ ├── App.tsx (root component)
|
| 91 |
+
│ └── main.tsx (entry point)
|
| 92 |
+
├── public/ (static assets folder)
|
| 93 |
+
├── index.html (HTML template)
|
| 94 |
+
├── package.json (dependencies)
|
| 95 |
+
├── tsconfig.json (TypeScript config)
|
| 96 |
+
├── tailwind.config.js (theme config)
|
| 97 |
+
├── vite.config.ts (build config)
|
| 98 |
+
├── postcss.config.js (CSS processing)
|
| 99 |
+
└── README.md (frontend docs)
|
| 100 |
+
|
| 101 |
+
Root Documentation
|
| 102 |
+
├── QUICK_START.md (5-minute setup guide)
|
| 103 |
+
├── DEPLOYMENT_GUIDE.md (production deployment)
|
| 104 |
+
├── SAAS_FEATURES.md (feature documentation)
|
| 105 |
+
├── ARCHITECTURE.md (technical architecture)
|
| 106 |
+
└── CHANGES.md (this file)
|
| 107 |
+
|
| 108 |
+
Updated Files
|
| 109 |
+
├── requirements.txt (added flask-cors)
|
| 110 |
+
├── job_apply_ai/ui/app_new.py (new Flask version)
|
| 111 |
+
└── README.md (updated with React info)
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### 🎨 Design System
|
| 115 |
+
|
| 116 |
+
#### Color Palette
|
| 117 |
+
- **Primary Black**: `#0A0E27` - Dark sophisticated background
|
| 118 |
+
- **Primary Green**: `#22c55e` (emerald-500) - Action color
|
| 119 |
+
- **Slate Grays**: `#334155` to `#1e293b` - Text and borders
|
| 120 |
+
- **Gradients**: Emerald fades and dark gradients
|
| 121 |
+
|
| 122 |
+
#### Typography
|
| 123 |
+
- **Font Family**: Inter (modern, clean)
|
| 124 |
+
- **Sizes**: 6 levels (sm to 2xl)
|
| 125 |
+
- **Weights**: 400 (normal) to 900 (black)
|
| 126 |
+
|
| 127 |
+
#### Effects
|
| 128 |
+
- **Shadows**: Subtle to strong glow effects
|
| 129 |
+
- **Animations**: 200ms-600ms duration
|
| 130 |
+
- **Transitions**: Smooth cubic bezier easing
|
| 131 |
+
|
| 132 |
+
### ✨ Animation Features
|
| 133 |
+
|
| 134 |
+
- **Page Transitions**: Fade and slide on route changes
|
| 135 |
+
- **Component Hover**: Cards lift up with glow effect
|
| 136 |
+
- **Button Interactions**: Scale down on tap for feedback
|
| 137 |
+
- **Loading States**: Smooth spinning and pulsing
|
| 138 |
+
- **Batch Progress**: Animated progress bar fills
|
| 139 |
+
- **Stagger Effects**: Children animate in sequence
|
| 140 |
+
- **Gesture Support**: Smooth mobile interactions
|
| 141 |
+
|
| 142 |
+
### 🔄 State Management
|
| 143 |
+
|
| 144 |
+
#### Zustand Store Features
|
| 145 |
+
```typescript
|
| 146 |
+
// Job State
|
| 147 |
+
- jobs: Job[]
|
| 148 |
+
- setJobs()
|
| 149 |
+
- addJob()
|
| 150 |
+
- removeJob()
|
| 151 |
+
|
| 152 |
+
// UI State
|
| 153 |
+
- isSearching: boolean
|
| 154 |
+
- isGenerating: boolean
|
| 155 |
+
- setIsSearching(), setIsGenerating()
|
| 156 |
+
|
| 157 |
+
// Selection & Batch
|
| 158 |
+
- selectedJobIds: Set<string>
|
| 159 |
+
- toggleJobSelection()
|
| 160 |
+
- selectAllJobs()
|
| 161 |
+
- deselectAllJobs()
|
| 162 |
+
|
| 163 |
+
// Progress Tracking
|
| 164 |
+
- batchProgress: BatchProgress
|
| 165 |
+
- setBatchProgress()
|
| 166 |
+
|
| 167 |
+
// File Management
|
| 168 |
+
- cvTemplate: CVTemplate | null
|
| 169 |
+
- setCVTemplate()
|
| 170 |
+
- generatedCVs: GeneratedCV[]
|
| 171 |
+
- addGeneratedCV()
|
| 172 |
+
|
| 173 |
+
// Settings
|
| 174 |
+
- tailoringMode: 'local' | 'api'
|
| 175 |
+
- llmProvider: LLMProvider
|
| 176 |
+
- setTailoringMode(), setLLMProvider()
|
| 177 |
+
|
| 178 |
+
// Notifications
|
| 179 |
+
- notification:Toast | null
|
| 180 |
+
- setNotification()
|
| 181 |
+
|
| 182 |
+
// Reset
|
| 183 |
+
- reset()
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
### 🛠️ New Dependencies
|
| 187 |
+
|
| 188 |
+
**Frontend (package.json)**
|
| 189 |
+
- react: 18.2
|
| 190 |
+
- typescript: 5.0
|
| 191 |
+
- vite: 5.0
|
| 192 |
+
- tailwindcss: 3.3
|
| 193 |
+
- framer-motion: 10.16
|
| 194 |
+
- zustand: 4.4
|
| 195 |
+
- axios: 1.6
|
| 196 |
+
- lucide-react: 0.294
|
| 197 |
+
|
| 198 |
+
**Backend (requirements.txt)**
|
| 199 |
+
- flask-cors: 4.0
|
| 200 |
+
|
| 201 |
+
### 📱 Responsive Design
|
| 202 |
+
|
| 203 |
+
- **Mobile**: 1 column, large touch targets
|
| 204 |
+
- **Tablet (768px+)**: 2 columns, optimized spacing
|
| 205 |
+
- **Desktop (1024px+)**: 4 columns, full features
|
| 206 |
+
- **Large (1280px+)**: max-width container, full experience
|
| 207 |
+
|
| 208 |
+
### ♿ Accessibility Features
|
| 209 |
+
|
| 210 |
+
- Semantic HTML elements
|
| 211 |
+
- ARIA labels for interactive elements
|
| 212 |
+
- Focus rings on all interactive elements
|
| 213 |
+
- Color contrast 4.5:1+ ratio
|
| 214 |
+
- Keyboard navigation support
|
| 215 |
+
- Status not conveyed by color alone
|
| 216 |
+
|
| 217 |
+
### 🚀 Performance Improvements
|
| 218 |
+
|
| 219 |
+
**Frontend**
|
| 220 |
+
- Code splitting via Vite
|
| 221 |
+
- Tree-shaking of unused code
|
| 222 |
+
- CSS purging with Tailwind
|
| 223 |
+
- SVG icons (no image requests)
|
| 224 |
+
- Lazy component loading
|
| 225 |
+
|
| 226 |
+
**Backend**
|
| 227 |
+
- Batched CV generation
|
| 228 |
+
- Session caching
|
| 229 |
+
- Efficient file handling
|
| 230 |
+
- Connection reuse for scraping
|
| 231 |
+
|
| 232 |
+
## Breaking Changes
|
| 233 |
+
|
| 234 |
+
### For Users
|
| 235 |
+
- ⚠️ Old HTML interface still available but new React UI is default
|
| 236 |
+
- ⚠️ Frontend now requires Node.js 18+ and npm to run/build
|
| 237 |
+
|
| 238 |
+
### For Developers
|
| 239 |
+
- ⚠️ Frontend is now in separate `frontend/` directory
|
| 240 |
+
- ⚠️ API endpoints changed from HTML forms to JSON endpoints
|
| 241 |
+
- ⚠️ Flask app renamed from `app.py` to `app_new.py` (old copy kept as backup)
|
| 242 |
+
|
| 243 |
+
## Backward Compatibility
|
| 244 |
+
|
| 245 |
+
✅ **Fully Compatible**
|
| 246 |
+
- Python backend logic unchanged
|
| 247 |
+
- All job scraping still works
|
| 248 |
+
- CV modification logic still works
|
| 249 |
+
- Excel export still works
|
| 250 |
+
- Session management preserved
|
| 251 |
+
- Legacy HTML interface available
|
| 252 |
+
|
| 253 |
+
## Migration Path
|
| 254 |
+
|
| 255 |
+
### From Old to New
|
| 256 |
+
|
| 257 |
+
```bash
|
| 258 |
+
# 1. Backup current setup
|
| 259 |
+
cp job_apply_ai/ui/app.py job_apply_ai/ui/app_legacy.py
|
| 260 |
+
|
| 261 |
+
# 2. Switch to new Flask app
|
| 262 |
+
mv job_apply_ai/ui/app_new.py job_apply_ai/ui/app.py
|
| 263 |
+
|
| 264 |
+
# 3. Install new dependencies
|
| 265 |
+
pip install flask-cors
|
| 266 |
+
cd frontend && npm install && cd ..
|
| 267 |
+
|
| 268 |
+
# 4. Start both servers
|
| 269 |
+
# Terminal 1: Backend
|
| 270 |
+
python -m job_apply_ai.ui.app
|
| 271 |
+
|
| 272 |
+
# Terminal 2: Frontend
|
| 273 |
+
cd frontend && npm run dev
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
## What Stayed the Same
|
| 277 |
+
|
| 278 |
+
✅ **Unchanged Features**
|
| 279 |
+
- Job scraping from LinkedIn
|
| 280 |
+
- CV PDF/DOCX modification
|
| 281 |
+
- Skill extraction and matching
|
| 282 |
+
- Batch processing
|
| 283 |
+
- Excel export
|
| 284 |
+
- API mode integration (OpenAI, Groq, etc.)
|
| 285 |
+
- All CLI commands
|
| 286 |
+
|
| 287 |
+
## Performance Metrics
|
| 288 |
+
|
| 289 |
+
### Build Size
|
| 290 |
+
- React App: ~150KB gzipped (including vendor)
|
| 291 |
+
- Tailwind CSS: ~25KB gzipped
|
| 292 |
+
- Total: ~175KB JavaScript
|
| 293 |
+
|
| 294 |
+
### Load Times (dev server)
|
| 295 |
+
- Initial page load: ~500ms
|
| 296 |
+
- API response (job search): 5-30s (depending on LinkedIn)
|
| 297 |
+
- CV generation: 2-10s (depending on file size)
|
| 298 |
+
- File download: instant
|
| 299 |
+
|
| 300 |
+
### Performance Optimizations
|
| 301 |
+
- Code splitting reduces initial bundle
|
| 302 |
+
- Lazy loading delays non-critical components
|
| 303 |
+
- CSS tree-shaking removes unused styles
|
| 304 |
+
- Gzip compression for all assets
|
| 305 |
+
|
| 306 |
+
## Future Roadmap
|
| 307 |
+
|
| 308 |
+
### Phase 1 (Current)
|
| 309 |
+
- ✅ Modern React frontend
|
| 310 |
+
- ✅ Professional SaaS design
|
| 311 |
+
- ✅ Animations with Framer Motion
|
| 312 |
+
- ✅ REST API endpoints
|
| 313 |
+
- ✅ Zustand state management
|
| 314 |
+
|
| 315 |
+
### Phase 2 (Next)
|
| 316 |
+
- [ ] Cover letter generation UI
|
| 317 |
+
- [ ] CV template library
|
| 318 |
+
- [ ] Job bookmarking feature
|
| 319 |
+
- [ ] Application history
|
| 320 |
+
- [ ] Advanced analytics
|
| 321 |
+
|
| 322 |
+
### Phase 3 (Later)
|
| 323 |
+
- [ ] User authentication
|
| 324 |
+
- [ ] Cloud storage integration
|
| 325 |
+
- [ ] Email notifications
|
| 326 |
+
- [ ] Mobile app (React Native)
|
| 327 |
+
- [ ] Browser extension
|
| 328 |
+
|
| 329 |
+
## Testing
|
| 330 |
+
|
| 331 |
+
### Manual Testing Checklist
|
| 332 |
+
- [ ] Upload CV file
|
| 333 |
+
- [ ] Search for jobs
|
| 334 |
+
- [ ] View job details
|
| 335 |
+
- [ ] Select multiple jobs
|
| 336 |
+
- [ ] Generate single CV
|
| 337 |
+
- [ ] Batch generate CVs
|
| 338 |
+
- [ ] Download ZIP file
|
| 339 |
+
- [ ] Settings modal
|
| 340 |
+
- [ ] Responsive on mobile
|
| 341 |
+
- [ ] Dark mode appearance
|
| 342 |
+
|
| 343 |
+
### Automated Testing (optional setup)
|
| 344 |
+
```bash
|
| 345 |
+
# Component tests
|
| 346 |
+
npm run test
|
| 347 |
+
|
| 348 |
+
# E2E tests
|
| 349 |
+
npm run test:e2e
|
| 350 |
+
|
| 351 |
+
# Type checking
|
| 352 |
+
npm run type-check
|
| 353 |
+
```
|
| 354 |
+
|
| 355 |
+
## Documentation
|
| 356 |
+
|
| 357 |
+
### User Documentation
|
| 358 |
+
- **README.md** - Project overview
|
| 359 |
+
- **QUICK_START.md** - 5-minute setup guide
|
| 360 |
+
- **DEPLOYMENT_GUIDE.md** - Production deployment
|
| 361 |
+
|
| 362 |
+
### Developer Documentation
|
| 363 |
+
- **frontend/README.md** - React app documentation
|
| 364 |
+
- **ARCHITECTURE.md** - Technical architecture
|
| 365 |
+
- **SAAS_FEATURES.md** - Feature documentation
|
| 366 |
+
- **CHANGES.md** - This file
|
| 367 |
+
|
| 368 |
+
## Support & Feedback
|
| 369 |
+
|
| 370 |
+
### Issues?
|
| 371 |
+
1. Check QUICK_START.md for setup help
|
| 372 |
+
2. Review DEPLOYMENT_GUIDE.md for deployment issues
|
| 373 |
+
3. Check browser console for errors
|
| 374 |
+
4. Review Flask server logs
|
| 375 |
+
|
| 376 |
+
### Feedback?
|
| 377 |
+
- Features work great? Share your thoughts!
|
| 378 |
+
- Found a bug? Please report it
|
| 379 |
+
- Ideas for improvements? We'd love to hear them
|
| 380 |
+
|
| 381 |
+
## Credits
|
| 382 |
+
|
| 383 |
+
### Technology Stack
|
| 384 |
+
- **React** - UI framework
|
| 385 |
+
- **Tailwind CSS** - Styling
|
| 386 |
+
- **Framer Motion** - Animations
|
| 387 |
+
- **Zustand** - State management
|
| 388 |
+
- **Vite** - Build tool
|
| 389 |
+
- **TypeScript** - Type safety
|
| 390 |
+
|
| 391 |
+
### Open Source
|
| 392 |
+
Built with love using modern open-source technologies ❤️
|
| 393 |
+
|
| 394 |
+
## License
|
| 395 |
+
|
| 396 |
+
MIT - Same as the original project
|
| 397 |
+
|
| 398 |
+
---
|
| 399 |
+
|
| 400 |
+
## Summary
|
| 401 |
+
|
| 402 |
+
The Job Apply AI project has been transformed from a basic Flask HTML interface to a modern, professional React SaaS application with:
|
| 403 |
+
|
| 404 |
+
✨ **Beautiful SaaS Design**
|
| 405 |
+
🎬 **Smooth Animations**
|
| 406 |
+
⚡ **Modern Tech Stack**
|
| 407 |
+
🎯 **Excellent UX**
|
| 408 |
+
📱 **Fully Responsive**
|
| 409 |
+
♿ **Accessible**
|
| 410 |
+
🚀 **High Performance**
|
| 411 |
+
|
| 412 |
+
Ready to use, easy to extend, and delightful for users! 🎉
|
| 413 |
+
|
| 414 |
+
---
|
| 415 |
+
|
| 416 |
+
**Version**: 2.0.0 (React SaaS Edition)
|
| 417 |
+
**Last Updated**: January 2024
|
| 418 |
+
**Status**: Production Ready
|
CV maker/2222cvCreate.ipynb
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 1,
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"outputs": [],
|
| 8 |
+
"source": [
|
| 9 |
+
"#!pip3 install python-docx\n",
|
| 10 |
+
"#!pip3 install openai\n",
|
| 11 |
+
"#!pip3 install spacy"
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"cell_type": "code",
|
| 16 |
+
"execution_count": 2,
|
| 17 |
+
"metadata": {},
|
| 18 |
+
"outputs": [],
|
| 19 |
+
"source": [
|
| 20 |
+
"import os # For file path operations\n",
|
| 21 |
+
"import re # For regular expressions (finding keywords)\n",
|
| 22 |
+
"import requests # For making HTTP requests to fetch job description\n",
|
| 23 |
+
"from docx import Document # From python-docx for reading/writing Word documents\n",
|
| 24 |
+
"from docx.shared import Pt # For setting font sizes, etc.\n",
|
| 25 |
+
"import time\n",
|
| 26 |
+
"import datetime\n",
|
| 27 |
+
"import pandas as pd\n",
|
| 28 |
+
"\n",
|
| 29 |
+
"import spacy\n",
|
| 30 |
+
"from datetime import datetime, timedelta\n",
|
| 31 |
+
"import undetected_chromedriver as uc\n",
|
| 32 |
+
"from selenium import webdriver\n",
|
| 33 |
+
"from selenium.webdriver.common.by import By\n",
|
| 34 |
+
"from selenium.webdriver.common.keys import Keys\n",
|
| 35 |
+
"from selenium.webdriver.support.ui import WebDriverWait\n",
|
| 36 |
+
"from selenium.webdriver.support import expected_conditions as EC"
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"cell_type": "markdown",
|
| 41 |
+
"metadata": {},
|
| 42 |
+
"source": [
|
| 43 |
+
"#### Getting the job from linked in then put the file as input file"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"cell_type": "code",
|
| 48 |
+
"execution_count": 3,
|
| 49 |
+
"metadata": {},
|
| 50 |
+
"outputs": [
|
| 51 |
+
{
|
| 52 |
+
"name": "stdout",
|
| 53 |
+
"output_type": "stream",
|
| 54 |
+
"text": [
|
| 55 |
+
"\n",
|
| 56 |
+
"🔍 Scraping LinkedIn Jobs...\n",
|
| 57 |
+
"\n",
|
| 58 |
+
"⏳ Skipping job: (Posted 15 days ago)\n",
|
| 59 |
+
"⏳ Skipping job: ******* ******* *********** ********* - *-******** (*/*/*) (Posted 115 days ago)\n",
|
| 60 |
+
"\n",
|
| 61 |
+
"✅ Jobs saved to /Users/eimon/Desktop/Code/AI works/Job apply AI agent/Job-apply-AI-agent/CV maker/linkedin_jobs_2025-02-27.xlsx\n"
|
| 62 |
+
]
|
| 63 |
+
}
|
| 64 |
+
],
|
| 65 |
+
"source": [
|
| 66 |
+
"def configure_driver():\n",
|
| 67 |
+
" options = webdriver.ChromeOptions()\n",
|
| 68 |
+
" options.add_argument(\"--headless\")\n",
|
| 69 |
+
" options.add_argument(\"--no-sandbox\")\n",
|
| 70 |
+
" options.add_argument(\"--disable-dev-shm-usage\")\n",
|
| 71 |
+
" driver = uc.Chrome(options=options)\n",
|
| 72 |
+
" return driver\n",
|
| 73 |
+
"\n",
|
| 74 |
+
"def scrape_linkedin_jobs(keyword, location):\n",
|
| 75 |
+
" print(\"\\n🔍 Scraping LinkedIn Jobs...\\n\")\n",
|
| 76 |
+
" driver = configure_driver()\n",
|
| 77 |
+
" search_url = f\"https://www.linkedin.com/jobs/search?keywords={keyword.replace(' ', '%20')}&location={location.replace(' ', '%20')}\"\n",
|
| 78 |
+
" driver.get(search_url)\n",
|
| 79 |
+
" \n",
|
| 80 |
+
" for _ in range(3): \n",
|
| 81 |
+
" driver.execute_script(\"window.scrollBy(0, 800);\")\n",
|
| 82 |
+
" time.sleep(2)\n",
|
| 83 |
+
" \n",
|
| 84 |
+
" wait = WebDriverWait(driver, 15)\n",
|
| 85 |
+
" try:\n",
|
| 86 |
+
" wait.until(EC.presence_of_element_located((By.CLASS_NAME, \"base-card\")))\n",
|
| 87 |
+
" except:\n",
|
| 88 |
+
" print(\"❌ No LinkedIn jobs found.\")\n",
|
| 89 |
+
" driver.quit()\n",
|
| 90 |
+
" return []\n",
|
| 91 |
+
"\n",
|
| 92 |
+
" jobs = []\n",
|
| 93 |
+
" today = datetime.today()\n",
|
| 94 |
+
" job_elements = driver.find_elements(By.CLASS_NAME, \"base-card\")\n",
|
| 95 |
+
" \n",
|
| 96 |
+
" for job in job_elements[:10]:\n",
|
| 97 |
+
" try:\n",
|
| 98 |
+
" title = job.find_element(By.CSS_SELECTOR, \"h3\").text.strip()\n",
|
| 99 |
+
" company = job.find_element(By.CSS_SELECTOR, \"h4\").text.strip()\n",
|
| 100 |
+
" link = job.find_element(By.TAG_NAME, \"a\").get_attribute(\"href\")\n",
|
| 101 |
+
" \n",
|
| 102 |
+
" try:\n",
|
| 103 |
+
" date_element = job.find_element(By.CSS_SELECTOR, \"time\")\n",
|
| 104 |
+
" posted_time = date_element.get_attribute(\"datetime\")\n",
|
| 105 |
+
" if posted_time:\n",
|
| 106 |
+
" posted_date = datetime.strptime(posted_time[:10], \"%Y-%m-%d\")\n",
|
| 107 |
+
" days_ago = (today - posted_date).days\n",
|
| 108 |
+
" if days_ago > 14:\n",
|
| 109 |
+
" print(f\"⏳ Skipping job: {title} (Posted {days_ago} days ago)\")\n",
|
| 110 |
+
" continue\n",
|
| 111 |
+
" except:\n",
|
| 112 |
+
" print(f\"⚠️ Could not find post time for: {title}, assuming it's recent.\")\n",
|
| 113 |
+
" days_ago = \"Unknown\"\n",
|
| 114 |
+
" \n",
|
| 115 |
+
" jobs.append({\"title\": title, \"company\": company, \"link\": link, \"source\": \"LinkedIn\", \"posted_days_ago\": days_ago})\n",
|
| 116 |
+
" except Exception as e:\n",
|
| 117 |
+
" print(f\"⚠️ Skipping a job entry due to error: {e}\")\n",
|
| 118 |
+
" continue\n",
|
| 119 |
+
" \n",
|
| 120 |
+
" driver.quit()\n",
|
| 121 |
+
" return jobs\n",
|
| 122 |
+
"\n",
|
| 123 |
+
"if __name__ == \"__main__\":\n",
|
| 124 |
+
" keyword = input(\"Enter job title (e.g., Software Engineer): \")\n",
|
| 125 |
+
" location = input(\"Enter location (e.g., Remote, New York, Berlin): \")\n",
|
| 126 |
+
" \n",
|
| 127 |
+
" linkedin_jobs = scrape_linkedin_jobs(keyword, location)\n",
|
| 128 |
+
" \n",
|
| 129 |
+
" if linkedin_jobs:\n",
|
| 130 |
+
" df = pd.DataFrame(linkedin_jobs)\n",
|
| 131 |
+
" today_date = datetime.today().strftime(\"%Y-%m-%d\")\n",
|
| 132 |
+
" filename = f\"linkedin_jobs_{today_date}.xlsx\"\n",
|
| 133 |
+
" \n",
|
| 134 |
+
" folder_path = \"/Users/eimon/Desktop/Code/AI works/Job apply AI agent/Job-apply-AI-agent/CV maker\"\n",
|
| 135 |
+
" os.makedirs(folder_path, exist_ok=True) # Ensure directory exists\n",
|
| 136 |
+
" input_file = os.path.join(folder_path, filename)\n",
|
| 137 |
+
" \n",
|
| 138 |
+
" df.to_excel(input_file, index=False)\n",
|
| 139 |
+
" print(f\"\\n✅ Jobs saved to {input_file}\")\n",
|
| 140 |
+
" else:\n",
|
| 141 |
+
" print(\"\\n❌ No LinkedIn jobs found.\")\n",
|
| 142 |
+
" input_file = None\n"
|
| 143 |
+
]
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"cell_type": "code",
|
| 147 |
+
"execution_count": 4,
|
| 148 |
+
"metadata": {},
|
| 149 |
+
"outputs": [
|
| 150 |
+
{
|
| 151 |
+
"name": "stdout",
|
| 152 |
+
"output_type": "stream",
|
| 153 |
+
"text": [
|
| 154 |
+
"/Users/eimon/Desktop/Code/AI works/Job apply AI agent/Job-apply-AI-agent/CV maker/linkedin_jobs_2025-02-27.xlsx\n"
|
| 155 |
+
]
|
| 156 |
+
}
|
| 157 |
+
],
|
| 158 |
+
"source": [
|
| 159 |
+
"#chekcing the input file is getting correctly\n",
|
| 160 |
+
"print(input_file)"
|
| 161 |
+
]
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"cell_type": "markdown",
|
| 165 |
+
"metadata": {},
|
| 166 |
+
"source": [
|
| 167 |
+
"## Getting the description of the job. fetch_full_job_details"
|
| 168 |
+
]
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"cell_type": "code",
|
| 172 |
+
"execution_count": 5,
|
| 173 |
+
"metadata": {},
|
| 174 |
+
"outputs": [],
|
| 175 |
+
"source": [
|
| 176 |
+
"def fetch_full_job_details(job_url: str) -> tuple:\n",
|
| 177 |
+
" \"\"\"\n",
|
| 178 |
+
" Opens the LinkedIn job page, fetches the job title, company name, and full job description.\n",
|
| 179 |
+
" Returns (job_title, company_name, job_description).\n",
|
| 180 |
+
" \"\"\"\n",
|
| 181 |
+
" options = uc.ChromeOptions()\n",
|
| 182 |
+
" options.add_argument(\"--headless\") # or remove this if you want to see the browser\n",
|
| 183 |
+
" options.add_argument(\"--no-sandbox\")\n",
|
| 184 |
+
" options.add_argument(\"--disable-dev-shm-usage\")\n",
|
| 185 |
+
"\n",
|
| 186 |
+
" driver = uc.Chrome(options=options)\n",
|
| 187 |
+
" driver.get(job_url)\n",
|
| 188 |
+
"\n",
|
| 189 |
+
" # Default empty values\n",
|
| 190 |
+
" job_title = \"\"\n",
|
| 191 |
+
" company_name = \"\"\n",
|
| 192 |
+
" job_description = \"\"\n",
|
| 193 |
+
"\n",
|
| 194 |
+
" try:\n",
|
| 195 |
+
" wait = WebDriverWait(driver, 15)\n",
|
| 196 |
+
"\n",
|
| 197 |
+
" # 1) Job Title (example selector)\n",
|
| 198 |
+
" title_elem = wait.until(\n",
|
| 199 |
+
" EC.presence_of_element_located((By.CSS_SELECTOR, \"h1.topcard__title\"))\n",
|
| 200 |
+
" )\n",
|
| 201 |
+
" job_title = title_elem.get_attribute(\"innerText\")\n",
|
| 202 |
+
"\n",
|
| 203 |
+
" # 2) Company Name (example selector)\n",
|
| 204 |
+
" company_elem = wait.until(\n",
|
| 205 |
+
" EC.presence_of_element_located((By.CSS_SELECTOR, \"a.topcard__org-name-link\"))\n",
|
| 206 |
+
" )\n",
|
| 207 |
+
" company_name = company_elem.get_attribute(\"innerText\")\n",
|
| 208 |
+
"\n",
|
| 209 |
+
" # 3) Full Job Description (often \"description__text\" class)\n",
|
| 210 |
+
" desc_elem = wait.until(\n",
|
| 211 |
+
" EC.presence_of_element_located((By.CLASS_NAME, \"description__text\"))\n",
|
| 212 |
+
" )\n",
|
| 213 |
+
" job_description = desc_elem.get_attribute(\"innerText\")\n",
|
| 214 |
+
"\n",
|
| 215 |
+
" except Exception as e:\n",
|
| 216 |
+
" print(f\"Error scraping {job_url}: {e}\")\n",
|
| 217 |
+
"\n",
|
| 218 |
+
" finally:\n",
|
| 219 |
+
" driver.quit()\n",
|
| 220 |
+
"\n",
|
| 221 |
+
" return job_title.strip(), company_name.strip(), job_description.strip()"
|
| 222 |
+
]
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"cell_type": "markdown",
|
| 226 |
+
"metadata": {},
|
| 227 |
+
"source": [
|
| 228 |
+
"#### After modifying the excel sheet with description"
|
| 229 |
+
]
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"cell_type": "code",
|
| 233 |
+
"execution_count": 16,
|
| 234 |
+
"metadata": {},
|
| 235 |
+
"outputs": [
|
| 236 |
+
{
|
| 237 |
+
"name": "stdout",
|
| 238 |
+
"output_type": "stream",
|
| 239 |
+
"text": [
|
| 240 |
+
" title \\\n",
|
| 241 |
+
"0 Working Student - Digital Analytics (all genders) \n",
|
| 242 |
+
"1 Working Student Graphic Design (m/w/d) \n",
|
| 243 |
+
"2 Werkstudent \n",
|
| 244 |
+
"3 Working Student Corporate and Business Develop... \n",
|
| 245 |
+
"4 Working Student in Product Marketing \n",
|
| 246 |
+
"\n",
|
| 247 |
+
" company \\\n",
|
| 248 |
+
"0 Digitl \n",
|
| 249 |
+
"1 Fanblast \n",
|
| 250 |
+
"2 DDC Management Consultants \n",
|
| 251 |
+
"3 PRIOjet GmbH \n",
|
| 252 |
+
"4 Rabot Energy \n",
|
| 253 |
+
"\n",
|
| 254 |
+
" link source \\\n",
|
| 255 |
+
"0 https://de.linkedin.com/jobs/view/working-stud... LinkedIn \n",
|
| 256 |
+
"1 https://de.linkedin.com/jobs/view/working-stud... LinkedIn \n",
|
| 257 |
+
"2 https://de.linkedin.com/jobs/view/werkstudent-... LinkedIn \n",
|
| 258 |
+
"3 https://de.linkedin.com/jobs/view/working-stud... LinkedIn \n",
|
| 259 |
+
"4 https://de.linkedin.com/jobs/view/working-stud... LinkedIn \n",
|
| 260 |
+
"\n",
|
| 261 |
+
" posted_days_ago description \\\n",
|
| 262 |
+
"0 5 Digitl ist ein junges und innovatives Unterneh... \n",
|
| 263 |
+
"1 8 About fanblast\\n\\nFanblast is a fast-growing S... \n",
|
| 264 |
+
"2 6 DDC Management Consultants ist als Management-... \n",
|
| 265 |
+
"3 6 Hi! We’re happy that you’re here.\\n\\n\\n\\n\\nPRI... \n",
|
| 266 |
+
"4 13 Why us?\\n\\nRABOT Energy is looking for a motiv... \n",
|
| 267 |
+
"\n",
|
| 268 |
+
" Extracted Skills Extracted Requirements \n",
|
| 269 |
+
"0 [] [] \n",
|
| 270 |
+
"1 [] [required, ability to, proficiency in, experie... \n",
|
| 271 |
+
"2 [] [] \n",
|
| 272 |
+
"3 [] [required, experience in] \n",
|
| 273 |
+
"4 [] [experience in] \n"
|
| 274 |
+
]
|
| 275 |
+
}
|
| 276 |
+
],
|
| 277 |
+
"source": [
|
| 278 |
+
"import pandas as pd\n",
|
| 279 |
+
"import re\n",
|
| 280 |
+
"\n",
|
| 281 |
+
"# Load the updated Excel file\n",
|
| 282 |
+
"input_file = \"final_job_descriptions.xlsx\"\n",
|
| 283 |
+
"df = pd.read_excel(input_file)\n",
|
| 284 |
+
"\n",
|
| 285 |
+
"# Define your existing skills and categories\n",
|
| 286 |
+
"my_skills = {\n",
|
| 287 |
+
" \"Data Science & Machine Learning\": [\"Python\", \"R\", \"TensorFlow\", \"NumPy\", \"Pandas\", \"Seaborn\", \"Scikit-learn\"],\n",
|
| 288 |
+
" \"Statistical Modeling & AI\": [\"ML models\", \"AI\", \"Custom-GPT\", \"Deep Learning\"],\n",
|
| 289 |
+
" \"AI Agent\": [\"n8n\", \"Python AI Agent\", \"Automation\"],\n",
|
| 290 |
+
" \"Business Intelligence & Dashboarding\": [\"Power BI\", \"Tableau\", \"SQL\", \"Data Visualization\"],\n",
|
| 291 |
+
" \"Database Optimization\": [\"SQL\", \"MySQL\", \"PostgreSQL\"],\n",
|
| 292 |
+
" \"Programming Languages\": [\"Python\", \"Java\", \"C\", \"JavaScript\"],\n",
|
| 293 |
+
" \"Microsoft Tools\": [\"Azure\", \"Microsoft 365\", \"Dynamics 365\"]\n",
|
| 294 |
+
"}\n",
|
| 295 |
+
"\n",
|
| 296 |
+
"# Common requirement phrases\n",
|
| 297 |
+
"requirement_keywords = [\"experience in\", \"knowledge of\", \"proficiency in\", \"familiarity with\", \"required\", \"preferred\", \"must have\", \"ability to\"]\n",
|
| 298 |
+
"\n",
|
| 299 |
+
"def extract_skills_and_requirements(description):\n",
|
| 300 |
+
" \"\"\"\n",
|
| 301 |
+
" Extracts relevant skills and job requirements from the job description\n",
|
| 302 |
+
" based on predefined skills and requirement keywords.\n",
|
| 303 |
+
" \"\"\"\n",
|
| 304 |
+
" description = description.lower() # Convert to lowercase for easier matching\n",
|
| 305 |
+
"\n",
|
| 306 |
+
" # Identify matching skills\n",
|
| 307 |
+
" matched_skills = set()\n",
|
| 308 |
+
" for category, skills in my_skills.items():\n",
|
| 309 |
+
" for skill in skills:\n",
|
| 310 |
+
" pattern = rf\"\\b{re.escape(skill.lower())}\\b\"\n",
|
| 311 |
+
" if re.search(pattern, description):\n",
|
| 312 |
+
" matched_skills.add(skill)\n",
|
| 313 |
+
"\n",
|
| 314 |
+
" # Extract job requirements based on common keywords\n",
|
| 315 |
+
" matched_requirements = set()\n",
|
| 316 |
+
" for keyword in requirement_keywords:\n",
|
| 317 |
+
" if keyword in description:\n",
|
| 318 |
+
" matched_requirements.add(keyword)\n",
|
| 319 |
+
"\n",
|
| 320 |
+
" return list(matched_skills), list(matched_requirements)\n",
|
| 321 |
+
"\n",
|
| 322 |
+
"def process_job_descriptions(df, desc_col=\"description\", title_col=\"title\"):\n",
|
| 323 |
+
" \"\"\"\n",
|
| 324 |
+
" Extracts skills and requirements from job descriptions and stores them in the DataFrame.\n",
|
| 325 |
+
" \"\"\"\n",
|
| 326 |
+
" skills_list = []\n",
|
| 327 |
+
" requirements_list = []\n",
|
| 328 |
+
"\n",
|
| 329 |
+
" for idx, row in df.iterrows():\n",
|
| 330 |
+
" description_text = str(row.get(desc_col, \"\"))\n",
|
| 331 |
+
" job_title = str(row.get(title_col, \"No Title Provided\"))\n",
|
| 332 |
+
"\n",
|
| 333 |
+
" if not description_text.strip():\n",
|
| 334 |
+
" skills_list.append([])\n",
|
| 335 |
+
" requirements_list.append([])\n",
|
| 336 |
+
" continue\n",
|
| 337 |
+
" \n",
|
| 338 |
+
" matched_skills, matched_requirements = extract_skills_and_requirements(description_text)\n",
|
| 339 |
+
" skills_list.append(matched_skills)\n",
|
| 340 |
+
" requirements_list.append(matched_requirements)\n",
|
| 341 |
+
"\n",
|
| 342 |
+
" df[\"Extracted Skills\"] = skills_list\n",
|
| 343 |
+
" df[\"Extracted Requirements\"] = requirements_list\n",
|
| 344 |
+
" return df\n",
|
| 345 |
+
"\n",
|
| 346 |
+
"# Process the job descriptions and display results\n",
|
| 347 |
+
"df = process_job_descriptions(df)\n",
|
| 348 |
+
"print(df)\n"
|
| 349 |
+
]
|
| 350 |
+
},
|
| 351 |
+
{
|
| 352 |
+
"cell_type": "markdown",
|
| 353 |
+
"metadata": {},
|
| 354 |
+
"source": [
|
| 355 |
+
"##### Getting some keywords"
|
| 356 |
+
]
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"cell_type": "code",
|
| 360 |
+
"execution_count": null,
|
| 361 |
+
"metadata": {},
|
| 362 |
+
"outputs": [],
|
| 363 |
+
"source": []
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"cell_type": "code",
|
| 367 |
+
"execution_count": null,
|
| 368 |
+
"metadata": {},
|
| 369 |
+
"outputs": [],
|
| 370 |
+
"source": []
|
| 371 |
+
}
|
| 372 |
+
],
|
| 373 |
+
"metadata": {
|
| 374 |
+
"kernelspec": {
|
| 375 |
+
"display_name": "CV_R",
|
| 376 |
+
"language": "python",
|
| 377 |
+
"name": "python3"
|
| 378 |
+
},
|
| 379 |
+
"language_info": {
|
| 380 |
+
"codemirror_mode": {
|
| 381 |
+
"name": "ipython",
|
| 382 |
+
"version": 3
|
| 383 |
+
},
|
| 384 |
+
"file_extension": ".py",
|
| 385 |
+
"mimetype": "text/x-python",
|
| 386 |
+
"name": "python",
|
| 387 |
+
"nbconvert_exporter": "python",
|
| 388 |
+
"pygments_lexer": "ipython3",
|
| 389 |
+
"version": "3.11.11"
|
| 390 |
+
}
|
| 391 |
+
},
|
| 392 |
+
"nbformat": 4,
|
| 393 |
+
"nbformat_minor": 2
|
| 394 |
+
}
|
CV_Tailor/ai_cv_tailor.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import docx
|
| 3 |
+
from docxtpl import DocxTemplate
|
| 4 |
+
from typing import TypedDict, List
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 7 |
+
from langgraph.graph import StateGraph, START, END
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# --- Define State & Schemas ---
|
| 13 |
+
class ResumeState(TypedDict):
|
| 14 |
+
original_resume: str
|
| 15 |
+
job_description: str
|
| 16 |
+
resume_skills: List[str]
|
| 17 |
+
jd_skills: List[str]
|
| 18 |
+
matched_skills: List[str]
|
| 19 |
+
tailored_resume: dict
|
| 20 |
+
|
| 21 |
+
class SkillList(BaseModel):
|
| 22 |
+
skills: List[str] = Field(description="A list of technical skills, languages, frameworks, and databases")
|
| 23 |
+
|
| 24 |
+
class ExperienceItem(BaseModel):
|
| 25 |
+
job_title: str = Field(description="The job title")
|
| 26 |
+
company: str = Field(description="The company name")
|
| 27 |
+
duration: str = Field(description="The time period worked")
|
| 28 |
+
bullets: List[str] = Field(description="3-5 bullet points emphasizing the matched skills")
|
| 29 |
+
|
| 30 |
+
class EducationItem(BaseModel):
|
| 31 |
+
degree: str = Field(description="The name of the degree or certification")
|
| 32 |
+
institution: str = Field(description="The name of the university or institution")
|
| 33 |
+
graduation_date: str = Field(description="The graduation year or timeframe")
|
| 34 |
+
|
| 35 |
+
class ATSResumeOutput(BaseModel):
|
| 36 |
+
name: str = Field(description="The candidate's full name")
|
| 37 |
+
contact_info: str = Field(description="Email, phone, and links (LinkedIn, GitHub)")
|
| 38 |
+
summary: str = Field(description="A professional summary emphasizing matched skills")
|
| 39 |
+
skills: List[str] = Field(description="The optimized list of relevant skills")
|
| 40 |
+
experience: List[ExperienceItem] = Field(description="The candidate's work history")
|
| 41 |
+
education: List[EducationItem] = Field(description="The candidate's educational background")
|
| 42 |
+
|
| 43 |
+
class AILangGraphTailor:
|
| 44 |
+
def __init__(self):
|
| 45 |
+
# Initialize Gemini 2.5 Flash
|
| 46 |
+
# Assumes GOOGLE_API_KEY is loaded in the environment
|
| 47 |
+
self.llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0.2)
|
| 48 |
+
self.app = self._build_graph()
|
| 49 |
+
|
| 50 |
+
def extract_text_from_docx(self, file_path: str) -> str:
|
| 51 |
+
"""Reads a .docx file and returns all text as a single string."""
|
| 52 |
+
try:
|
| 53 |
+
doc = docx.Document(file_path)
|
| 54 |
+
full_text = [para.text for para in doc.paragraphs if para.text.strip()]
|
| 55 |
+
return "\n".join(full_text)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Error reading document: {e}")
|
| 58 |
+
raise
|
| 59 |
+
|
| 60 |
+
def generate_final_docx(self, tailored_dict: dict, template_path: str, output_path: str):
|
| 61 |
+
"""Takes the JSON dictionary from Gemini and renders the final Word document using docxtpl."""
|
| 62 |
+
try:
|
| 63 |
+
doc = DocxTemplate(template_path)
|
| 64 |
+
doc.render(tailored_dict)
|
| 65 |
+
doc.save(output_path)
|
| 66 |
+
logger.info(f"Success! Tailored document saved to {output_path}.")
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Error rendering docxtpl: {e}")
|
| 69 |
+
raise
|
| 70 |
+
|
| 71 |
+
# --- Node Functions ---
|
| 72 |
+
def extract_resume_skills(self, state: ResumeState):
|
| 73 |
+
structured_llm = self.llm.with_structured_output(SkillList)
|
| 74 |
+
prompt = f"Extract all technical skills from this resume. Return only the skills.\n\nResume:\n{state['original_resume']}"
|
| 75 |
+
return {"resume_skills": structured_llm.invoke(prompt).skills}
|
| 76 |
+
|
| 77 |
+
def extract_jd_skills(self, state: ResumeState):
|
| 78 |
+
structured_llm = self.llm.with_structured_output(SkillList)
|
| 79 |
+
prompt = f"Extract all required technical skills from this job description. Return only the skills.\n\nJob Description:\n{state['job_description']}"
|
| 80 |
+
return {"jd_skills": structured_llm.invoke(prompt).skills}
|
| 81 |
+
|
| 82 |
+
def match_skills(self, state: ResumeState):
|
| 83 |
+
resume_set = {s.strip().lower() for s in state['resume_skills']}
|
| 84 |
+
jd_set = {s.strip().lower() for s in state['jd_skills']}
|
| 85 |
+
matches = list(resume_set.intersection(jd_set))
|
| 86 |
+
return {"matched_skills": matches}
|
| 87 |
+
|
| 88 |
+
def tailor_resume(self, state: ResumeState):
|
| 89 |
+
matched_skills_str = ", ".join(state['matched_skills'])
|
| 90 |
+
structured_llm = self.llm.with_structured_output(ATSResumeOutput)
|
| 91 |
+
prompt = f"""
|
| 92 |
+
You are a professional resume writer. Your task is to rewrite the provided 'Original Resume' to strongly emphasize these 'Key Skills to Emphasize': {matched_skills_str}
|
| 93 |
+
|
| 94 |
+
RULES:
|
| 95 |
+
1. Rephrase the summary and experience bullet points to highlight the key skills, and do not use buzzwords while writing any Summary and experience. Keep everything simple.
|
| 96 |
+
2. You MUST NOT add any new skills, projects, experiences, or education that are not in the Original Resume.
|
| 97 |
+
3. Ensure the output is a professional, complete resume.
|
| 98 |
+
|
| 99 |
+
Original Resume:
|
| 100 |
+
---
|
| 101 |
+
{state['original_resume']}
|
| 102 |
+
---
|
| 103 |
+
"""
|
| 104 |
+
tailored_data = structured_llm.invoke(prompt)
|
| 105 |
+
return {"tailored_resume": tailored_data.model_dump()}
|
| 106 |
+
|
| 107 |
+
def _build_graph(self):
|
| 108 |
+
workflow = StateGraph(ResumeState)
|
| 109 |
+
workflow.add_node("extract_resume", self.extract_resume_skills)
|
| 110 |
+
workflow.add_node("extract_jd", self.extract_jd_skills)
|
| 111 |
+
workflow.add_node("match_skills", self.match_skills)
|
| 112 |
+
workflow.add_node("tailor_resume", self.tailor_resume)
|
| 113 |
+
|
| 114 |
+
workflow.add_edge(START, "extract_resume")
|
| 115 |
+
workflow.add_edge("extract_resume", "extract_jd")
|
| 116 |
+
workflow.add_edge("extract_jd", "match_skills")
|
| 117 |
+
workflow.add_edge("match_skills", "tailor_resume")
|
| 118 |
+
workflow.add_edge("tailor_resume", END)
|
| 119 |
+
return workflow.compile()
|
| 120 |
+
|
| 121 |
+
def run_pipeline(self, original_resume_text: str, job_description: str) -> dict:
|
| 122 |
+
"""Executes the LangGraph pipeline."""
|
| 123 |
+
initial_state = {
|
| 124 |
+
"original_resume": original_resume_text,
|
| 125 |
+
"job_description": job_description
|
| 126 |
+
}
|
| 127 |
+
return self.app.invoke(initial_state)
|
DEPLOYMENT_GUIDE.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment & Migration Guide
|
| 2 |
+
|
| 3 |
+
Complete guide for deploying the new React frontend and updated Flask backend.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
This project has been upgraded with:
|
| 8 |
+
- **Modern React 18 Frontend** with Framer Motion animations
|
| 9 |
+
- **Updated Flask Backend** with REST API endpoints
|
| 10 |
+
- **Improved State Management** with Zustand
|
| 11 |
+
- **Professional SaaS Design** with black & emerald colors
|
| 12 |
+
|
| 13 |
+
## Quick Migration Path
|
| 14 |
+
|
| 15 |
+
### For Current Users
|
| 16 |
+
|
| 17 |
+
If you're already running the project with the old HTML interface:
|
| 18 |
+
|
| 19 |
+
1. **Backup your current setup**
|
| 20 |
+
```bash
|
| 21 |
+
cp job_apply_ai/ui/app.py job_apply_ai/ui/app_legacy.py
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
2. **Switch to new Flask app**
|
| 25 |
+
```bash
|
| 26 |
+
# Rename the new app to be the main file
|
| 27 |
+
mv job_apply_ai/ui/app_new.py job_apply_ai/ui/app.py
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
3. **Install frontend dependencies**
|
| 31 |
+
```bash
|
| 32 |
+
cd frontend
|
| 33 |
+
npm install
|
| 34 |
+
cd ..
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
4. **Start both servers**
|
| 38 |
+
```bash
|
| 39 |
+
# Terminal 1: Backend
|
| 40 |
+
python -m job_apply_ai.ui.app
|
| 41 |
+
|
| 42 |
+
# Terminal 2: Frontend (for development)
|
| 43 |
+
cd frontend
|
| 44 |
+
npm run dev
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## Development Setup
|
| 48 |
+
|
| 49 |
+
### Local Development with Hot Reload
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
# Terminal 1: Start Flask backend
|
| 53 |
+
source venv/bin/activate # or venv\Scripts\activate.bat on Windows
|
| 54 |
+
python -m job_apply_ai.ui.app
|
| 55 |
+
|
| 56 |
+
# Terminal 2: Start React dev server
|
| 57 |
+
cd frontend
|
| 58 |
+
npm install # if first time
|
| 59 |
+
npm run dev
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Visit: `http://localhost:3000`
|
| 63 |
+
|
| 64 |
+
### Build Frontend for Production
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
cd frontend
|
| 68 |
+
npm run build
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Output: `frontend/dist/` → copied to `job_apply_ai/ui/static/dist/`
|
| 72 |
+
|
| 73 |
+
## Deployment Options
|
| 74 |
+
|
| 75 |
+
### Option 1: Standalone Flask Server (Recommended)
|
| 76 |
+
|
| 77 |
+
Serves both the React frontend and API from a single Flask server.
|
| 78 |
+
|
| 79 |
+
#### Steps
|
| 80 |
+
|
| 81 |
+
1. **Build Frontend**
|
| 82 |
+
```bash
|
| 83 |
+
cd frontend
|
| 84 |
+
npm run build
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
2. **Install Dependencies**
|
| 88 |
+
```bash
|
| 89 |
+
pip install -r requirements.txt
|
| 90 |
+
pip install flask-cors # New dependency for API
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
3. **Run the Server**
|
| 94 |
+
```bash
|
| 95 |
+
python -m job_apply_ai.ui.app
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
4. **Access the App**
|
| 99 |
+
- Main app: `http://localhost:5050`
|
| 100 |
+
- API endpoints: `http://localhost:5050/api/*`
|
| 101 |
+
|
| 102 |
+
#### Environment Variables
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
# Core
|
| 106 |
+
FLASK_ENV=production
|
| 107 |
+
FLASK_DEBUG=False
|
| 108 |
+
SECRET_KEY=your_random_secret_key_here
|
| 109 |
+
PORT=5050
|
| 110 |
+
|
| 111 |
+
# Storage
|
| 112 |
+
JOB_APPLY_AI_DATA_DIR=/path/to/data
|
| 113 |
+
|
| 114 |
+
# Tailoring
|
| 115 |
+
CV_TAILORING_MODE=local # or 'api'
|
| 116 |
+
CV_ENABLE_SUMMARY_TAILORING=1
|
| 117 |
+
|
| 118 |
+
# For API mode
|
| 119 |
+
LLM_PROVIDER=groq
|
| 120 |
+
GROQ_API_KEY=your_key_here
|
| 121 |
+
GROQ_MODEL=llama-3.3-70b-versatile
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### Option 2: Docker Deployment
|
| 125 |
+
|
| 126 |
+
Create a `Dockerfile`:
|
| 127 |
+
|
| 128 |
+
```dockerfile
|
| 129 |
+
FROM node:18-alpine as frontend
|
| 130 |
+
WORKDIR /app/frontend
|
| 131 |
+
COPY frontend/package*.json ./
|
| 132 |
+
RUN npm install
|
| 133 |
+
COPY frontend/ .
|
| 134 |
+
RUN npm run build
|
| 135 |
+
|
| 136 |
+
FROM python:3.11-slim
|
| 137 |
+
WORKDIR /app
|
| 138 |
+
COPY requirements.txt .
|
| 139 |
+
RUN pip install -r requirements.txt && pip install flask-cors gunicorn
|
| 140 |
+
|
| 141 |
+
COPY . .
|
| 142 |
+
COPY --from=frontend /app/frontend/dist ./job_apply_ai/ui/static/dist
|
| 143 |
+
|
| 144 |
+
ENV FLASK_ENV=production
|
| 145 |
+
ENV PYTHONUNBUFFERED=1
|
| 146 |
+
|
| 147 |
+
EXPOSE 5050
|
| 148 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:5050", "job_apply_ai.ui.app:app"]
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
Build and run:
|
| 152 |
+
```bash
|
| 153 |
+
docker build -t job-apply-ai .
|
| 154 |
+
docker run -p 5050:5050 job-apply-ai
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### Option 3: Separate Frontend & Backend
|
| 158 |
+
|
| 159 |
+
Run React and Flask on different servers (useful for separate scaling).
|
| 160 |
+
|
| 161 |
+
#### Backend (Flask)
|
| 162 |
+
```bash
|
| 163 |
+
FLASK_ENV=production python -m job_apply_ai.ui.app
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
#### Frontend (Nginx/Vercel/etc)
|
| 167 |
+
```bash
|
| 168 |
+
# Build
|
| 169 |
+
cd frontend
|
| 170 |
+
npm run build
|
| 171 |
+
|
| 172 |
+
# Deploy to Vercel, Netlify, or your own server
|
| 173 |
+
npm install -g vercel
|
| 174 |
+
vercel deploy dist
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
In `vite.config.ts`, update API URL:
|
| 178 |
+
```typescript
|
| 179 |
+
server: {
|
| 180 |
+
proxy: {
|
| 181 |
+
'/api': {
|
| 182 |
+
target: 'https://your-backend.com', // Change to production backend
|
| 183 |
+
changeOrigin: true,
|
| 184 |
+
rewrite: (path) => path.replace(/^\/api/, '')
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
## Production Checklist
|
| 191 |
+
|
| 192 |
+
- [ ] Set `FLASK_DEBUG=False`
|
| 193 |
+
- [ ] Use `sqlite` or `postgresql` for sessions (not default)
|
| 194 |
+
- [ ] Set strong `SECRET_KEY`
|
| 195 |
+
- [ ] Configure CORS properly for your domain
|
| 196 |
+
- [ ] Enable HTTPS in production
|
| 197 |
+
- [ ] Set appropriate resource limits
|
| 198 |
+
- [ ] Configure backup for generated CVs
|
| 199 |
+
- [ ] Set up monitoring/logging
|
| 200 |
+
- [ ] Test file uploads with various CV formats
|
| 201 |
+
- [ ] Verify batch processing stability with many jobs
|
| 202 |
+
|
| 203 |
+
## Performance Optimization
|
| 204 |
+
|
| 205 |
+
### Frontend
|
| 206 |
+
```bash
|
| 207 |
+
# Analyze bundle
|
| 208 |
+
npm install -g vite-plugin-visualizer
|
| 209 |
+
npm run build -- --analyze
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
### Backend
|
| 213 |
+
```python
|
| 214 |
+
# Use production-grade WSGI server
|
| 215 |
+
pip install gunicorn
|
| 216 |
+
gunicorn -w 4 -b 0.0.0.0:5050 job_apply_ai.ui.app:app
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
### Caching
|
| 220 |
+
- Enable browser caching for static assets
|
| 221 |
+
- Use Redis for session storage (optional)
|
| 222 |
+
- Implement job result caching
|
| 223 |
+
|
| 224 |
+
## Scaling Considerations
|
| 225 |
+
|
| 226 |
+
### Horizontal Scaling
|
| 227 |
+
- Use reverse proxy (Nginx) to load balance
|
| 228 |
+
- Store sessions in Redis or database
|
| 229 |
+
- Use shared storage for generated CVs
|
| 230 |
+
|
| 231 |
+
### Vertical Scaling
|
| 232 |
+
- Increase worker processes for Flask/Gunicorn
|
| 233 |
+
- Allocate more memory for CV processing
|
| 234 |
+
- Use distributed task queue for batch jobs
|
| 235 |
+
|
| 236 |
+
## Troubleshooting
|
| 237 |
+
|
| 238 |
+
### Frontend won't load
|
| 239 |
+
```bash
|
| 240 |
+
# Check if Flask is running
|
| 241 |
+
curl http://localhost:5050
|
| 242 |
+
|
| 243 |
+
# Check if React build exists
|
| 244 |
+
ls job_apply_ai/ui/static/dist/index.html
|
| 245 |
+
|
| 246 |
+
# Verify CORS is enabled
|
| 247 |
+
npm run dev # Use dev server instead
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
### API endpoints returning 404
|
| 251 |
+
- Ensure `app_new.py` is being used (not legacy `app.py`)
|
| 252 |
+
- Check Flask logs for errors
|
| 253 |
+
- Verify API routes match frontend expectations
|
| 254 |
+
|
| 255 |
+
### File upload fails
|
| 256 |
+
- Check upload folder permissions
|
| 257 |
+
- Verify disk space available
|
| 258 |
+
- Check file size limits in Flask config
|
| 259 |
+
|
| 260 |
+
### Memory issues
|
| 261 |
+
- Reduce max job batch size
|
| 262 |
+
- Implement streaming for large CVs
|
| 263 |
+
- Use separate worker processes
|
| 264 |
+
|
| 265 |
+
## Rollback Procedure
|
| 266 |
+
|
| 267 |
+
If issues occur:
|
| 268 |
+
|
| 269 |
+
```bash
|
| 270 |
+
# Restore old Flask app
|
| 271 |
+
mv job_apply_ai/ui/app.py job_apply_ai/ui/app_new.py
|
| 272 |
+
mv job_apply_ai/ui/app_legacy.py job_apply_ai/ui/app.py
|
| 273 |
+
|
| 274 |
+
# Restart server
|
| 275 |
+
python -m job_apply_ai.ui.app
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
## Support
|
| 279 |
+
|
| 280 |
+
For issues or questions:
|
| 281 |
+
1. Check frontend logs in browser DevTools
|
| 282 |
+
2. Check Flask logs in terminal
|
| 283 |
+
3. Review specific error messages
|
| 284 |
+
4. Contact project maintainers
|
| 285 |
+
|
| 286 |
+
## Next Steps
|
| 287 |
+
|
| 288 |
+
- [ ] Test the new React frontend thoroughly
|
| 289 |
+
- [ ] Migrate user data if needed
|
| 290 |
+
- [ ] Train users on new interface
|
| 291 |
+
- [ ] Monitor performance and stability
|
| 292 |
+
- [ ] Gather feedback for improvements
|
DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Documentation Index
|
| 2 |
+
|
| 3 |
+
Complete documentation guide for Job Apply AI React SaaS Edition.
|
| 4 |
+
|
| 5 |
+
## 📚 Start Here
|
| 6 |
+
|
| 7 |
+
### For First-Time Users
|
| 8 |
+
1. **[QUICK_START.md](QUICK_START.md)** - 5-minute setup guide
|
| 9 |
+
- Installation steps
|
| 10 |
+
- Running the application
|
| 11 |
+
- Basic troubleshooting
|
| 12 |
+
- Common tasks
|
| 13 |
+
|
| 14 |
+
### For Existing Users
|
| 15 |
+
1. **[CHANGES.md](CHANGES.md)** - What's new in v2.0
|
| 16 |
+
- Complete list of changes
|
| 17 |
+
- Migration guide
|
| 18 |
+
- Backward compatibility
|
| 19 |
+
- Performance improvements
|
| 20 |
+
|
| 21 |
+
## 🚀 Getting Started
|
| 22 |
+
|
| 23 |
+
### Setup & Installation
|
| 24 |
+
- [QUICK_START.md](QUICK_START.md) - Complete setup instructions
|
| 25 |
+
- [frontend/README.md](frontend/README.md) - React frontend guide
|
| 26 |
+
- [requirements.txt](requirements.txt) - Python dependencies
|
| 27 |
+
- [frontend/package.json](frontend/package.json) - Node dependencies
|
| 28 |
+
|
| 29 |
+
### Running the Application
|
| 30 |
+
```bash
|
| 31 |
+
# Backend
|
| 32 |
+
python -m job_apply_ai.ui.app_new
|
| 33 |
+
|
| 34 |
+
# Frontend
|
| 35 |
+
cd frontend && npm run dev
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
Visit: **http://localhost:3000**
|
| 39 |
+
|
| 40 |
+
## 📖 Documentation Topics
|
| 41 |
+
|
| 42 |
+
### User Documentation
|
| 43 |
+
|
| 44 |
+
| Document | Purpose | For Whom |
|
| 45 |
+
|----------|---------|----------|
|
| 46 |
+
| [README.md](README.md) | Project overview | Everyone |
|
| 47 |
+
| [QUICK_START.md](QUICK_START.md) | 5-minute setup | Users |
|
| 48 |
+
| [SAAS_FEATURES.md](SAAS_FEATURES.md) | Feature guide | Users |
|
| 49 |
+
|
| 50 |
+
### Developer Documentation
|
| 51 |
+
|
| 52 |
+
| Document | Purpose | For Whom |
|
| 53 |
+
|----------|---------|----------|
|
| 54 |
+
| [ARCHITECTURE.md](ARCHITECTURE.md) | Technical deep-dive | Developers |
|
| 55 |
+
| [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) | Production deployment | DevOps/Developers |
|
| 56 |
+
| [CHANGES.md](CHANGES.md) | What changed | Developers |
|
| 57 |
+
| [frontend/README.md](frontend/README.md) | React app guide | Frontend Developers |
|
| 58 |
+
|
| 59 |
+
### Component Documentation
|
| 60 |
+
|
| 61 |
+
| Location | Purpose |
|
| 62 |
+
|----------|---------|
|
| 63 |
+
| [frontend/src/components/common/](frontend/src/components/common/) | Reusable components |
|
| 64 |
+
| [frontend/src/components/pages/](frontend/src/components/pages/) | Full page layouts |
|
| 65 |
+
| [frontend/src/components/sections/](frontend/src/components/sections/) | Page sections |
|
| 66 |
+
|
| 67 |
+
## 🎯 Common Tasks
|
| 68 |
+
|
| 69 |
+
### I want to...
|
| 70 |
+
|
| 71 |
+
**...use the application**
|
| 72 |
+
→ [QUICK_START.md](QUICK_START.md)
|
| 73 |
+
|
| 74 |
+
**...understand the new features**
|
| 75 |
+
→ [SAAS_FEATURES.md](SAAS_FEATURES.md)
|
| 76 |
+
|
| 77 |
+
**...deploy to production**
|
| 78 |
+
→ [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md)
|
| 79 |
+
|
| 80 |
+
**...develop new features**
|
| 81 |
+
→ [ARCHITECTURE.md](ARCHITECTURE.md) + [frontend/README.md](frontend/README.md)
|
| 82 |
+
|
| 83 |
+
**...migrate from old version**
|
| 84 |
+
→ [CHANGES.md](CHANGES.md)
|
| 85 |
+
|
| 86 |
+
**...build the frontend**
|
| 87 |
+
→ [frontend/README.md](frontend/README.md) - Build section
|
| 88 |
+
|
| 89 |
+
**...customize colors/theme**
|
| 90 |
+
→ [frontend/README.md](frontend/README.md) - Styling section
|
| 91 |
+
|
| 92 |
+
**...add a new component**
|
| 93 |
+
→ [ARCHITECTURE.md](ARCHITECTURE.md) - Component Hierarchy
|
| 94 |
+
|
| 95 |
+
## 🗂️ File Structure
|
| 96 |
+
|
| 97 |
+
```
|
| 98 |
+
Job-apply-AI/
|
| 99 |
+
├── frontend/ # React SaaS Application
|
| 100 |
+
│ ├── src/
|
| 101 |
+
│ │ ├── components/ # 25+ reusable components
|
| 102 |
+
│ │ ├── store/ # Zustand state management
|
| 103 |
+
│ │ ├── types/ # TypeScript definitions
|
| 104 |
+
│ │ ├── utils/ # API client and helpers
|
| 105 |
+
│ │ ├── styles/ # Global styling
|
| 106 |
+
│ │ ├── App.tsx # Root component
|
| 107 |
+
│ │ └── main.tsx # Entry point
|
| 108 |
+
│ ├── public/ # Static assets
|
| 109 |
+
│ ├── index.html # HTML template
|
| 110 |
+
│ ├── package.json # Node dependencies
|
| 111 |
+
│ ├── tsconfig.json # TypeScript config
|
| 112 |
+
│ ├── tailwind.config.js # Theme configuration
|
| 113 |
+
│ ├── vite.config.ts # Build configuration
|
| 114 |
+
│ ├── postcss.config.js # CSS processing
|
| 115 |
+
│ ├── README.md # Frontend documentation
|
| 116 |
+
│ └── .env.example # Environment template
|
| 117 |
+
│
|
| 118 |
+
├── job_apply_ai/ # Python Backend
|
| 119 |
+
│ ├── ui/
|
| 120 |
+
│ │ ├── app_new.py # New React-compatible Flask (USE THIS)
|
| 121 |
+
│ │ ├── app.py # Legacy HTML Flask (backup)
|
| 122 |
+
│ │ ├── templates/ # Legacy HTML templates
|
| 123 |
+
│ │ └── static/ # Serve React build here
|
| 124 |
+
│ ├── scraper/ # Job scraping logic
|
| 125 |
+
│ ├── cv_modifier/ # CV customization logic
|
| 126 |
+
│ └── utils/ # Helper utilities
|
| 127 |
+
│
|
| 128 |
+
├── README.md # Project overview
|
| 129 |
+
├── QUICK_START.md # 5-minute setup
|
| 130 |
+
├── CHANGES.md # What's new
|
| 131 |
+
├── SAAS_FEATURES.md # Feature guide
|
| 132 |
+
├── ARCHITECTURE.md # Technical details
|
| 133 |
+
├── DEPLOYMENT_GUIDE.md # Production guide
|
| 134 |
+
├── requirements.txt # Python dependencies
|
| 135 |
+
├── install.sh # Linux/Mac installer
|
| 136 |
+
├── install.bat # Windows installer
|
| 137 |
+
└── .env.example # Environment template
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
## 🔗 Direct Links
|
| 141 |
+
|
| 142 |
+
### Configuration Files
|
| 143 |
+
- [frontend/package.json](frontend/package.json) - Node packages
|
| 144 |
+
- [frontend/tsconfig.json](frontend/tsconfig.json) - TypeScript
|
| 145 |
+
- [frontend/tailwind.config.js](frontend/tailwind.config.js) - Theme colors
|
| 146 |
+
- [frontend/vite.config.ts](frontend/vite.config.ts) - Build config
|
| 147 |
+
- [requirements.txt](requirements.txt) - Python packages
|
| 148 |
+
- [.env.example](.env.example) - Environment variables
|
| 149 |
+
|
| 150 |
+
### Source Code
|
| 151 |
+
- [frontend/src/App.tsx](frontend/src/App.tsx) - Main app component
|
| 152 |
+
- [frontend/src/store/appStore.ts](frontend/src/store/appStore.ts) - State store
|
| 153 |
+
- [frontend/src/utils/api.ts](frontend/src/utils/api.ts) - API client
|
| 154 |
+
- [job_apply_ai/ui/app_new.py](job_apply_ai/ui/app_new.py) - Flask backend
|
| 155 |
+
|
| 156 |
+
## 🎓 Learning Paths
|
| 157 |
+
|
| 158 |
+
### For React Developers
|
| 159 |
+
1. [QUICK_START.md](QUICK_START.md) - Get it running
|
| 160 |
+
2. [frontend/README.md](frontend/README.md) - Learn the structure
|
| 161 |
+
3. [ARCHITECTURE.md](ARCHITECTURE.md) - Understand the design
|
| 162 |
+
4. Explore `frontend/src/components/` - See the code
|
| 163 |
+
|
| 164 |
+
### For Backend Developers
|
| 165 |
+
1. [QUICK_START.md](QUICK_START.md) - Get it running
|
| 166 |
+
2. [ARCHITECTURE.md](ARCHITECTURE.md) - System design
|
| 167 |
+
3. [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Production setup
|
| 168 |
+
4. [job_apply_ai/ui/app_new.py](job_apply_ai/ui/app_new.py) - Review code
|
| 169 |
+
|
| 170 |
+
### For DevOps/Sys Admins
|
| 171 |
+
1. [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Deployment options
|
| 172 |
+
2. [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture
|
| 173 |
+
3. [QUICK_START.md](QUICK_START.md) - Local testing
|
| 174 |
+
4. Deployment section in DEPLOYMENT_GUIDE.md
|
| 175 |
+
|
| 176 |
+
### For Product Managers
|
| 177 |
+
1. [README.md](README.md) - Project overview
|
| 178 |
+
2. [SAAS_FEATURES.md](SAAS_FEATURES.md) - Feature details
|
| 179 |
+
3. [CHANGES.md](CHANGES.md) - Version updates
|
| 180 |
+
4. Future section in SAAS_FEATURES.md
|
| 181 |
+
|
| 182 |
+
## 🐛 Troubleshooting
|
| 183 |
+
|
| 184 |
+
### Setup Issues
|
| 185 |
+
→ [QUICK_START.md](QUICK_START.md) - Troubleshooting section
|
| 186 |
+
|
| 187 |
+
### Production Issues
|
| 188 |
+
→ [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Troubleshooting section
|
| 189 |
+
|
| 190 |
+
### API Integration
|
| 191 |
+
→ [ARCHITECTURE.md](ARCHITECTURE.md) - Request/Response section
|
| 192 |
+
|
| 193 |
+
### Component Development
|
| 194 |
+
→ [frontend/README.md](frontend/README.md) - Components section
|
| 195 |
+
|
| 196 |
+
## 🚀 Quick Reference
|
| 197 |
+
|
| 198 |
+
### Commands
|
| 199 |
+
|
| 200 |
+
**Development**
|
| 201 |
+
```bash
|
| 202 |
+
npm run dev # Start dev server
|
| 203 |
+
npm run build # Build for production
|
| 204 |
+
npm run type-check # Check TypeScript
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
**Python**
|
| 208 |
+
```bash
|
| 209 |
+
python -m job_apply_ai.ui.app_new # Start Flask backend
|
| 210 |
+
pip install -r requirements.txt # Install dependencies
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
### URLs
|
| 214 |
+
|
| 215 |
+
| URL | Purpose |
|
| 216 |
+
|-----|---------|
|
| 217 |
+
| http://localhost:3000 | React frontend (dev) |
|
| 218 |
+
| http://localhost:5050 | Flask backend (API) |
|
| 219 |
+
| http://localhost:5050 | React frontend (production) |
|
| 220 |
+
|
| 221 |
+
### API Endpoints
|
| 222 |
+
|
| 223 |
+
| Method | Endpoint | Purpose |
|
| 224 |
+
|--------|----------|---------|
|
| 225 |
+
| POST | /api/upload-cv | Upload CV template |
|
| 226 |
+
| POST | /api/search | Search for jobs |
|
| 227 |
+
| GET | /api/jobs | Get stored jobs |
|
| 228 |
+
| POST | /api/generate-cv/<id> | Generate single CV |
|
| 229 |
+
| POST | /api/generate-all-cvs | Batch generate CVs |
|
| 230 |
+
| GET | /api/download/<filename> | Download file |
|
| 231 |
+
|
| 232 |
+
## 📞 Help & Support
|
| 233 |
+
|
| 234 |
+
### Where to Find Answers
|
| 235 |
+
|
| 236 |
+
| Question | Resource |
|
| 237 |
+
|----------|----------|
|
| 238 |
+
| How do I start using it? | [QUICK_START.md](QUICK_START.md) |
|
| 239 |
+
| What's new in this version? | [CHANGES.md](CHANGES.md) |
|
| 240 |
+
| How does it work technically? | [ARCHITECTURE.md](ARCHITECTURE.md) |
|
| 241 |
+
| How do I deploy it? | [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) |
|
| 242 |
+
| How do I develop new features? | [frontend/README.md](frontend/README.md) |
|
| 243 |
+
| What features exist? | [SAAS_FEATURES.md](SAAS_FEATURES.md) |
|
| 244 |
+
|
| 245 |
+
## ✅ Documentation Checklist
|
| 246 |
+
|
| 247 |
+
- [x] Main README updated with React info
|
| 248 |
+
- [x] Quick start guide created
|
| 249 |
+
- [x] Features documentation created
|
| 250 |
+
- [x] Architecture documentation created
|
| 251 |
+
- [x] Deployment guide created
|
| 252 |
+
- [x] Changes/migration guide created
|
| 253 |
+
- [x] Frontend README created
|
| 254 |
+
- [x] Component documentation in code
|
| 255 |
+
- [x] API documentation in ARCHITECTURE
|
| 256 |
+
- [x] Troubleshooting guides included
|
| 257 |
+
- [x] This documentation index created
|
| 258 |
+
|
| 259 |
+
## 📊 Statistics
|
| 260 |
+
|
| 261 |
+
| Metric | Count |
|
| 262 |
+
|--------|-------|
|
| 263 |
+
| Total Components | 25+ |
|
| 264 |
+
| Total Pages | 4 |
|
| 265 |
+
| Lines of React Code | 2000+ |
|
| 266 |
+
| Lines of Python Code | 500+ |
|
| 267 |
+
| Documentation Pages | 8 |
|
| 268 |
+
| Configuration Files | 5 |
|
| 269 |
+
| API Endpoints | 7 |
|
| 270 |
+
|
| 271 |
+
## 🎉 Version History
|
| 272 |
+
|
| 273 |
+
| Version | Date | Changes |
|
| 274 |
+
|---------|------|---------|
|
| 275 |
+
| 2.0.0 | Jan 2024 | React SaaS frontend, modern design |
|
| 276 |
+
| 1.0.0 | - | Original HTML/Flask interface |
|
| 277 |
+
|
| 278 |
+
## 📝 Notes
|
| 279 |
+
|
| 280 |
+
- All documentation is kept in individual markdown files for easy navigation
|
| 281 |
+
- Code examples are provided where relevant
|
| 282 |
+
- Links throughout docs help navigate between related topics
|
| 283 |
+
- Each doc is self-contained but references related docs
|
| 284 |
+
- Keep documentation updated as features change
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
**Last Updated**: January 2024
|
| 289 |
+
**Status**: Complete and Production Ready
|
| 290 |
+
**Maintainer**: Job Apply AI Team
|
| 291 |
+
|
| 292 |
+
**Start with:** [QUICK_START.md](QUICK_START.md) ➡️
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1. Use your exact requested Python version
|
| 2 |
+
FROM python:3.13.4-slim
|
| 3 |
+
|
| 4 |
+
# 2. Set environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
ENV PORT=7860
|
| 8 |
+
|
| 9 |
+
# 3. Set the working directory
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# 4. Install OS-level dependencies
|
| 13 |
+
# (REMOVED Chromium! Just keeping the essentials needed to compile Python 3.13 packages)
|
| 14 |
+
RUN apt-get update && apt-get install -y \
|
| 15 |
+
build-essential \
|
| 16 |
+
wget \
|
| 17 |
+
curl \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
# 5. Copy the requirements file
|
| 21 |
+
COPY f_requirements.txt .
|
| 22 |
+
|
| 23 |
+
# 6. Install Python dependencies
|
| 24 |
+
# Selenium will install here as a harmless "dummy" library since it's never called.
|
| 25 |
+
RUN pip install --upgrade pip && \
|
| 26 |
+
pip install --no-cache-dir -r f_requirements.txt
|
| 27 |
+
|
| 28 |
+
# 7. Download required NLTK data for the CV Analyzer fallback
|
| 29 |
+
RUN python -c "import nltk; nltk.download('stopwords'); nltk.download('punkt'); nltk.download('punkt_tab')"
|
| 30 |
+
|
| 31 |
+
# 8. Copy the entire jumbled repository into the container
|
| 32 |
+
COPY . .
|
| 33 |
+
|
| 34 |
+
# 9. Create runtime directories and grant write permissions
|
| 35 |
+
RUN mkdir -p .runtime/uploads/cvs .runtime/uploads/jobs .runtime/uploads/session_state && \
|
| 36 |
+
chmod -R 777 .runtime
|
| 37 |
+
|
| 38 |
+
# 10. Expose the Hugging Face port
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
# 11. Run your React-compatible Flask app
|
| 42 |
+
CMD ["python", "app_new.py"]
|
QUICK_START.md
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quick Start Guide - Modern React SaaS
|
| 2 |
+
|
| 3 |
+
Complete setup and run instructions for the new Job Apply AI React frontend.
|
| 4 |
+
|
| 5 |
+
## 📋 What's New
|
| 6 |
+
|
| 7 |
+
✨ **Modern React 18 Frontend**
|
| 8 |
+
- Beautiful dark SaaS theme (black + emerald green)
|
| 9 |
+
- Smooth Framer Motion animations
|
| 10 |
+
- Professional component library
|
| 11 |
+
- Responsive design for all devices
|
| 12 |
+
|
| 13 |
+
🔄 **Improved State Management**
|
| 14 |
+
- Zustand for reactive state
|
| 15 |
+
- Persistent storage
|
| 16 |
+
- Clean API
|
| 17 |
+
|
| 18 |
+
⚡ **Better Performance**
|
| 19 |
+
- Optimized React build
|
| 20 |
+
- Code splitting
|
| 21 |
+
- CSS-in-JS with Tailwind
|
| 22 |
+
|
| 23 |
+
🛠️ **REST API Endpoints**
|
| 24 |
+
- Proper API structure
|
| 25 |
+
- CORS enabled
|
| 26 |
+
- Easy to extend
|
| 27 |
+
|
| 28 |
+
## 🚀 Quick Start (5 minutes)
|
| 29 |
+
|
| 30 |
+
### 1️⃣ Install Dependencies
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
# Python backend
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
pip install flask-cors
|
| 36 |
+
|
| 37 |
+
# React frontend
|
| 38 |
+
cd frontend
|
| 39 |
+
npm install
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### 2️⃣ Start the Backend (Terminal 1)
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
# Activate virtual environment
|
| 46 |
+
source venv/bin/activate # Linux/Mac
|
| 47 |
+
# OR
|
| 48 |
+
venv\Scripts\activate.bat # Windows
|
| 49 |
+
|
| 50 |
+
# Run Flask server
|
| 51 |
+
python -m job_apply_ai.ui.app_new
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
You should see:
|
| 55 |
+
```
|
| 56 |
+
Running on http://0.0.0.0:5050
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### 3️⃣ Start the Frontend (Terminal 2)
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
cd frontend
|
| 63 |
+
npm run dev
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
You should see:
|
| 67 |
+
```
|
| 68 |
+
VITE v5.0.0 ready in 123 ms
|
| 69 |
+
➜ Local: http://localhost:3000/
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### 4️⃣ Open Your Browser
|
| 73 |
+
|
| 74 |
+
Visit: **http://localhost:3000**
|
| 75 |
+
|
| 76 |
+
You should see the beautiful Job Apply AI home page! 🎉
|
| 77 |
+
|
| 78 |
+
## 📖 How to Use
|
| 79 |
+
|
| 80 |
+
### Home Page
|
| 81 |
+
- Overview of features
|
| 82 |
+
- Call-to-action buttons
|
| 83 |
+
- Statistics
|
| 84 |
+
|
| 85 |
+
### Workflow
|
| 86 |
+
1. **Upload CV** - Drag and drop your .docx file
|
| 87 |
+
2. **Search Jobs** - Enter job title and location
|
| 88 |
+
3. **Review Jobs** - See matched skills for each job
|
| 89 |
+
4. **Generate CVs** - Select jobs and create tailored CVs
|
| 90 |
+
5. **Download** - Get all CVs as a ZIP file
|
| 91 |
+
|
| 92 |
+
### Settings
|
| 93 |
+
- Toggle between "Smart Mode" (local) and "AI Mode" (with API)
|
| 94 |
+
- Configure LLM provider
|
| 95 |
+
- Enable/disable professional summaries
|
| 96 |
+
|
| 97 |
+
## 🎨 Customization
|
| 98 |
+
|
| 99 |
+
### Change Theme Colors
|
| 100 |
+
|
| 101 |
+
Edit `frontend/tailwind.config.js`:
|
| 102 |
+
|
| 103 |
+
```javascript
|
| 104 |
+
colors: {
|
| 105 |
+
emerald: {
|
| 106 |
+
500: '#22c55e', // Change to your color
|
| 107 |
+
// ... other shades
|
| 108 |
+
},
|
| 109 |
+
black: '#0A0E27', // Or your dark color
|
| 110 |
+
// Custom colors here
|
| 111 |
+
}
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### Modify Animations
|
| 115 |
+
|
| 116 |
+
Edit `frontend/src/components/common/Button.tsx`:
|
| 117 |
+
|
| 118 |
+
```tsx
|
| 119 |
+
whileHover={{ y: -2 }} // Change hover distance
|
| 120 |
+
whileTap={{ scale: 0.98 }} // Change tap scale
|
| 121 |
+
transition={{ duration: 0.3 }} // Change duration
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### Add New Pages
|
| 125 |
+
|
| 126 |
+
1. Create `frontend/src/components/pages/NewPage.tsx`
|
| 127 |
+
2. Import in `frontend/src/App.tsx`
|
| 128 |
+
3. Add route logic in App component
|
| 129 |
+
|
| 130 |
+
Example:
|
| 131 |
+
```tsx
|
| 132 |
+
{currentPage === 'newpage' && (
|
| 133 |
+
<NewPage onBack={() => setCurrentPage('home')} />
|
| 134 |
+
)}
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## 🔧 Development Commands
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
# Frontend
|
| 141 |
+
cd frontend
|
| 142 |
+
|
| 143 |
+
npm run dev # Start dev server
|
| 144 |
+
npm run build # Build for production
|
| 145 |
+
npm run preview # Preview production build
|
| 146 |
+
npm run type-check # Check TypeScript
|
| 147 |
+
npm run lint # Run ESLint
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## 📁 Project Structure
|
| 151 |
+
|
| 152 |
+
```
|
| 153 |
+
Job-apply-AI/
|
| 154 |
+
├── frontend/ # React app (NEW)
|
| 155 |
+
│ ├── src/
|
| 156 |
+
│ │ ├── components/
|
| 157 |
+
│ │ │ ├── common/ # Reusable components
|
| 158 |
+
│ │ │ ├── pages/ # Full pages
|
| 159 |
+
│ │ │ └── sections/ # Page sections
|
| 160 |
+
│ │ ├── store/ # Zustand store
|
| 161 |
+
│ │ ├── types/ # TypeScript types
|
| 162 |
+
│ │ ├── utils/
|
| 163 |
+
│ │ │ ├── api.ts # API calls
|
| 164 |
+
│ │ │ └── helpers.ts # Utilities
|
| 165 |
+
│ │ ├── styles/ # Global CSS
|
| 166 |
+
│ │ ├── App.tsx # Root component
|
| 167 |
+
│ │ └── main.tsx # Entry point
|
| 168 |
+
│ ├── tailwind.config.js # Theme config
|
| 169 |
+
│ ├── vite.config.ts # Build config
|
| 170 |
+
│ └── package.json
|
| 171 |
+
│
|
| 172 |
+
├── job_apply_ai/ # Python backend
|
| 173 |
+
│ ├── ui/
|
| 174 |
+
│ │ ├── app_new.py # New Flask app (UPDATED)
|
| 175 |
+
│ │ └── app.py # Old Flask app (legacy)
|
| 176 |
+
│ ├── scraper/ # Job scraping
|
| 177 |
+
│ ├── cv_modifier/ # CV customization
|
| 178 |
+
│ └── utils/ # Helpers
|
| 179 |
+
│
|
| 180 |
+
├── requirements.txt # Python deps (UPDATED)
|
| 181 |
+
├── DEPLOYMENT_GUIDE.md # Deployment (NEW)
|
| 182 |
+
├── SAAS_FEATURES.md # Features doc (NEW)
|
| 183 |
+
└── README.md # Main docs (UPDATED)
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
## 🚀 Deploying to Production
|
| 187 |
+
|
| 188 |
+
### Build the frontend
|
| 189 |
+
|
| 190 |
+
```bash
|
| 191 |
+
cd frontend
|
| 192 |
+
npm run build
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
This creates `frontend/dist/` with optimized files that Flask will serve.
|
| 196 |
+
|
| 197 |
+
### Run with production settings
|
| 198 |
+
|
| 199 |
+
```bash
|
| 200 |
+
# Set environment variables
|
| 201 |
+
export FLASK_ENV=production
|
| 202 |
+
export FLASK_DEBUG=False
|
| 203 |
+
export SECRET_KEY=your_random_secret_key
|
| 204 |
+
|
| 205 |
+
# Run with gunicorn
|
| 206 |
+
pip install gunicorn
|
| 207 |
+
gunicorn -w 4 -b 0.0.0.0:5050 job_apply_ai.ui.app:app
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
Visit `http://localhost:5050` to see the production build.
|
| 211 |
+
|
| 212 |
+
## 🐛 Troubleshooting
|
| 213 |
+
|
| 214 |
+
### Frontend won't load
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
# Make sure backend is running
|
| 218 |
+
curl http://localhost:5050
|
| 219 |
+
|
| 220 |
+
# Check React dev server
|
| 221 |
+
# Should see "Ready in XXX ms" in terminal
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
### API calls failing
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
# Check if endpoints exist
|
| 228 |
+
curl http://localhost:5050/api/health
|
| 229 |
+
|
| 230 |
+
# Should return: {"status":"ok","version":"2.0.0"}
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
### Styles look wrong
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
# Restart dev server
|
| 237 |
+
npm run dev
|
| 238 |
+
|
| 239 |
+
# Clear cache
|
| 240 |
+
rm -rf node_modules/.vite
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
### Port already in use
|
| 244 |
+
|
| 245 |
+
```bash
|
| 246 |
+
# Change port in vite.config.ts or terminal
|
| 247 |
+
npm run dev -- --port 3001
|
| 248 |
+
|
| 249 |
+
# Or change Flask port
|
| 250 |
+
export PORT=5051
|
| 251 |
+
python -m job_apply_ai.ui.app_new
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
## 📚 Key Files to Know
|
| 255 |
+
|
| 256 |
+
### Frontend
|
| 257 |
+
|
| 258 |
+
- **App.tsx** - Main app logic, page routing
|
| 259 |
+
- **appStore.ts** - All state management
|
| 260 |
+
- **api.ts** - Backend API calls
|
| 261 |
+
- **tailwind.config.js** - Colors, themes, animations
|
| 262 |
+
- **Job components** - Job search, listing, generation
|
| 263 |
+
|
| 264 |
+
### Backend
|
| 265 |
+
|
| 266 |
+
- **app_new.py** - Flask server with React routing + APIs
|
| 267 |
+
- **linkedin.py** - Job scraping
|
| 268 |
+
- **cv_analyzer.py** - Skill extraction
|
| 269 |
+
- **cv_modifier.py** - CV customization
|
| 270 |
+
|
| 271 |
+
## 🎯 Common Tasks
|
| 272 |
+
|
| 273 |
+
### Add a new API endpoint
|
| 274 |
+
|
| 275 |
+
1. Add backend method in `app_new.py`:
|
| 276 |
+
```python
|
| 277 |
+
@app.route('/api/my-endpoint', methods=['POST'])
|
| 278 |
+
def my_endpoint():
|
| 279 |
+
return jsonify({'success': True})
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
2. Add frontend call in `api.ts`:
|
| 283 |
+
```typescript
|
| 284 |
+
async myAPICall() {
|
| 285 |
+
const response = await api.post('/my-endpoint');
|
| 286 |
+
return response.data;
|
| 287 |
+
}
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
3. Use in component:
|
| 291 |
+
```tsx
|
| 292 |
+
const handleClick = async () => {
|
| 293 |
+
const data = await jobsAPI.myAPICall();
|
| 294 |
+
setNotification({ type: 'success', message: 'Done!' });
|
| 295 |
+
}
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
### Change color scheme
|
| 299 |
+
|
| 300 |
+
All colors are in `tailwind.config.js` and `globals.css`. Change:
|
| 301 |
+
- `emerald` colors for primary actions
|
| 302 |
+
- `slate` colors for text/backgrounds
|
| 303 |
+
- `black` for dark mode
|
| 304 |
+
|
| 305 |
+
### Add a loading skeleton
|
| 306 |
+
|
| 307 |
+
Import Spinner:
|
| 308 |
+
```tsx
|
| 309 |
+
import { Spinner } from '@/components/common';
|
| 310 |
+
|
| 311 |
+
<Spinner size="md" /> // sm, md, lg
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
### Create a modal dialog
|
| 315 |
+
|
| 316 |
+
```tsx
|
| 317 |
+
import Modal from '@/components/common/Modal';
|
| 318 |
+
|
| 319 |
+
<Modal isOpen={isOpen} onClose={onClose} title="Confirm">
|
| 320 |
+
Your content here
|
| 321 |
+
</Modal>
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
## 🌐 Environment Variables
|
| 325 |
+
|
| 326 |
+
### Backend (.env)
|
| 327 |
+
```
|
| 328 |
+
FLASK_ENV=development
|
| 329 |
+
CV_TAILORING_MODE=local
|
| 330 |
+
LLM_PROVIDER=groq
|
| 331 |
+
GROQ_API_KEY=xxxxx
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
### Frontend (.env in frontend/)
|
| 335 |
+
```
|
| 336 |
+
VITE_API_URL=http://localhost:5050
|
| 337 |
+
VITE_APP_NAME=Job Apply AI
|
| 338 |
+
```
|
| 339 |
+
|
| 340 |
+
## 📞 Support
|
| 341 |
+
|
| 342 |
+
Need help?
|
| 343 |
+
|
| 344 |
+
1. **Check logs** - Terminal output usually shows the issue
|
| 345 |
+
2. **Read DEPLOYMENT_GUIDE.md** - Detailed deployment info
|
| 346 |
+
3. **Review SAAS_FEATURES.md** - Feature documentation
|
| 347 |
+
4. **Check frontend/README.md** - React-specific docs
|
| 348 |
+
|
| 349 |
+
## ✅ Checklist
|
| 350 |
+
|
| 351 |
+
- [ ] Installed Python and Node.js 18+
|
| 352 |
+
- [ ] Ran `npm install` in frontend folder
|
| 353 |
+
- [ ] Pinned flask-cors in requirements.txt
|
| 354 |
+
- [ ] Started backend on port 5050
|
| 355 |
+
- [ ] Started frontend on port 3000
|
| 356 |
+
- [ ] Opened http://localhost:3000
|
| 357 |
+
- [ ] Tested file upload
|
| 358 |
+
- [ ] Tested job search
|
| 359 |
+
- [ ] Tested CV generation
|
| 360 |
+
- [ ] ☀️ Celebrated the beautiful new UI!
|
| 361 |
+
|
| 362 |
+
---
|
| 363 |
+
|
| 364 |
+
**Ready to use your amazing new Job Apply AI?** 🎉
|
| 365 |
+
|
| 366 |
+
Start building better job applications! 💼✨
|
README.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SkillSync
|
| 3 |
+
emoji: 💼
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app_new.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Job Application AI Agent
|
| 12 |
+
|
| 13 |
+
An intelligent AI-powered tool that automates the job application process by:
|
| 14 |
+
|
| 15 |
+
1. Scraping job listings from platforms like LinkedIn
|
| 16 |
+
2. Analyzing job descriptions to extract key requirements
|
| 17 |
+
3. Automatically tailoring your CV to match job requirements
|
| 18 |
+
4. Generating customized cover letters
|
| 19 |
+
|
| 20 |
+
## Features
|
| 21 |
+
|
| 22 |
+
- **Job Scraping**: Automatically search and collect job listings from LinkedIn
|
| 23 |
+
- **Intelligent Analysis**: Extract key skills and requirements from job descriptions
|
| 24 |
+
- **CV Customization**: Tailor your CV to highlight relevant skills for each job
|
| 25 |
+
- **Batch Processing**: Generate multiple tailored CVs for different jobs at once
|
| 26 |
+
- **User-Friendly Interface**: Simple web interface to control the entire process
|
| 27 |
+
|
| 28 |
+
## Setup
|
| 29 |
+
|
| 30 |
+
### Prerequisites
|
| 31 |
+
|
| 32 |
+
- Python 3.8+
|
| 33 |
+
- Node.js 18+ and npm (for the React frontend)
|
| 34 |
+
- Chrome browser (for web scraping)
|
| 35 |
+
|
| 36 |
+
### Installation
|
| 37 |
+
|
| 38 |
+
1. Clone this repository:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
git clone https://github.com/yourusername/Job-apply-AI-agent.git
|
| 42 |
+
cd Job-apply-AI-agent
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
2. Run the installation script:
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# On Unix-based systems (macOS, Linux)
|
| 49 |
+
./install.sh
|
| 50 |
+
|
| 51 |
+
# On Windows
|
| 52 |
+
install.bat
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
3. Install frontend dependencies:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
cd frontend
|
| 59 |
+
npm install
|
| 60 |
+
cd ..
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Windows: Keep Everything Inside This Project Folder
|
| 64 |
+
|
| 65 |
+
If your C drive is full, run installation and app commands with local state folders inside this project.
|
| 66 |
+
|
| 67 |
+
```powershell
|
| 68 |
+
# Run from project root
|
| 69 |
+
$env:JOB_APPLY_AI_DATA_DIR = "$PWD\\.runtime"
|
| 70 |
+
$env:TMP = "$PWD\\.local_state\\temp"
|
| 71 |
+
$env:TEMP = "$PWD\\.local_state\\temp"
|
| 72 |
+
$env:PIP_CACHE_DIR = "$PWD\\.local_state\\pip-cache"
|
| 73 |
+
$env:PYTHONPYCACHEPREFIX = "$PWD\\.local_state\\pycache"
|
| 74 |
+
|
| 75 |
+
# First-time setup
|
| 76 |
+
./install.bat
|
| 77 |
+
|
| 78 |
+
# If your Chrome major version is different from auto-detected chromedriver
|
| 79 |
+
# (example shown for Chrome 146)
|
| 80 |
+
$env:UC_CHROME_VERSION_MAIN = "146"
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
The project is now configured to keep generated files under local folders such as `.runtime` and `.local_state`.
|
| 84 |
+
|
| 85 |
+
## Environment Configuration
|
| 86 |
+
|
| 87 |
+
### Using .env File (Recommended)
|
| 88 |
+
|
| 89 |
+
A `.env` file is included with all configuration options. To use Grok API:
|
| 90 |
+
|
| 91 |
+
1. Open `.env` in the project root
|
| 92 |
+
2. Replace `your_groq_api_key_here` with your actual Groq API key
|
| 93 |
+
3. Save the file
|
| 94 |
+
|
| 95 |
+
The app will automatically load these settings when you run it.
|
| 96 |
+
|
| 97 |
+
### Use API Tailoring in the Main UI (Optional)
|
| 98 |
+
|
| 99 |
+
The web UI can run in two modes:
|
| 100 |
+
|
| 101 |
+
- `CV_TAILORING_MODE=local` (default): uses local rule/NLP tailoring in `job_apply_ai/`
|
| 102 |
+
- `CV_TAILORING_MODE=api`: uses the API subproject engine in `Automatic CV and Cover Letter with API/`
|
| 103 |
+
|
| 104 |
+
When using API mode, set one provider:
|
| 105 |
+
|
| 106 |
+
```powershell
|
| 107 |
+
# Choose one: ollama | groq | openai
|
| 108 |
+
$env:LLM_PROVIDER = "groq"
|
| 109 |
+
$env:GROQ_API_KEY = "your_groq_key_here"
|
| 110 |
+
$env:GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 111 |
+
|
| 112 |
+
# Enable API engine from the same web UI
|
| 113 |
+
$env:CV_TAILORING_MODE = "api"
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Optional cover letter template path for API mode:
|
| 117 |
+
|
| 118 |
+
```powershell
|
| 119 |
+
$env:API_COVER_LETTER_TEMPLATE_PATH = "D:\projects\job_search_agent\Job-apply-AI-agent-main\Automatic CV and Cover Letter with API\data\Cover Letter_Imon .docx"
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### Manual Environment Variables
|
| 123 |
+
|
| 124 |
+
Or set them in PowerShell before running commands:
|
| 125 |
+
|
| 126 |
+
```powershell
|
| 127 |
+
$env:LLM_PROVIDER = "grok"
|
| 128 |
+
$env:GROK_API_KEY = "your_actual_key_here"
|
| 129 |
+
$env:UC_CHROME_VERSION_MAIN = "146"
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
This will:
|
| 133 |
+
|
| 134 |
+
- Create a virtual environment
|
| 135 |
+
- Install all dependencies
|
| 136 |
+
- Download the required spaCy language model
|
| 137 |
+
- Install the package in development mode
|
| 138 |
+
- Keep temporary and cache files in this project folder (Windows install script)
|
| 139 |
+
|
| 140 |
+
## API Cost Notes
|
| 141 |
+
|
| 142 |
+
- The main app under `job_apply_ai/` does not require a paid LLM API to run.
|
| 143 |
+
- The optional subproject under `Automatic CV and Cover Letter with API/` can run with:
|
| 144 |
+
- Free local Ollama (default, slowest)
|
| 145 |
+
- Groq API (fast and cost-effective)
|
| 146 |
+
- Grok API (fast, affordable, free account available)
|
| 147 |
+
- OpenAI API (premium quality, paid)
|
| 148 |
+
|
| 149 |
+
## Usage
|
| 150 |
+
|
| 151 |
+
### Web Interface (React Frontend - SaaS Edition)
|
| 152 |
+
|
| 153 |
+
The application now includes a modern React frontend with professional SaaS design, Framer Motion animations, and advanced state management.
|
| 154 |
+
|
| 155 |
+
#### Quick Start
|
| 156 |
+
|
| 157 |
+
1. **Install Frontend Dependencies** (from project root):
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
cd frontend
|
| 161 |
+
npm install
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
2. **Start Backend** (in one terminal):
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
# Activate the virtual environment first
|
| 168 |
+
source venv/bin/activate # On Unix-based systems
|
| 169 |
+
venv\Scripts\activate.bat # On Windows
|
| 170 |
+
|
| 171 |
+
# Start the Flask backend
|
| 172 |
+
python -m job_apply_ai.ui.app_new
|
| 173 |
+
# Or use the installed command:
|
| 174 |
+
job-apply-ai web
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
3. **Start Frontend** (in another terminal):
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
cd frontend
|
| 181 |
+
npm run dev
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
4. **Open your browser**: http://localhost:3000
|
| 185 |
+
|
| 186 |
+
#### Features
|
| 187 |
+
|
| 188 |
+
- 🎨 **Modern SaaS Design** - Black & emerald green professional theme
|
| 189 |
+
- ✨ **Smooth Animations** - Powered by Framer Motion
|
| 190 |
+
- 📊 **Smart State Management** - Zustand for reactive updates
|
| 191 |
+
- 📱 **Fully Responsive** - Works on all devices
|
| 192 |
+
- 🔄 **Real-time Progress** - Batch CV generation tracking
|
| 193 |
+
- 🎯 **Workflow Steps** - Guided experience from CV upload to generation
|
| 194 |
+
|
| 195 |
+
#### Workflow
|
| 196 |
+
|
| 197 |
+
1. **Upload CV** - Upload your base CV template (.docx)
|
| 198 |
+
2. **Search Jobs** - Find opportunities by keyword and location
|
| 199 |
+
3. **Review & Select** - Browse matched jobs with extracted skills
|
| 200 |
+
4. **Generate CVs** - Create tailored CVs with one click
|
| 201 |
+
5. **Download** - Get all generated CVs as a ZIP file
|
| 202 |
+
|
| 203 |
+
#### Building for Production
|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
cd frontend
|
| 207 |
+
npm run build
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
This creates an optimized build that the Flask backend will serve.
|
| 211 |
+
|
| 212 |
+
### Legacy Web Interface (HTML/Bootstrap)
|
| 213 |
+
|
| 214 |
+
The original HTML-based interface is still available. To use it, edit `job_apply_ai/ui/app.py` and ensure it's the active server file.
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
python -m flask --app job_apply_ai.ui.app run
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
Then visit: http://localhost:5000
|
| 221 |
+
|
| 222 |
+
### Command Line
|
| 223 |
+
|
| 224 |
+
The application also provides a command-line interface:
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
# Scrape job listings
|
| 228 |
+
job-apply-ai scrape --keyword "Software Engineer" --location "Berlin" --max-jobs 5
|
| 229 |
+
|
| 230 |
+
# Generate tailored CVs for all jobs in an Excel file
|
| 231 |
+
job-apply-ai batch --cv path/to/cv_template.docx --jobs-file path/to/jobs.xlsx
|
| 232 |
+
|
| 233 |
+
# Generate a tailored CV for a single job description
|
| 234 |
+
job-apply-ai tailor --cv path/to/cv_template.docx --job path/to/job_description.txt
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
## Project Structure
|
| 238 |
+
|
| 239 |
+
- `job_apply_ai/scraper/`: Job listing scraping modules
|
| 240 |
+
- `job_apply_ai/cv_modifier/`: CV customization functionality
|
| 241 |
+
- `job_apply_ai/utils/`: Utility functions and helpers
|
| 242 |
+
- `job_apply_ai/ui/`: User interface components
|
| 243 |
+
- `job_apply_ai/outputs/`: Output directories for jobs and CVs
|
| 244 |
+
- `job_apply_ai/outputs/jobs/`: Contains Excel files with job listings
|
| 245 |
+
- `job_apply_ai/outputs/cvs/`: Contains generated CV files
|
| 246 |
+
|
| 247 |
+
## Testing
|
| 248 |
+
|
| 249 |
+
For detailed testing instructions, see [TESTING_GUIDE.md](TESTING_GUIDE.md).
|
| 250 |
+
|
| 251 |
+
## License
|
| 252 |
+
|
| 253 |
+
MIT
|
| 254 |
+
|
| 255 |
+
## Contributing
|
| 256 |
+
|
| 257 |
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
| 258 |
+
|
| 259 |
+
.\.venv\Scripts\job-apply-ai.exe web
|
| 260 |
+
|
| 261 |
+
Set-Location "D:\projects\job_search_agent\Job-apply-AI-agent-main"
|
| 262 |
+
|
| 263 |
+
$env:PATH = "D:\projects\veloce\.tools\node\node-v24.14.1-win-x64;$env:PATH"
|
| 264 |
+
$env:NPM_CONFIG_CACHE = "$PWD\.npm-cache"
|
| 265 |
+
$env:NPM_CONFIG_PREFIX = "$PWD\.npm-prefix"
|
| 266 |
+
|
| 267 |
+
Set-Location ".\frontend"
|
| 268 |
+
npm.cmd run dev
|
| 269 |
+
|
| 270 |
+
Set-Location "D:\projects\job_search_agent\Job-apply-AI-agent-main"
|
| 271 |
+
|
| 272 |
+
$env:JOB_APPLY_AI_DATA_DIR = "$PWD\.runtime"
|
| 273 |
+
$env:TMP = "$PWD\.local_state\temp"
|
| 274 |
+
$env:TEMP = "$PWD\.local_state\temp"
|
| 275 |
+
$env:PIP_CACHE_DIR = "$PWD\.local_state\pip-cache"
|
| 276 |
+
$env:PYTHONPYCACHEPREFIX = "$PWD\.local_state\pycache"
|
| 277 |
+
|
| 278 |
+
.\.venv\Scripts\python.exe -m job_apply_ai.ui.app_new
|
SAAS_FEATURES.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Modern SaaS Features
|
| 2 |
+
|
| 3 |
+
Comprehensive guide to all modern features added to Job Apply AI.
|
| 4 |
+
|
| 5 |
+
## 🎨 Design System
|
| 6 |
+
|
| 7 |
+
### Color Scheme
|
| 8 |
+
- **Primary Black**: `#0A0E27` - Dark sophisticated background
|
| 9 |
+
- **Primary Color**: Emerald `#22c55e` - Modern, professional accent
|
| 10 |
+
- **Secondary**: Slate grays for text and borders
|
| 11 |
+
- **Gradients**: Emerald fades for depth and visual interest
|
| 12 |
+
|
| 13 |
+
### Typography
|
| 14 |
+
- **Font**: Inter (sans-serif) - Modern, clean, professional
|
| 15 |
+
- **Hierarchy**: Clear visual distinction between headings, body text
|
| 16 |
+
- **Sizes**: Responsive scaling from mobile to desktop
|
| 17 |
+
|
| 18 |
+
### Effects
|
| 19 |
+
- **Glow Effects**: Subtle emerald glow on hover/focus
|
| 20 |
+
- **Shadows**: Layered shadows for depth
|
| 21 |
+
- **Animations**: Smooth transitions and transforms
|
| 22 |
+
|
| 23 |
+
## ✨ Animation Library (Framer Motion)
|
| 24 |
+
|
| 25 |
+
### Page Transitions
|
| 26 |
+
```tsx
|
| 27 |
+
// Smooth fade and slide on page changes
|
| 28 |
+
<motion.div
|
| 29 |
+
initial={{ opacity: 0, y: 20 }}
|
| 30 |
+
animate={{ opacity: 1, y: 0 }}
|
| 31 |
+
exit={{ opacity: 0, y: -20 }}
|
| 32 |
+
/>
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
### Component Interactions
|
| 36 |
+
- **Hover Effects**: Cards lift up, buttons glow
|
| 37 |
+
- **Click Feedback**: Scale down on tap for tactile feedback
|
| 38 |
+
- **Loading States**: Spinning icons with smooth rotation
|
| 39 |
+
- **Progress Animations**: Smooth progress bar fills
|
| 40 |
+
|
| 41 |
+
### Staggered Children
|
| 42 |
+
```tsx
|
| 43 |
+
<motion.div variants={containerVariants} initial="hidden" animate="visible">
|
| 44 |
+
{items.map((item) => (
|
| 45 |
+
<motion.div key={item.id} variants={itemVariants}>
|
| 46 |
+
Content animates in sequence
|
| 47 |
+
</motion.div>
|
| 48 |
+
))}
|
| 49 |
+
</motion.div>
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## 🎯 State Management (Zustand)
|
| 53 |
+
|
| 54 |
+
### Reactive Updates
|
| 55 |
+
```tsx
|
| 56 |
+
// Subscribe to state changes
|
| 57 |
+
const { jobs, setJobs } = useJobStore();
|
| 58 |
+
|
| 59 |
+
// Automatic re-render on state change
|
| 60 |
+
handleSearch(); // setState triggers React render
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Persistent State
|
| 64 |
+
- CV template preference
|
| 65 |
+
- Tailoring mode selection
|
| 66 |
+
- User settings
|
| 67 |
+
- Auto-saves to localStorage
|
| 68 |
+
|
| 69 |
+
### Organized Store
|
| 70 |
+
```typescript
|
| 71 |
+
// All state in one place, clearly organized
|
| 72 |
+
- Job data (search results, matched skills)
|
| 73 |
+
- UI state (loading, modals)
|
| 74 |
+
- User preferences (tailoring mode)
|
| 75 |
+
- Notifications (toasts)
|
| 76 |
+
- File selections
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## 📱 Responsive Design
|
| 80 |
+
|
| 81 |
+
### Mobile-First Approach
|
| 82 |
+
```css
|
| 83 |
+
/* Defaults for mobile, then breakpoints */
|
| 84 |
+
grid-cols-1 /* Mobile (base) */
|
| 85 |
+
md:grid-cols-2 /* Tablet (768px+) */
|
| 86 |
+
lg:grid-cols-4 /* Desktop (1024px+) */
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### Adaptive Layouts
|
| 90 |
+
- Stack to side-by-side on larger screens
|
| 91 |
+
- Collapse menus on mobile, full nav on desktop
|
| 92 |
+
- Touch-friendly button sizes on mobile
|
| 93 |
+
- Optimized spacing per device
|
| 94 |
+
|
| 95 |
+
## 🧩 Component Architecture
|
| 96 |
+
|
| 97 |
+
### Base Components
|
| 98 |
+
- Fully typed with TypeScript
|
| 99 |
+
- Reusable across multiple pages
|
| 100 |
+
- Consistent styling system
|
| 101 |
+
- Built-in accessibility
|
| 102 |
+
|
| 103 |
+
### Examples
|
| 104 |
+
|
| 105 |
+
#### Button Variants
|
| 106 |
+
```tsx
|
| 107 |
+
<Button variant="primary">Main Action</Button>
|
| 108 |
+
<Button variant="secondary">Alternative</Button>
|
| 109 |
+
<Button variant="outline">Border Only</Button>
|
| 110 |
+
<Button variant="ghost">Minimal</Button>
|
| 111 |
+
<Button variant="danger">Destructive</Button>
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
#### Card Styles
|
| 115 |
+
```tsx
|
| 116 |
+
<Card hover glow innerGlow>
|
| 117 |
+
// Hover lifts up
|
| 118 |
+
// Outer glow effect
|
| 119 |
+
// Inner gradient glow
|
| 120 |
+
</Card>
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
#### Input Validation
|
| 124 |
+
```tsx
|
| 125 |
+
<Input
|
| 126 |
+
label="Keyword"
|
| 127 |
+
icon={<SearchIcon />}
|
| 128 |
+
error={errors.keyword}
|
| 129 |
+
helperText="Required field"
|
| 130 |
+
/>
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
#### Toast Notifications
|
| 134 |
+
```tsx
|
| 135 |
+
<Toast
|
| 136 |
+
type="success"
|
| 137 |
+
message="Job search completed!"
|
| 138 |
+
title="Success"
|
| 139 |
+
autoClose={5000}
|
| 140 |
+
/>
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
## 🔄 Advanced State Flows
|
| 144 |
+
|
| 145 |
+
### Multi-Step Workflow
|
| 146 |
+
```
|
| 147 |
+
1. Upload CV → 2. Search Jobs → 3. Review → 4. Generate → 5. Download
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
Each step is independently manageable with Zustand.
|
| 151 |
+
|
| 152 |
+
### Batch Processing
|
| 153 |
+
```
|
| 154 |
+
Select Jobs → Initiate Generation → Track Progress → Download ZIP
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
Real-time progress updates without page reload.
|
| 158 |
+
|
| 159 |
+
### Error Boundaries
|
| 160 |
+
```tsx
|
| 161 |
+
// Specific error messages for different failure modes
|
| 162 |
+
- File upload errors
|
| 163 |
+
- Search failures
|
| 164 |
+
- CV generation errors
|
| 165 |
+
- Download failures
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
## 🎬 Micro-Interactions
|
| 169 |
+
|
| 170 |
+
### Loading States
|
| 171 |
+
- Rotating spinners
|
| 172 |
+
- Progress bars with smooth animations
|
| 173 |
+
- Skeleton screens (optional)
|
| 174 |
+
- Disabled states
|
| 175 |
+
|
| 176 |
+
### User Feedback
|
| 177 |
+
- Inline error messages
|
| 178 |
+
- Success confirmations
|
| 179 |
+
- Warning indicators
|
| 180 |
+
- Info tooltips
|
| 181 |
+
|
| 182 |
+
### Visual Hierarchy
|
| 183 |
+
- Glow on primary actions
|
| 184 |
+
- Subtle shadows for depth
|
| 185 |
+
- Color coding for status
|
| 186 |
+
- Opacity for disabled states
|
| 187 |
+
|
| 188 |
+
## 🚀 Performance Features
|
| 189 |
+
|
| 190 |
+
### Code Splitting
|
| 191 |
+
- Page components lazy-loaded
|
| 192 |
+
- Components tree-shaked
|
| 193 |
+
- Unused CSS removed
|
| 194 |
+
|
| 195 |
+
### Image Optimization
|
| 196 |
+
- SVG icons (no image requests)
|
| 197 |
+
- CSS gradients (no image files)
|
| 198 |
+
- Optimized build output
|
| 199 |
+
|
| 200 |
+
### Network Optimization
|
| 201 |
+
- API calls batched where possible
|
| 202 |
+
- Streaming file downloads
|
| 203 |
+
- Session persistence reduces requests
|
| 204 |
+
|
| 205 |
+
## ♿ Accessibility Features
|
| 206 |
+
|
| 207 |
+
### Keyboard Navigation
|
| 208 |
+
- Tab through all interactive elements
|
| 209 |
+
- Enter to activate buttons
|
| 210 |
+
- Escape to close modals
|
| 211 |
+
- Arrow keys for navigation (future)
|
| 212 |
+
|
| 213 |
+
### ARIA Labels
|
| 214 |
+
```tsx
|
| 215 |
+
<button aria-label="Close dialog" onClick={onClose}>
|
| 216 |
+
��
|
| 217 |
+
</button>
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### Focus States
|
| 221 |
+
```css
|
| 222 |
+
focus:outline-none
|
| 223 |
+
focus:ring-2
|
| 224 |
+
focus:ring-emerald-500/50
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
### Color Contrast
|
| 228 |
+
- Text always 4.5:1+ contrast ratio
|
| 229 |
+
- Status not conveyed by color alone
|
| 230 |
+
- Warning/error text + icons
|
| 231 |
+
|
| 232 |
+
## 📊 Dashboard & Analytics Ready
|
| 233 |
+
|
| 234 |
+
The component structure supports future additions:
|
| 235 |
+
- Analytics dashboard
|
| 236 |
+
- Job application history
|
| 237 |
+
- CV version tracking
|
| 238 |
+
- Success rate metrics
|
| 239 |
+
- Performance charts
|
| 240 |
+
|
| 241 |
+
## 🔐 Security Features
|
| 242 |
+
|
| 243 |
+
### Frontend Security
|
| 244 |
+
- No credentials stored locally
|
| 245 |
+
- API endpoints validated
|
| 246 |
+
- File type validation (docx only)
|
| 247 |
+
- File size limits
|
| 248 |
+
|
| 249 |
+
### Backend Integration
|
| 250 |
+
- CORS properly configured
|
| 251 |
+
- Session management with unique keys
|
| 252 |
+
- File path validation (no traversal)
|
| 253 |
+
- Input sanitization
|
| 254 |
+
|
| 255 |
+
## 🎓 Developer Experience
|
| 256 |
+
|
| 257 |
+
### TypeScript Everything
|
| 258 |
+
```tsx
|
| 259 |
+
// Full type safety
|
| 260 |
+
type Job = {
|
| 261 |
+
id: string;
|
| 262 |
+
title: string;
|
| 263 |
+
company: string;
|
| 264 |
+
// ... fully typed
|
| 265 |
+
}
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
### Clear Component Props
|
| 269 |
+
```tsx
|
| 270 |
+
// Self-documenting interfaces
|
| 271 |
+
interface CardProps {
|
| 272 |
+
hover?: boolean;
|
| 273 |
+
glow?: boolean;
|
| 274 |
+
noPadding?: boolean;
|
| 275 |
+
}
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
### ESLint Configuration
|
| 279 |
+
- Catches common mistakes
|
| 280 |
+
- Enforces best practices
|
| 281 |
+
- Clean, consistent code
|
| 282 |
+
|
| 283 |
+
### Path Aliases
|
| 284 |
+
```tsx
|
| 285 |
+
// Clean imports
|
| 286 |
+
import { Button } from '@/components/common';
|
| 287 |
+
import type { Job } from '@/types';
|
| 288 |
+
import { useJobStore } from '@/store/appStore';
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
## 🌟 Future Enhancement Opportunities
|
| 292 |
+
|
| 293 |
+
### Phase 2
|
| 294 |
+
- [ ] Cover letter generation UI
|
| 295 |
+
- [ ] CV template library
|
| 296 |
+
- [ ] Job bookmarking/saved searches
|
| 297 |
+
- [ ] Application history timeline
|
| 298 |
+
- [ ] Success rate analytics
|
| 299 |
+
|
| 300 |
+
### Phase 3
|
| 301 |
+
- [ ] User authentication/accounts
|
| 302 |
+
- [ ] Multi-user support
|
| 303 |
+
- [ ] Cloud storage integration
|
| 304 |
+
- [ ] Advanced analytics
|
| 305 |
+
- [ ] API integrations (LinkedIn, Indeed)
|
| 306 |
+
|
| 307 |
+
### Phase 4
|
| 308 |
+
- [ ] Mobile app (React Native)
|
| 309 |
+
- [ ] Extensions/browser plugins
|
| 310 |
+
- [ ] Slack integration
|
| 311 |
+
- [ ] Email notifications
|
| 312 |
+
- [ ] Social media posting
|
| 313 |
+
|
| 314 |
+
## 📚 Documentation
|
| 315 |
+
|
| 316 |
+
All components are self-documenting with:
|
| 317 |
+
- JSDoc comments
|
| 318 |
+
- TypeScript interfaces
|
| 319 |
+
- Usage examples
|
| 320 |
+
- Props documentation
|
| 321 |
+
|
| 322 |
+
## 🎪 Demo Features
|
| 323 |
+
|
| 324 |
+
Try these interactions:
|
| 325 |
+
1. Hover over any card - smooth lift and glow
|
| 326 |
+
2. Click buttons - scale animation feedback
|
| 327 |
+
3. Upload CV - drag-and-drop animation
|
| 328 |
+
4. Search jobs - smooth fade-in of results
|
| 329 |
+
5. Generate CVs - real-time progress tracking
|
| 330 |
+
6. Download - seamless file handling
|
| 331 |
+
|
| 332 |
+
## 🏆 Best Practices Implemented
|
| 333 |
+
|
| 334 |
+
- **Component Composition**: Small, focused components
|
| 335 |
+
- **State Management**: Zustand for simplicity
|
| 336 |
+
- **Styling**: Tailwind utility-first
|
| 337 |
+
- **Animations**: Framer Motion best practices
|
| 338 |
+
- **Type Safety**: Full TypeScript coverage
|
| 339 |
+
- **Accessibility**: WCAG 2.1 guidelines
|
| 340 |
+
- **Performance**: Optimized builds and lazy loading
|
| 341 |
+
- **Testing**: Ready for unit and integration tests
|
| 342 |
+
|
| 343 |
+
## 📖 Learning Resources
|
| 344 |
+
|
| 345 |
+
### For Developers
|
| 346 |
+
- Tailwind CSS: https://tailwindcss.com
|
| 347 |
+
- Framer Motion: https://www.framer.com/motion
|
| 348 |
+
- Zustand: https://github.com/pmndrs/zustand
|
| 349 |
+
- React Documentation: https://react.dev
|
| 350 |
+
- TypeScript: https://www.typescriptlang.org
|
| 351 |
+
|
| 352 |
+
### For Designers
|
| 353 |
+
- Color Theory: Check `tailwind.config.js` for palette
|
| 354 |
+
- Animation Timing: See Framer Motion docs
|
| 355 |
+
- Component Guidelines: Review `src/components/common`
|
| 356 |
+
|
| 357 |
+
## 🎉 Conclusion
|
| 358 |
+
|
| 359 |
+
This modern React frontend provides:
|
| 360 |
+
- Professional SaaS appearance
|
| 361 |
+
- Smooth, delightful interactions
|
| 362 |
+
- Scalable component architecture
|
| 363 |
+
- Type-safe development
|
| 364 |
+
- Excellent user experience
|
| 365 |
+
- Foundation for future growth
|
| 366 |
+
|
| 367 |
+
Enjoy building with Job Apply AI! 🚀
|
TESTING_GUIDE.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Testing Guide for Job Application AI Agent
|
| 2 |
+
|
| 3 |
+
This guide will help you test the Job Application AI Agent, particularly the batch CV generation functionality that creates multiple tailored CVs based on different job listings.
|
| 4 |
+
|
| 5 |
+
## Setup
|
| 6 |
+
|
| 7 |
+
1. **Install the application**:
|
| 8 |
+
```bash
|
| 9 |
+
# On Unix-based systems (macOS, Linux)
|
| 10 |
+
./install.sh
|
| 11 |
+
|
| 12 |
+
# On Windows
|
| 13 |
+
install.bat
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
2. **Activate the virtual environment**:
|
| 17 |
+
```bash
|
| 18 |
+
# On Unix-based systems (macOS, Linux)
|
| 19 |
+
source venv/bin/activate
|
| 20 |
+
|
| 21 |
+
# On Windows
|
| 22 |
+
venv\Scripts\activate.bat
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Testing Batch CV Generation
|
| 26 |
+
|
| 27 |
+
### Method 1: Using the Test Script (Quickest method)
|
| 28 |
+
|
| 29 |
+
We've included a test script that automates the entire process:
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
./test_batch_processing.py --cv path/to/your/cv_template.docx
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
This script will:
|
| 36 |
+
1. Scrape job listings for "Software Engineer" in "Berlin" (default)
|
| 37 |
+
2. Fetch job descriptions for each job
|
| 38 |
+
3. Save the jobs to an Excel file
|
| 39 |
+
4. Generate tailored CVs for each job
|
| 40 |
+
5. Save the CVs to the output directory
|
| 41 |
+
|
| 42 |
+
You can customize the script with these options:
|
| 43 |
+
- `--keyword "Data Scientist"` - Change the job title to search for
|
| 44 |
+
- `--location "Remote"` - Change the location to search in
|
| 45 |
+
- `--max-jobs 10` - Change the maximum number of jobs to scrape
|
| 46 |
+
- `--output-dir "/custom/path"` - Specify a custom output directory
|
| 47 |
+
|
| 48 |
+
### Method 2: Using the Web Interface (Recommended for beginners)
|
| 49 |
+
|
| 50 |
+
1. **Start the web interface**:
|
| 51 |
+
```bash
|
| 52 |
+
job-apply-ai web
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
2. **Open the web application**:
|
| 56 |
+
- Open your browser and go to: http://localhost:5000
|
| 57 |
+
|
| 58 |
+
3. **Upload your CV template**:
|
| 59 |
+
- Click on "Upload CV" button
|
| 60 |
+
- Select your CV template file (must be a .docx file)
|
| 61 |
+
- Click "Upload"
|
| 62 |
+
|
| 63 |
+
4. **Search for jobs**:
|
| 64 |
+
- Enter a job title (e.g., "Software Engineer")
|
| 65 |
+
- Enter a location (e.g., "Berlin" or "Remote")
|
| 66 |
+
- Set the maximum number of jobs (e.g., 5)
|
| 67 |
+
- Click "Search"
|
| 68 |
+
|
| 69 |
+
5. **Generate CVs for all jobs**:
|
| 70 |
+
- On the search results page, click "Tailor CV for All Jobs"
|
| 71 |
+
- Wait for the process to complete
|
| 72 |
+
- You'll see a list of all generated CVs
|
| 73 |
+
- Click "Download All CVs (ZIP)" to download all tailored CVs as a zip file
|
| 74 |
+
|
| 75 |
+
6. **Check the generated CVs**:
|
| 76 |
+
- Extract the downloaded zip file
|
| 77 |
+
- Open each CV to verify that the skills section has been tailored for each job
|
| 78 |
+
- Note that each CV filename includes the date, company name, and job title
|
| 79 |
+
|
| 80 |
+
### Method 3: Using the Command Line
|
| 81 |
+
|
| 82 |
+
1. **Scrape job listings**:
|
| 83 |
+
```bash
|
| 84 |
+
job-apply-ai scrape --keyword "Software Engineer" --location "Berlin" --max-jobs 5
|
| 85 |
+
```
|
| 86 |
+
This will save job listings to the `job_apply_ai/outputs/jobs` directory.
|
| 87 |
+
|
| 88 |
+
2. **Generate tailored CVs for all jobs**:
|
| 89 |
+
```bash
|
| 90 |
+
job-apply-ai batch --cv path/to/your/cv_template.docx --jobs-file job_apply_ai/outputs/jobs/linkedin_jobs_YYYY-MM-DD.xlsx
|
| 91 |
+
```
|
| 92 |
+
Replace `path/to/your/cv_template.docx` with the path to your CV template and `YYYY-MM-DD` with the current date.
|
| 93 |
+
|
| 94 |
+
3. **Check the generated CVs**:
|
| 95 |
+
- Go to the `job_apply_ai/outputs/cvs` directory
|
| 96 |
+
- You'll find multiple CV files, each named with the date, company name, and job title
|
| 97 |
+
- Open each CV to verify that the skills section has been tailored for each job
|
| 98 |
+
|
| 99 |
+
## Testing Individual CV Generation
|
| 100 |
+
|
| 101 |
+
If you want to test generating a CV for a single job:
|
| 102 |
+
|
| 103 |
+
1. **View job details**:
|
| 104 |
+
- In the web interface, click on "View Details" for a specific job
|
| 105 |
+
- Review the job description
|
| 106 |
+
|
| 107 |
+
2. **Generate a CV for that job**:
|
| 108 |
+
- Click "Tailor CV for This Job"
|
| 109 |
+
- Wait for the process to complete
|
| 110 |
+
- Click "Download Tailored CV" to download the CV
|
| 111 |
+
|
| 112 |
+
## Folder Structure
|
| 113 |
+
|
| 114 |
+
The application uses the following folder structure for outputs:
|
| 115 |
+
|
| 116 |
+
- `job_apply_ai/outputs/jobs`: Contains Excel files with job listings
|
| 117 |
+
- `job_apply_ai/outputs/cvs`: Contains generated CV files
|
| 118 |
+
|
| 119 |
+
Each CV is named using the format: `CV_YYYY-MM-DD_CompanyName_JobTitle.docx`
|
| 120 |
+
|
| 121 |
+
## Troubleshooting
|
| 122 |
+
|
| 123 |
+
If you encounter any issues:
|
| 124 |
+
|
| 125 |
+
1. **Check the console output** for error messages
|
| 126 |
+
2. **Verify that your CV template has a skills section** (the application looks for headings like "skills", "technical skills", "core competencies", or "expertise")
|
| 127 |
+
3. **Make sure job descriptions are being fetched** correctly (view job details in the web interface)
|
| 128 |
+
4. **Check file permissions** if you're having trouble saving files
|
| 129 |
+
|
| 130 |
+
## Advanced Testing
|
| 131 |
+
|
| 132 |
+
For advanced testing, you can modify the skills categories in `job_apply_ai/cv_modifier/cv_analyzer.py` to match your specific skills and expertise.
|
| 133 |
+
|
| 134 |
+
You can also test with different CV templates to see how the application handles different formats.
|
config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Configuration for datasets and models
|
| 2 |
+
DATASETS = [
|
| 3 |
+
"bwbayu/job_cv_supervised",
|
| 4 |
+
"cnamuangtoun/resume-job-description-fit",
|
| 5 |
+
"InferencePrince555/Resume-Dataset",
|
| 6 |
+
"jog-description-and-salary-in-indonesia",
|
| 7 |
+
"itjobpostdescriptions",
|
| 8 |
+
"resume-dataset"
|
| 9 |
+
]
|
| 10 |
+
|
| 11 |
+
MODEL_CONFIG = {
|
| 12 |
+
"active_model": "sbert",
|
| 13 |
+
"sbert_path": "trained_models/sbert",
|
| 14 |
+
"glove_path": "trained_models/glove.model",
|
| 15 |
+
"doc2vec_path": "trained_models/doc2vec.model",
|
| 16 |
+
"job_taxonomy": [
|
| 17 |
+
"System Administrator",
|
| 18 |
+
"Database Administrator",
|
| 19 |
+
"Web Developer",
|
| 20 |
+
"Security Analyst",
|
| 21 |
+
"Network Administrator",
|
| 22 |
+
"Data Scientist",
|
| 23 |
+
"DevOps Engineer",
|
| 24 |
+
"Cloud Engineer",
|
| 25 |
+
"Machine Learning Engineer",
|
| 26 |
+
"Software Engineer"
|
| 27 |
+
]
|
| 28 |
+
}
|
f_requirements.txt
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
annotated-doc==0.0.4
|
| 2 |
+
annotated-types==0.7.0
|
| 3 |
+
anyio==4.13.0
|
| 4 |
+
apify_client==2.5.0
|
| 5 |
+
apify_shared==2.2.0
|
| 6 |
+
attrs==26.1.0
|
| 7 |
+
beautifulsoup4==4.14.3
|
| 8 |
+
blinker==1.9.0
|
| 9 |
+
blis==1.3.3
|
| 10 |
+
catalogue==2.0.10
|
| 11 |
+
certifi==2026.2.25
|
| 12 |
+
cffi==2.0.0
|
| 13 |
+
charset-normalizer==3.4.7
|
| 14 |
+
click==8.3.2
|
| 15 |
+
cloudpathlib==0.23.0
|
| 16 |
+
colorama==0.4.6
|
| 17 |
+
confection==1.3.3
|
| 18 |
+
cryptography==46.0.7
|
| 19 |
+
cymem==2.0.13
|
| 20 |
+
distro==1.9.0
|
| 21 |
+
docxtpl==0.20.2
|
| 22 |
+
dotenv==0.9.9
|
| 23 |
+
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
|
| 24 |
+
et_xmlfile==2.0.0
|
| 25 |
+
filelock==3.29.0
|
| 26 |
+
filetype==1.2.0
|
| 27 |
+
Flask==3.1.3
|
| 28 |
+
flask-cors==6.0.2
|
| 29 |
+
fsspec==2026.4.0
|
| 30 |
+
gensim==4.4.0
|
| 31 |
+
google-ai-generativelanguage==0.6.15
|
| 32 |
+
google-api-core==2.30.3
|
| 33 |
+
google-api-python-client==2.196.0
|
| 34 |
+
google-auth==2.49.2
|
| 35 |
+
google-auth-httplib2==0.4.0
|
| 36 |
+
google-genai==1.73.1
|
| 37 |
+
google-generativeai==0.8.6
|
| 38 |
+
googleapis-common-protos==1.75.0
|
| 39 |
+
grpcio==1.80.0
|
| 40 |
+
grpcio-status==1.71.2
|
| 41 |
+
h11==0.16.0
|
| 42 |
+
hf-xet==1.5.0
|
| 43 |
+
httpcore==1.0.9
|
| 44 |
+
httplib2==0.31.2
|
| 45 |
+
httpx==0.28.1
|
| 46 |
+
huggingface_hub==1.15.0
|
| 47 |
+
idna==3.11
|
| 48 |
+
impit==0.12.0
|
| 49 |
+
itsdangerous==2.2.0
|
| 50 |
+
Jinja2==3.1.6
|
| 51 |
+
jiter==0.14.0
|
| 52 |
+
joblib==1.5.3
|
| 53 |
+
jsonpatch==1.33
|
| 54 |
+
jsonpointer==3.1.1
|
| 55 |
+
langchain-core==1.3.1
|
| 56 |
+
langchain-google-genai==4.2.2
|
| 57 |
+
langgraph==1.1.9
|
| 58 |
+
langgraph-checkpoint==4.0.2
|
| 59 |
+
langgraph-prebuilt==1.0.10
|
| 60 |
+
langgraph-sdk==0.3.13
|
| 61 |
+
langsmith==0.7.35
|
| 62 |
+
lxml==6.1.0
|
| 63 |
+
markdown-it-py==4.0.0
|
| 64 |
+
MarkupSafe==3.0.3
|
| 65 |
+
mdurl==0.1.2
|
| 66 |
+
more-itertools==11.0.2
|
| 67 |
+
mpmath==1.3.0
|
| 68 |
+
murmurhash==1.0.15
|
| 69 |
+
networkx==3.6.1
|
| 70 |
+
nltk==3.9.4
|
| 71 |
+
numpy==2.4.4
|
| 72 |
+
openai==2.32.0
|
| 73 |
+
openpyxl==3.1.5
|
| 74 |
+
orjson==3.11.8
|
| 75 |
+
ormsgpack==1.12.2
|
| 76 |
+
outcome==1.3.0.post0
|
| 77 |
+
packaging==26.1
|
| 78 |
+
pandas==3.0.2
|
| 79 |
+
preshed==3.0.13
|
| 80 |
+
proto-plus==1.28.0
|
| 81 |
+
protobuf==5.29.6
|
| 82 |
+
pyasn1==0.6.3
|
| 83 |
+
pyasn1_modules==0.4.2
|
| 84 |
+
pycparser==3.0
|
| 85 |
+
pydantic==2.13.2
|
| 86 |
+
pydantic_core==2.46.2
|
| 87 |
+
Pygments==2.20.0
|
| 88 |
+
pyparsing==3.3.2
|
| 89 |
+
PyPDF2==3.0.1
|
| 90 |
+
PySocks==1.7.1
|
| 91 |
+
python-dateutil==2.9.0.post0
|
| 92 |
+
python-docx==1.2.0
|
| 93 |
+
python-dotenv==1.2.2
|
| 94 |
+
PyYAML==6.0.3
|
| 95 |
+
regex==2026.5.9
|
| 96 |
+
requests==2.33.1
|
| 97 |
+
requests-toolbelt==1.0.0
|
| 98 |
+
rich==15.0.0
|
| 99 |
+
safetensors==0.7.0
|
| 100 |
+
scikit-learn==1.8.0
|
| 101 |
+
scipy==1.17.1
|
| 102 |
+
selenium==4.43.0
|
| 103 |
+
sentence-transformers==5.5.0
|
| 104 |
+
setuptools==81.0.0
|
| 105 |
+
shellingham==1.5.4
|
| 106 |
+
six==1.17.0
|
| 107 |
+
smart_open==7.6.0
|
| 108 |
+
sniffio==1.3.1
|
| 109 |
+
sortedcontainers==2.4.0
|
| 110 |
+
soupsieve==2.8.3
|
| 111 |
+
spacy==3.8.14
|
| 112 |
+
spacy-legacy==3.0.12
|
| 113 |
+
spacy-loggers==1.0.5
|
| 114 |
+
srsly==2.5.3
|
| 115 |
+
sympy==1.14.0
|
| 116 |
+
tenacity==9.1.4
|
| 117 |
+
thinc==8.3.13
|
| 118 |
+
threadpoolctl==3.6.0
|
| 119 |
+
tokenizers==0.22.2
|
| 120 |
+
torch==2.12.0
|
| 121 |
+
tqdm==4.67.3
|
| 122 |
+
transformers==5.8.1
|
| 123 |
+
trio==0.33.0
|
| 124 |
+
trio-websocket==0.12.2
|
| 125 |
+
typer==0.24.1
|
| 126 |
+
typing-inspection==0.4.2
|
| 127 |
+
typing_extensions==4.15.0
|
| 128 |
+
tzdata==2026.1
|
| 129 |
+
undetected-chromedriver==3.5.5
|
| 130 |
+
uritemplate==4.2.0
|
| 131 |
+
urllib3==2.6.3
|
| 132 |
+
uuid_utils==0.14.1
|
| 133 |
+
wasabi==1.1.3
|
| 134 |
+
weasel==1.0.0
|
| 135 |
+
websocket-client==1.9.0
|
| 136 |
+
websockets==16.0
|
| 137 |
+
Werkzeug==3.1.8
|
| 138 |
+
wrapt==2.1.2
|
| 139 |
+
wsproto==1.3.2
|
| 140 |
+
xxhash==3.6.0
|
| 141 |
+
zstandard==0.25.0
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
VITE_API_URL=http://localhost:5050
|
| 2 |
+
VITE_APP_NAME=Job Apply AI
|
| 3 |
+
VITE_APP_VERSION=1.0.0
|
frontend/.eslintignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// .eslintignore
|
| 2 |
+
node_modules/
|
| 3 |
+
dist/
|
| 4 |
+
build/
|
| 5 |
+
.venv/
|
| 6 |
+
*.d.ts
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
*.pnp
|
| 4 |
+
.pnp.js
|
| 5 |
+
|
| 6 |
+
# Testing
|
| 7 |
+
coverage/
|
| 8 |
+
|
| 9 |
+
# Production
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
|
| 13 |
+
# Misc
|
| 14 |
+
.DS_Store
|
| 15 |
+
.env
|
| 16 |
+
.env.local
|
| 17 |
+
.env.development.local
|
| 18 |
+
.env.test.local
|
| 19 |
+
.env.production.local
|
| 20 |
+
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
| 24 |
+
pnpm-debug.log*
|
| 25 |
+
lerna-debug.log*
|
| 26 |
+
|
| 27 |
+
# IDE
|
| 28 |
+
.vscode/
|
| 29 |
+
.idea/
|
| 30 |
+
*.swp
|
| 31 |
+
*.swo
|
| 32 |
+
|
| 33 |
+
# OS
|
| 34 |
+
Thumbs.db
|
frontend/README.md
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Job Apply AI - React Frontend
|
| 2 |
+
|
| 3 |
+
A modern, SaaS-like React frontend for the Job Application AI Agent with beautiful animations, dark theme, and professional UI.
|
| 4 |
+
|
| 5 |
+
## 🚀 Features
|
| 6 |
+
|
| 7 |
+
- **Modern React 18** with TypeScript
|
| 8 |
+
- **State Management** with Zustand for efficient state handling
|
| 9 |
+
- **Tailwind CSS** with custom emerald/black SaaS theme
|
| 10 |
+
- **Framer Motion** animations for smooth interactions
|
| 11 |
+
- **Component Library** with reusable, animated components
|
| 12 |
+
- **Responsive Design** that works on all devices
|
| 13 |
+
- **REST API Integration** with the Python backend
|
| 14 |
+
- **Dark Mode** with professional styling
|
| 15 |
+
|
| 16 |
+
## 🛠️ Tech Stack
|
| 17 |
+
|
| 18 |
+
- **React 18** - UI framework
|
| 19 |
+
- **TypeScript** - Type safety
|
| 20 |
+
- **Vite** - Build tool and dev server
|
| 21 |
+
- **Tailwind CSS** - Utility-first CSS framework
|
| 22 |
+
- **Framer Motion** - Animation library
|
| 23 |
+
- **Zustand** - State management
|
| 24 |
+
- **Axios** - HTTP client
|
| 25 |
+
- **Lucide React** - Icon library
|
| 26 |
+
|
| 27 |
+
## 📦 Installation
|
| 28 |
+
|
| 29 |
+
### Prerequisites
|
| 30 |
+
|
| 31 |
+
- Node.js 18+ and npm/yarn
|
| 32 |
+
- Python backend running on port 5050
|
| 33 |
+
|
| 34 |
+
### Setup
|
| 35 |
+
|
| 36 |
+
1. **Install Dependencies**
|
| 37 |
+
```bash
|
| 38 |
+
cd frontend
|
| 39 |
+
npm install
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
2. **Configure Environment**
|
| 43 |
+
```bash
|
| 44 |
+
# Copy environment template
|
| 45 |
+
cp .env.example .env
|
| 46 |
+
|
| 47 |
+
# Edit .env if needed
|
| 48 |
+
# VITE_API_URL=http://localhost:5050
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
3. **Start Development Server**
|
| 52 |
+
```bash
|
| 53 |
+
npm run dev
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
The app will be available at `http://localhost:3000`
|
| 57 |
+
|
| 58 |
+
## 🏗️ Project Structure
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
frontend/
|
| 62 |
+
├── src/
|
| 63 |
+
│ ├── components/
|
| 64 |
+
│ │ ├── common/ # Reusable UI components
|
| 65 |
+
│ │ │ ├── Button.tsx
|
| 66 |
+
│ │ │ ├── Card.tsx
|
| 67 |
+
│ │ │ ├── Input.tsx
|
| 68 |
+
│ │ │ ├── Modal.tsx
|
| 69 |
+
│ │ │ └── ...
|
| 70 |
+
│ │ ├── pages/ # Full page components
|
| 71 |
+
│ │ │ ├── HomePage.tsx
|
| 72 |
+
│ │ │ ├── WorkflowPage.tsx
|
| 73 |
+
│ │ │ ├── JobListPage.tsx
|
| 74 |
+
│ │ │ └── SettingsModal.tsx
|
| 75 |
+
│ │ └── sections/ # Reusable page sections
|
| 76 |
+
│ │ ├── Header.tsx
|
| 77 |
+
│ │ ├── Footer.tsx
|
| 78 |
+
│ │ ├── CVUpload.tsx
|
| 79 |
+
│ │ └── JobSearch.tsx
|
| 80 |
+
│ ├── store/ # Zustand state management
|
| 81 |
+
│ │ └── appStore.ts
|
| 82 |
+
│ ├── types/ # TypeScript type definitions
|
| 83 |
+
│ │ └── index.ts
|
| 84 |
+
│ ├── utils/ # Utility functions
|
| 85 |
+
│ │ ├── api.ts
|
| 86 |
+
│ │ └── helpers.ts
|
| 87 |
+
│ ├── styles/ # Global styles
|
| 88 |
+
│ │ └── globals.css
|
| 89 |
+
│ ├── App.tsx # Root component
|
| 90 |
+
│ └── main.tsx # Entry point
|
| 91 |
+
├── public/ # Static assets
|
| 92 |
+
├── index.html # HTML template
|
| 93 |
+
├── package.json
|
| 94 |
+
├── tailwind.config.js # Tailwind CSS config
|
| 95 |
+
├── tsconfig.json
|
| 96 |
+
└── vite.config.ts # Vite config
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
## 🎨 Styling & Theming
|
| 100 |
+
|
| 101 |
+
The project uses Tailwind CSS with a custom color scheme:
|
| 102 |
+
|
| 103 |
+
### Colors
|
| 104 |
+
- **Background**: `#0A0E27` (black)
|
| 105 |
+
- **Primary**: `#22c55e` (emerald-500)
|
| 106 |
+
- **Secondary**: Slate gray colors
|
| 107 |
+
- **Accents**: Emerald shades for highlights
|
| 108 |
+
|
| 109 |
+
### Key Tailwind Config Features
|
| 110 |
+
- Custom gradient backgrounds
|
| 111 |
+
- Glow effects with custom shadows
|
| 112 |
+
- Animation utilities
|
| 113 |
+
- Responsive breakpoints
|
| 114 |
+
|
| 115 |
+
### Using Layout Classes
|
| 116 |
+
```tsx
|
| 117 |
+
// Dark SaaS background
|
| 118 |
+
<div className="bg-gradient-saas">
|
| 119 |
+
|
| 120 |
+
// Primary button with glow
|
| 121 |
+
<Button className="shadow-glow">Generate CVs</Button>
|
| 122 |
+
|
| 123 |
+
// Card with optional glow and inner glow
|
| 124 |
+
<Card glow innerGlow>Content</Card>
|
| 125 |
+
|
| 126 |
+
// Emerald gradient text
|
| 127 |
+
<h1 className="bg-gradient-emerald bg-clip-text text-transparent">
|
| 128 |
+
Title
|
| 129 |
+
</h1>
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## 🧩 Components
|
| 133 |
+
|
| 134 |
+
### Common Components
|
| 135 |
+
|
| 136 |
+
All components are in `src/components/common/`:
|
| 137 |
+
|
| 138 |
+
#### Button
|
| 139 |
+
```tsx
|
| 140 |
+
<Button
|
| 141 |
+
variant="primary" // primary, secondary, outline, ghost, danger
|
| 142 |
+
size="lg" // sm, md, lg
|
| 143 |
+
loading={false}
|
| 144 |
+
icon={<Icon />}
|
| 145 |
+
fullWidth
|
| 146 |
+
>
|
| 147 |
+
Click Me
|
| 148 |
+
</Button>
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
#### Card
|
| 152 |
+
```tsx
|
| 153 |
+
<Card hover glow innerGlow>
|
| 154 |
+
Content with animations
|
| 155 |
+
</Card>
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
#### Input
|
| 159 |
+
```tsx
|
| 160 |
+
<Input
|
| 161 |
+
label="Your Name"
|
| 162 |
+
placeholder="Enter name"
|
| 163 |
+
error={errors.name}
|
| 164 |
+
icon={<Icon />}
|
| 165 |
+
helperText="Optional"
|
| 166 |
+
/>
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
#### Badge
|
| 170 |
+
```tsx
|
| 171 |
+
<Badge variant="primary" size="md" icon={<Icon />}>
|
| 172 |
+
Label
|
| 173 |
+
</Badge>
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
#### Modal
|
| 177 |
+
```tsx
|
| 178 |
+
<Modal isOpen={isOpen} onClose={handleClose} title="Dialog">
|
| 179 |
+
Content
|
| 180 |
+
</Modal>
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
#### Toast Notification
|
| 184 |
+
```tsx
|
| 185 |
+
<Toast
|
| 186 |
+
type="success" // success, error, warning, info
|
| 187 |
+
message="Success!"
|
| 188 |
+
title="Great"
|
| 189 |
+
onClose={() => {}}
|
| 190 |
+
autoClose={true}
|
| 191 |
+
/>
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
## 📊 State Management with Zustand
|
| 195 |
+
|
| 196 |
+
The app uses Zustand for state management. Store is located in `src/store/appStore.ts`:
|
| 197 |
+
|
| 198 |
+
```tsx
|
| 199 |
+
import { useJobStore } from '@/store/appStore';
|
| 200 |
+
|
| 201 |
+
function MyComponent() {
|
| 202 |
+
const { jobs, setJobs, cvTemplate, setCVTemplate } = useJobStore();
|
| 203 |
+
|
| 204 |
+
// State is automatically persisted to localStorage
|
| 205 |
+
// Update state by calling setter functions
|
| 206 |
+
}
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
### Available State
|
| 210 |
+
- `jobs` - Array of found jobs
|
| 211 |
+
- `cvTemplate` - Uploaded CV template
|
| 212 |
+
- `tailoringMode` - 'local' or 'api'
|
| 213 |
+
- `isSearching`, `isGenerating` - Loading states
|
| 214 |
+
- `selectedJobIds` - Jobs selected for batch generation
|
| 215 |
+
- `batchProgress` - Progress of batch operation
|
| 216 |
+
- `notification` - Toast notification data
|
| 217 |
+
|
| 218 |
+
## 🌐 API Integration
|
| 219 |
+
|
| 220 |
+
The frontend communicates with the backend via REST API endpoints in `src/utils/api.ts`:
|
| 221 |
+
|
| 222 |
+
```tsx
|
| 223 |
+
import { jobsAPI } from '@/utils/api';
|
| 224 |
+
|
| 225 |
+
// Search jobs
|
| 226 |
+
const result = await jobsAPI.searchJobs({
|
| 227 |
+
keyword: 'React Developer',
|
| 228 |
+
location: 'San Francisco',
|
| 229 |
+
maxJobs: 10
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
// Upload CV
|
| 233 |
+
await jobsAPI.uploadCV(file);
|
| 234 |
+
|
| 235 |
+
// Generate CV
|
| 236 |
+
await jobsAPI.generateCV(jobId);
|
| 237 |
+
|
| 238 |
+
// Batch generate
|
| 239 |
+
await jobsAPI.generateAllCVs(jobIds);
|
| 240 |
+
|
| 241 |
+
// Download file
|
| 242 |
+
const blob = await jobsAPI.downloadFile(filename);
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
## 🎬 Animations with Framer Motion
|
| 246 |
+
|
| 247 |
+
All components use Framer Motion for smooth animations:
|
| 248 |
+
|
| 249 |
+
```tsx
|
| 250 |
+
import { motion } from 'framer-motion';
|
| 251 |
+
|
| 252 |
+
<motion.div
|
| 253 |
+
initial={{ opacity: 0, y: 20 }}
|
| 254 |
+
animate={{ opacity: 1, y: 0 }}
|
| 255 |
+
exit={{ opacity: 0, y: -20 }}
|
| 256 |
+
whileHover={{ scale: 1.05 }}
|
| 257 |
+
whileTap={{ scale: 0.95 }}
|
| 258 |
+
>
|
| 259 |
+
Animated content
|
| 260 |
+
</motion.div>
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
## 🔧 Development
|
| 264 |
+
|
| 265 |
+
### Commands
|
| 266 |
+
|
| 267 |
+
```bash
|
| 268 |
+
# Start dev server
|
| 269 |
+
npm run dev
|
| 270 |
+
|
| 271 |
+
# Build for production
|
| 272 |
+
npm run build
|
| 273 |
+
|
| 274 |
+
# Preview production build
|
| 275 |
+
npm run preview
|
| 276 |
+
|
| 277 |
+
# Type check
|
| 278 |
+
npm run type-check
|
| 279 |
+
|
| 280 |
+
# Lint code
|
| 281 |
+
npm run lint
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
### File Paths
|
| 285 |
+
|
| 286 |
+
Components use path aliases for clean imports:
|
| 287 |
+
|
| 288 |
+
```tsx
|
| 289 |
+
// Instead of:
|
| 290 |
+
import Button from '../../../components/common/Button';
|
| 291 |
+
|
| 292 |
+
// Use:
|
| 293 |
+
import { Button } from '@/components/common';
|
| 294 |
+
import type { Job } from '@/types';
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
Available aliases:
|
| 298 |
+
- `@/components/*` - Components
|
| 299 |
+
- `@/store/*` - State management
|
| 300 |
+
- `@/types/*` - Type definitions
|
| 301 |
+
- `@/utils/*` - Utilities
|
| 302 |
+
- `@/hooks/*` - Custom hooks
|
| 303 |
+
|
| 304 |
+
## 🚀 Build & Deployment
|
| 305 |
+
|
| 306 |
+
### Production Build
|
| 307 |
+
|
| 308 |
+
```bash
|
| 309 |
+
npm run build
|
| 310 |
+
```
|
| 311 |
+
|
| 312 |
+
This generates a production-optimized build in `dist/` folder that gets served by the Flask backend.
|
| 313 |
+
|
| 314 |
+
### Integration with Flask
|
| 315 |
+
|
| 316 |
+
The Vite config is set up to build directly into the Flask static folder:
|
| 317 |
+
|
| 318 |
+
```
|
| 319 |
+
frontend/dist/ → job_apply_ai/ui/static/dist/
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
The Flask app serves these files at `/static/dist/` or `/app/` routes.
|
| 323 |
+
|
| 324 |
+
## 📱 Responsive Design
|
| 325 |
+
|
| 326 |
+
The app is fully responsive with Tailwind breakpoints:
|
| 327 |
+
|
| 328 |
+
```tsx
|
| 329 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
| 330 |
+
// 1 column on mobile, 2 on tablet, 4 on desktop
|
| 331 |
+
</div>
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
## ♿ Accessibility
|
| 335 |
+
|
| 336 |
+
- Semantic HTML
|
| 337 |
+
- ARIA labels where needed
|
| 338 |
+
- Keyboard navigation support
|
| 339 |
+
- Focus states for all interactive elements
|
| 340 |
+
- Color contrast compliance
|
| 341 |
+
|
| 342 |
+
## 🐛 Troubleshooting
|
| 343 |
+
|
| 344 |
+
### Dev server not connecting to backend
|
| 345 |
+
- Ensure Flask is running on port 5050
|
| 346 |
+
- Check `VITE_API_URL` in `.env`
|
| 347 |
+
- Clear browser cache
|
| 348 |
+
|
| 349 |
+
### Tailwind styles not applying
|
| 350 |
+
- Restart dev server
|
| 351 |
+
- Verify file paths in `tailwind.config.js` include all source files
|
| 352 |
+
|
| 353 |
+
### Type errors
|
| 354 |
+
- Run `npm run type-check`
|
| 355 |
+
- Ensure `tsconfig.json` paths are correct
|
| 356 |
+
|
| 357 |
+
## 📄 License
|
| 358 |
+
|
| 359 |
+
MIT
|
| 360 |
+
|
| 361 |
+
## 🤝 Contributing
|
| 362 |
+
|
| 363 |
+
Contributions welcome! Please follow the existing code style and component patterns.
|
frontend/index.html
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta
|
| 8 |
+
name="description"
|
| 9 |
+
content="Job Apply AI - Automate your job application process with intelligent AI-powered CV tailoring"
|
| 10 |
+
/>
|
| 11 |
+
<meta name="theme-color" content="#0A0E27" />
|
| 12 |
+
<title>SkillSync AI - Automate Job Applications</title>
|
| 13 |
+
|
| 14 |
+
<!-- Fonts -->
|
| 15 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 16 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 17 |
+
<link
|
| 18 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
|
| 19 |
+
rel="stylesheet"
|
| 20 |
+
/>
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
<div id="root"></div>
|
| 24 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 25 |
+
</body>
|
| 26 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "job-apply-ai-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Modern React SaaS frontend for Job Apply AI",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
| 11 |
+
"type-check": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"react": "^18.2.0",
|
| 15 |
+
"react-dom": "^18.2.0",
|
| 16 |
+
"zustand": "^4.4.0",
|
| 17 |
+
"framer-motion": "^10.16.0",
|
| 18 |
+
"lucide-react": "^0.294.0",
|
| 19 |
+
"axios": "^1.6.0",
|
| 20 |
+
"clsx": "^2.0.0",
|
| 21 |
+
"tailwind-merge": "^2.2.0"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"@types/react": "^18.2.0",
|
| 25 |
+
"@types/react-dom": "^18.2.0",
|
| 26 |
+
"@types/node": "^20.0.0",
|
| 27 |
+
"typescript": "^5.0.0",
|
| 28 |
+
"vite": "^5.0.0",
|
| 29 |
+
"@vitejs/plugin-react": "^4.0.0",
|
| 30 |
+
"tailwindcss": "^3.3.0",
|
| 31 |
+
"postcss": "^8.4.0",
|
| 32 |
+
"autoprefixer": "^10.4.0",
|
| 33 |
+
"eslint": "^8.50.0",
|
| 34 |
+
"eslint-plugin-react-hooks": "^4.6.0",
|
| 35 |
+
"eslint-plugin-react-refresh": "^0.4.3"
|
| 36 |
+
}
|
| 37 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { AnimatePresence } from 'framer-motion';
|
| 3 |
+
import { useJobStore } from '@/store/appStore';
|
| 4 |
+
import { HomePage, WorkflowPage, JobListPage, SettingsModal } from '@/components/pages';
|
| 5 |
+
import Toast from '@/components/common/Toast';
|
| 6 |
+
import { jobsAPI } from '@/utils/api';
|
| 7 |
+
|
| 8 |
+
const App: React.FC = () => {
|
| 9 |
+
const [isSettingsOpen, setIsSettingsOpen] = React.useState(false);
|
| 10 |
+
const {
|
| 11 |
+
currentPage,
|
| 12 |
+
navigateToHome,
|
| 13 |
+
navigateToWorkflow,
|
| 14 |
+
navigateToJobList,
|
| 15 |
+
setTailoringMode,
|
| 16 |
+
setLLMProvider,
|
| 17 |
+
setEnableProfessionalSummary,
|
| 18 |
+
setIncludeCoverLetters,
|
| 19 |
+
notification,
|
| 20 |
+
setNotification
|
| 21 |
+
} = useJobStore();
|
| 22 |
+
|
| 23 |
+
React.useEffect(() => {
|
| 24 |
+
const syncConfig = async () => {
|
| 25 |
+
try {
|
| 26 |
+
const config = await jobsAPI.getConfig();
|
| 27 |
+
setTailoringMode(config.tailoring_mode);
|
| 28 |
+
setLLMProvider(config.llm_provider);
|
| 29 |
+
setEnableProfessionalSummary(!!config.enable_professional_summary);
|
| 30 |
+
setIncludeCoverLetters(!!config.include_cover_letters);
|
| 31 |
+
} catch {
|
| 32 |
+
// Keep existing defaults if backend config is unavailable.
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
void syncConfig();
|
| 37 |
+
}, [setEnableProfessionalSummary, setIncludeCoverLetters, setLLMProvider, setTailoringMode]);
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className="min-h-screen bg-gradient-saas">
|
| 41 |
+
<AnimatePresence mode="wait">
|
| 42 |
+
{currentPage === 'home' && (
|
| 43 |
+
<HomePage
|
| 44 |
+
key="home"
|
| 45 |
+
onGetStarted={navigateToWorkflow}
|
| 46 |
+
onSettingsClick={() => setIsSettingsOpen(true)}
|
| 47 |
+
/>
|
| 48 |
+
)}
|
| 49 |
+
|
| 50 |
+
{currentPage === 'workflow' && (
|
| 51 |
+
<WorkflowPage
|
| 52 |
+
key="workflow"
|
| 53 |
+
onJobsFound={navigateToJobList}
|
| 54 |
+
onHomeClick={navigateToHome}
|
| 55 |
+
onSettingsClick={() => setIsSettingsOpen(true)}
|
| 56 |
+
/>
|
| 57 |
+
)}
|
| 58 |
+
|
| 59 |
+
{currentPage === 'joblist' && (
|
| 60 |
+
<JobListPage
|
| 61 |
+
key="joblist"
|
| 62 |
+
onBack={navigateToWorkflow}
|
| 63 |
+
onHomeClick={navigateToHome}
|
| 64 |
+
onSettingsClick={() => setIsSettingsOpen(true)}
|
| 65 |
+
/>
|
| 66 |
+
)}
|
| 67 |
+
</AnimatePresence>
|
| 68 |
+
|
| 69 |
+
{/* Settings Modal */}
|
| 70 |
+
<SettingsModal
|
| 71 |
+
isOpen={isSettingsOpen}
|
| 72 |
+
onClose={() => setIsSettingsOpen(false)}
|
| 73 |
+
/>
|
| 74 |
+
|
| 75 |
+
{/* Notification Toast */}
|
| 76 |
+
{notification && (
|
| 77 |
+
<div className="fixed bottom-4 left-4 z-50">
|
| 78 |
+
<Toast
|
| 79 |
+
type={notification.type}
|
| 80 |
+
message={notification.message}
|
| 81 |
+
onClose={() => setNotification(null)}
|
| 82 |
+
/>
|
| 83 |
+
</div>
|
| 84 |
+
)}
|
| 85 |
+
</div>
|
| 86 |
+
);
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
export default App;
|
frontend/src/components/common/Badge.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion, type HTMLMotionProps } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface BadgeProps extends Omit<HTMLMotionProps<'div'>, 'ref'> {
|
| 6 |
+
variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'error';
|
| 7 |
+
size?: 'sm' | 'md';
|
| 8 |
+
icon?: React.ReactNode;
|
| 9 |
+
children?: React.ReactNode;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
| 13 |
+
(
|
| 14 |
+
{
|
| 15 |
+
variant = 'primary',
|
| 16 |
+
size = 'md',
|
| 17 |
+
icon,
|
| 18 |
+
children,
|
| 19 |
+
className,
|
| 20 |
+
...props
|
| 21 |
+
},
|
| 22 |
+
ref
|
| 23 |
+
) => {
|
| 24 |
+
const variants = {
|
| 25 |
+
primary: 'bg-emerald-500/20 text-emerald-200 border border-emerald-500/30',
|
| 26 |
+
secondary: 'bg-slate-700/50 text-slate-200 border border-slate-600/50',
|
| 27 |
+
success: 'bg-green-500/20 text-green-200 border border-green-500/30',
|
| 28 |
+
warning: 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30',
|
| 29 |
+
error: 'bg-red-500/20 text-red-200 border border-red-500/30',
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const sizes = {
|
| 33 |
+
sm: 'px-2 py-1 text-xs gap-1',
|
| 34 |
+
md: 'px-3 py-1.5 text-sm gap-1.5',
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<motion.div
|
| 39 |
+
ref={ref}
|
| 40 |
+
whileHover={{ scale: 1.05 }}
|
| 41 |
+
className={cn(
|
| 42 |
+
'inline-flex items-center rounded-full font-medium transition-all duration-200',
|
| 43 |
+
variants[variant],
|
| 44 |
+
sizes[size],
|
| 45 |
+
className
|
| 46 |
+
)}
|
| 47 |
+
{...props}
|
| 48 |
+
>
|
| 49 |
+
{icon && <span className="flex">{icon}</span>}
|
| 50 |
+
{children}
|
| 51 |
+
</motion.div>
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
Badge.displayName = 'Badge';
|
| 57 |
+
|
| 58 |
+
export default Badge;
|
frontend/src/components/common/Button.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion, type HTMLMotionProps } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface ButtonProps extends Omit<HTMLMotionProps<'button'>, 'ref'> {
|
| 6 |
+
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
| 7 |
+
size?: 'sm' | 'md' | 'lg';
|
| 8 |
+
loading?: boolean;
|
| 9 |
+
fullWidth?: boolean;
|
| 10 |
+
icon?: React.ReactNode;
|
| 11 |
+
iconPosition?: 'left' | 'right';
|
| 12 |
+
children?: React.ReactNode;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 16 |
+
(
|
| 17 |
+
{
|
| 18 |
+
variant = 'primary',
|
| 19 |
+
size = 'md',
|
| 20 |
+
loading = false,
|
| 21 |
+
fullWidth = false,
|
| 22 |
+
icon,
|
| 23 |
+
iconPosition = 'left',
|
| 24 |
+
children,
|
| 25 |
+
className,
|
| 26 |
+
disabled,
|
| 27 |
+
...props
|
| 28 |
+
},
|
| 29 |
+
ref
|
| 30 |
+
) => {
|
| 31 |
+
const baseStyles =
|
| 32 |
+
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed';
|
| 33 |
+
|
| 34 |
+
const variants = {
|
| 35 |
+
primary:
|
| 36 |
+
'bg-gradient-emerald text-white hover:shadow-glow-lg active:scale-95',
|
| 37 |
+
secondary:
|
| 38 |
+
'bg-slate-700 text-white hover:bg-slate-600 active:scale-95',
|
| 39 |
+
outline:
|
| 40 |
+
'border-2 border-emerald-500 text-emerald-400 hover:bg-emerald-500 hover:bg-opacity-10',
|
| 41 |
+
ghost:
|
| 42 |
+
'text-slate-300 hover:bg-slate-800 hover:text-white active:scale-95',
|
| 43 |
+
danger:
|
| 44 |
+
'bg-red-600 text-white hover:bg-red-700 active:scale-95',
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const sizes = {
|
| 48 |
+
sm: 'px-3 py-1.5 text-sm gap-2',
|
| 49 |
+
md: 'px-4 py-2.5 text-base gap-2',
|
| 50 |
+
lg: 'px-6 py-3 text-lg gap-3',
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<motion.button
|
| 55 |
+
ref={ref}
|
| 56 |
+
whileHover={{ y: -2 }}
|
| 57 |
+
whileTap={{ scale: 0.98 }}
|
| 58 |
+
className={cn(
|
| 59 |
+
baseStyles,
|
| 60 |
+
variants[variant],
|
| 61 |
+
sizes[size],
|
| 62 |
+
fullWidth && 'w-full',
|
| 63 |
+
className
|
| 64 |
+
)}
|
| 65 |
+
disabled={loading || disabled}
|
| 66 |
+
{...props}
|
| 67 |
+
>
|
| 68 |
+
{icon && iconPosition === 'left' && !loading && (
|
| 69 |
+
<span className="flex">{icon}</span>
|
| 70 |
+
)}
|
| 71 |
+
{loading && (
|
| 72 |
+
<motion.div
|
| 73 |
+
animate={{ rotate: 360 }}
|
| 74 |
+
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
| 75 |
+
className="flex"
|
| 76 |
+
>
|
| 77 |
+
{icon || <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full" />}
|
| 78 |
+
</motion.div>
|
| 79 |
+
)}
|
| 80 |
+
{children}
|
| 81 |
+
{icon && iconPosition === 'right' && !loading && (
|
| 82 |
+
<span className="flex">{icon}</span>
|
| 83 |
+
)}
|
| 84 |
+
</motion.button>
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
);
|
| 88 |
+
|
| 89 |
+
Button.displayName = 'Button';
|
| 90 |
+
|
| 91 |
+
export default Button;
|
frontend/src/components/common/Card.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion, type HTMLMotionProps } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface CardProps extends Omit<HTMLMotionProps<'div'>, 'ref'> {
|
| 6 |
+
hover?: boolean;
|
| 7 |
+
glow?: boolean;
|
| 8 |
+
innerGlow?: boolean;
|
| 9 |
+
noPadding?: boolean;
|
| 10 |
+
children?: React.ReactNode;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
| 14 |
+
(
|
| 15 |
+
{
|
| 16 |
+
hover = true,
|
| 17 |
+
glow = false,
|
| 18 |
+
innerGlow = false,
|
| 19 |
+
noPadding = false,
|
| 20 |
+
children,
|
| 21 |
+
className,
|
| 22 |
+
...props
|
| 23 |
+
},
|
| 24 |
+
ref
|
| 25 |
+
) => {
|
| 26 |
+
return (
|
| 27 |
+
<motion.div
|
| 28 |
+
ref={ref}
|
| 29 |
+
whileHover={hover ? { y: -4 } : undefined}
|
| 30 |
+
className={cn(
|
| 31 |
+
'relative bg-slate-900 border border-slate-700 rounded-xl',
|
| 32 |
+
glow && 'shadow-glow',
|
| 33 |
+
noPadding ? '' : 'p-6',
|
| 34 |
+
className
|
| 35 |
+
)}
|
| 36 |
+
{...props}
|
| 37 |
+
>
|
| 38 |
+
{innerGlow && (
|
| 39 |
+
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-xl pointer-events-none" />
|
| 40 |
+
)}
|
| 41 |
+
<div className="relative z-10">{children}</div>
|
| 42 |
+
</motion.div>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
Card.displayName = 'Card';
|
| 48 |
+
|
| 49 |
+
export default Card;
|
frontend/src/components/common/Input.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion, type HTMLMotionProps } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface InputProps extends Omit<HTMLMotionProps<'input'>, 'ref'> {
|
| 6 |
+
label?: string;
|
| 7 |
+
error?: string;
|
| 8 |
+
icon?: React.ReactNode;
|
| 9 |
+
helperText?: string;
|
| 10 |
+
containerClassName?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
| 14 |
+
(
|
| 15 |
+
{
|
| 16 |
+
label,
|
| 17 |
+
error,
|
| 18 |
+
icon,
|
| 19 |
+
helperText,
|
| 20 |
+
containerClassName,
|
| 21 |
+
className,
|
| 22 |
+
...props
|
| 23 |
+
},
|
| 24 |
+
ref
|
| 25 |
+
) => {
|
| 26 |
+
return (
|
| 27 |
+
<div className={cn('w-full', containerClassName)}>
|
| 28 |
+
{label && (
|
| 29 |
+
<label className="block text-sm font-medium text-slate-300 mb-2">
|
| 30 |
+
{label}
|
| 31 |
+
{props.required && <span className="text-red-500 ml-1">*</span>}
|
| 32 |
+
</label>
|
| 33 |
+
)}
|
| 34 |
+
<div className="relative">
|
| 35 |
+
{icon && (
|
| 36 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">
|
| 37 |
+
{icon}
|
| 38 |
+
</div>
|
| 39 |
+
)}
|
| 40 |
+
<motion.input
|
| 41 |
+
ref={ref}
|
| 42 |
+
whileFocus={{
|
| 43 |
+
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.1)',
|
| 44 |
+
}}
|
| 45 |
+
className={cn(
|
| 46 |
+
'w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5',
|
| 47 |
+
'text-slate-50 placeholder-slate-500',
|
| 48 |
+
'transition-all duration-200',
|
| 49 |
+
'focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20',
|
| 50 |
+
icon && 'pl-10',
|
| 51 |
+
error && 'border-red-500 focus:border-red-500 focus:ring-red-500/20',
|
| 52 |
+
className
|
| 53 |
+
)}
|
| 54 |
+
{...props}
|
| 55 |
+
/>
|
| 56 |
+
</div>
|
| 57 |
+
{error && (
|
| 58 |
+
<motion.p
|
| 59 |
+
initial={{ opacity: 0, y: -4 }}
|
| 60 |
+
animate={{ opacity: 1, y: 0 }}
|
| 61 |
+
className="text-sm text-red-500 mt-1"
|
| 62 |
+
>
|
| 63 |
+
{error}
|
| 64 |
+
</motion.p>
|
| 65 |
+
)}
|
| 66 |
+
{helperText && !error && (
|
| 67 |
+
<p className="text-xs text-slate-500 mt-1">{helperText}</p>
|
| 68 |
+
)}
|
| 69 |
+
</div>
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
Input.displayName = 'Input';
|
| 75 |
+
|
| 76 |
+
export default Input;
|
frontend/src/components/common/Modal.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface ModalProps {
|
| 6 |
+
isOpen: boolean;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
title?: string;
|
| 9 |
+
children: React.ReactNode;
|
| 10 |
+
size?: 'sm' | 'md' | 'lg' | 'xl';
|
| 11 |
+
closeButton?: boolean;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
| 15 |
+
(
|
| 16 |
+
{
|
| 17 |
+
isOpen,
|
| 18 |
+
onClose,
|
| 19 |
+
title,
|
| 20 |
+
children,
|
| 21 |
+
size = 'md',
|
| 22 |
+
closeButton = true,
|
| 23 |
+
},
|
| 24 |
+
ref
|
| 25 |
+
) => {
|
| 26 |
+
const sizes = {
|
| 27 |
+
sm: 'max-w-sm',
|
| 28 |
+
md: 'max-w-md',
|
| 29 |
+
lg: 'max-w-lg',
|
| 30 |
+
xl: 'max-w-2xl',
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<AnimatePresence>
|
| 35 |
+
{isOpen && (
|
| 36 |
+
<>
|
| 37 |
+
<motion.div
|
| 38 |
+
initial={{ opacity: 0 }}
|
| 39 |
+
animate={{ opacity: 1 }}
|
| 40 |
+
exit={{ opacity: 0 }}
|
| 41 |
+
onClick={onClose}
|
| 42 |
+
className="fixed inset-0 bg-black/50 z-40"
|
| 43 |
+
/>
|
| 44 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
| 45 |
+
<motion.div
|
| 46 |
+
ref={ref}
|
| 47 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 48 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 49 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 50 |
+
className={cn(
|
| 51 |
+
'bg-slate-900 border border-emerald-500/10 rounded-xl shadow-2xl pointer-events-auto overflow-hidden max-h-[calc(100vh-2rem)] flex flex-col',
|
| 52 |
+
sizes[size],
|
| 53 |
+
'w-full'
|
| 54 |
+
)}
|
| 55 |
+
>
|
| 56 |
+
{title && (
|
| 57 |
+
<div className="border-b border-slate-700 px-6 py-4 flex items-center justify-between">
|
| 58 |
+
<h2 className="text-xl font-semibold text-slate-50">
|
| 59 |
+
{title}
|
| 60 |
+
</h2>
|
| 61 |
+
{closeButton && (
|
| 62 |
+
<motion.button
|
| 63 |
+
whileHover={{ scale: 1.1 }}
|
| 64 |
+
whileTap={{ scale: 0.95 }}
|
| 65 |
+
onClick={onClose}
|
| 66 |
+
className="text-slate-400 hover:text-slate-200 transition-colors"
|
| 67 |
+
>
|
| 68 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 69 |
+
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
| 70 |
+
</svg>
|
| 71 |
+
</motion.button>
|
| 72 |
+
)}
|
| 73 |
+
</div>
|
| 74 |
+
)}
|
| 75 |
+
<div className="p-6 overflow-y-auto flex-1 overscroll-contain">
|
| 76 |
+
{children}
|
| 77 |
+
</div>
|
| 78 |
+
</motion.div>
|
| 79 |
+
</div>
|
| 80 |
+
</>
|
| 81 |
+
)}
|
| 82 |
+
</AnimatePresence>
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
);
|
| 86 |
+
|
| 87 |
+
Modal.displayName = 'Modal';
|
| 88 |
+
|
| 89 |
+
export default Modal;
|
frontend/src/components/common/ProgressBar.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface ProgressBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
| 6 |
+
value: number;
|
| 7 |
+
max?: number;
|
| 8 |
+
showLabel?: boolean;
|
| 9 |
+
variant?: 'primary' | 'success' | 'warning' | 'error';
|
| 10 |
+
animated?: boolean;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const ProgressBar = React.forwardRef<HTMLDivElement, ProgressBarProps>(
|
| 14 |
+
(
|
| 15 |
+
{
|
| 16 |
+
value,
|
| 17 |
+
max = 100,
|
| 18 |
+
showLabel = true,
|
| 19 |
+
variant = 'primary',
|
| 20 |
+
animated = true,
|
| 21 |
+
className,
|
| 22 |
+
...props
|
| 23 |
+
},
|
| 24 |
+
ref
|
| 25 |
+
) => {
|
| 26 |
+
const percentage = Math.min((value / max) * 100, 100);
|
| 27 |
+
|
| 28 |
+
const variants = {
|
| 29 |
+
primary: 'bg-gradient-emerald',
|
| 30 |
+
success: 'bg-green-500',
|
| 31 |
+
warning: 'bg-yellow-500',
|
| 32 |
+
error: 'bg-red-500',
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div ref={ref} className={cn('w-full', className)} {...props}>
|
| 37 |
+
<div className="bg-slate-800 rounded-full h-2 overflow-hidden border border-slate-700">
|
| 38 |
+
<motion.div
|
| 39 |
+
initial={{ width: 0 }}
|
| 40 |
+
animate={{ width: `${percentage}%` }}
|
| 41 |
+
transition={{ duration: 0.5, ease: 'easeOut' }}
|
| 42 |
+
className={cn(variants[variant], animated && 'animate-pulse-glow')}
|
| 43 |
+
/>
|
| 44 |
+
</div>
|
| 45 |
+
{showLabel && (
|
| 46 |
+
<p className="text-xs text-slate-400 mt-2">
|
| 47 |
+
{value} / {max}
|
| 48 |
+
</p>
|
| 49 |
+
)}
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
ProgressBar.displayName = 'ProgressBar';
|
| 56 |
+
|
| 57 |
+
export default ProgressBar;
|
frontend/src/components/common/Select.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion, AnimatePresence, type HTMLMotionProps } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface SelectProps extends Omit<HTMLMotionProps<'select'>, 'ref'> {
|
| 6 |
+
label?: string;
|
| 7 |
+
error?: string;
|
| 8 |
+
icon?: React.ReactNode;
|
| 9 |
+
options: Array<{ value: string; label: string }>;
|
| 10 |
+
helperText?: string;
|
| 11 |
+
containerClassName?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
| 15 |
+
(
|
| 16 |
+
{
|
| 17 |
+
label,
|
| 18 |
+
error,
|
| 19 |
+
icon,
|
| 20 |
+
options,
|
| 21 |
+
helperText,
|
| 22 |
+
containerClassName,
|
| 23 |
+
className,
|
| 24 |
+
...props
|
| 25 |
+
},
|
| 26 |
+
ref
|
| 27 |
+
) => {
|
| 28 |
+
return (
|
| 29 |
+
<div className={cn('w-full', containerClassName)}>
|
| 30 |
+
{label && (
|
| 31 |
+
<label className="block text-sm font-medium text-slate-300 mb-2">
|
| 32 |
+
{label}
|
| 33 |
+
{props.required && <span className="text-red-500 ml-1">*</span>}
|
| 34 |
+
</label>
|
| 35 |
+
)}
|
| 36 |
+
<div className="relative">
|
| 37 |
+
{icon && (
|
| 38 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none z-10">
|
| 39 |
+
{icon}
|
| 40 |
+
</div>
|
| 41 |
+
)}
|
| 42 |
+
<motion.select
|
| 43 |
+
ref={ref}
|
| 44 |
+
whileFocus={{
|
| 45 |
+
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.1)',
|
| 46 |
+
}}
|
| 47 |
+
className={cn(
|
| 48 |
+
'w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 appearance-none',
|
| 49 |
+
'text-slate-50',
|
| 50 |
+
'transition-all duration-200',
|
| 51 |
+
'focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20',
|
| 52 |
+
'cursor-pointer',
|
| 53 |
+
icon && 'pl-10',
|
| 54 |
+
error && 'border-red-500 focus:border-red-500 focus:ring-red-500/20',
|
| 55 |
+
className
|
| 56 |
+
)}
|
| 57 |
+
{...props}
|
| 58 |
+
>
|
| 59 |
+
{options.map((opt) => (
|
| 60 |
+
<option key={opt.value} value={opt.value}>
|
| 61 |
+
{opt.label}
|
| 62 |
+
</option>
|
| 63 |
+
))}
|
| 64 |
+
</motion.select>
|
| 65 |
+
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
| 66 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 67 |
+
<path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
| 68 |
+
</svg>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
<AnimatePresence mode="wait">
|
| 72 |
+
{error && (
|
| 73 |
+
<motion.p
|
| 74 |
+
initial={{ opacity: 0, y: -4 }}
|
| 75 |
+
animate={{ opacity: 1, y: 0 }}
|
| 76 |
+
exit={{ opacity: 0, y: -4 }}
|
| 77 |
+
className="text-sm text-red-500 mt-1"
|
| 78 |
+
>
|
| 79 |
+
{error}
|
| 80 |
+
</motion.p>
|
| 81 |
+
)}
|
| 82 |
+
</AnimatePresence>
|
| 83 |
+
{helperText && !error && (
|
| 84 |
+
<p className="text-xs text-slate-500 mt-1">{helperText}</p>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
Select.displayName = 'Select';
|
| 92 |
+
|
| 93 |
+
export default Select;
|
frontend/src/components/common/Spinner.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
|
| 4 |
+
interface SpinnerProps {
|
| 5 |
+
size?: 'sm' | 'md' | 'lg';
|
| 6 |
+
color?: 'emerald' | 'white';
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const Spinner: React.FC<SpinnerProps> = ({ size = 'md', color = 'emerald' }) => {
|
| 10 |
+
const sizes = {
|
| 11 |
+
sm: 'w-4 h-4',
|
| 12 |
+
md: 'w-6 h-6',
|
| 13 |
+
lg: 'w-8 h-8',
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const colors = {
|
| 17 |
+
emerald: 'border-emerald-500 border-t-transparent',
|
| 18 |
+
white: 'border-white border-t-transparent',
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<motion.div
|
| 23 |
+
animate={{ rotate: 360 }}
|
| 24 |
+
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
| 25 |
+
className={`${sizes[size]} ${colors[color]} border-2 rounded-full`}
|
| 26 |
+
/>
|
| 27 |
+
);
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export default Spinner;
|
frontend/src/components/common/Tabs.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { cn } from '@/utils/helpers';
|
| 4 |
+
|
| 5 |
+
interface TabsProps {
|
| 6 |
+
tabs: Array<{
|
| 7 |
+
label: string;
|
| 8 |
+
value: string;
|
| 9 |
+
icon?: React.ReactNode;
|
| 10 |
+
}>;
|
| 11 |
+
value: string;
|
| 12 |
+
onChange: (value: string) => void;
|
| 13 |
+
className?: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const Tabs: React.FC<TabsProps> = ({ tabs, value, onChange, className }) => {
|
| 17 |
+
return (
|
| 18 |
+
<div className={cn('flex gap-1 p-1 bg-slate-800 rounded-lg w-fit', className)}>
|
| 19 |
+
{tabs.map((tab) => (
|
| 20 |
+
<motion.button
|
| 21 |
+
key={tab.value}
|
| 22 |
+
onClick={() => onChange(tab.value)}
|
| 23 |
+
className={cn(
|
| 24 |
+
'px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 flex items-center gap-2',
|
| 25 |
+
value === tab.value
|
| 26 |
+
? 'text-white'
|
| 27 |
+
: 'text-slate-400 hover:text-slate-200'
|
| 28 |
+
)}
|
| 29 |
+
whileHover={{ y: -1 }}
|
| 30 |
+
whileTap={{ scale: 0.98 }}
|
| 31 |
+
>
|
| 32 |
+
{tab.icon && <span>{tab.icon}</span>}
|
| 33 |
+
{tab.label}
|
| 34 |
+
{value === tab.value && (
|
| 35 |
+
<motion.div
|
| 36 |
+
layoutId="activeTab"
|
| 37 |
+
className="absolute inset-0 bg-gradient-emerald rounded-md -z-10"
|
| 38 |
+
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
| 39 |
+
/>
|
| 40 |
+
)}
|
| 41 |
+
</motion.button>
|
| 42 |
+
))}
|
| 43 |
+
</div>
|
| 44 |
+
);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
export default Tabs;
|
frontend/src/components/common/Toast.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import { CheckCircle, AlertCircle, Info, X } from 'lucide-react';
|
| 4 |
+
import { cn } from '@/utils/helpers';
|
| 5 |
+
|
| 6 |
+
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
| 7 |
+
|
| 8 |
+
interface ToastProps {
|
| 9 |
+
type: ToastType;
|
| 10 |
+
message: string;
|
| 11 |
+
title?: string;
|
| 12 |
+
onClose?: () => void;
|
| 13 |
+
autoClose?: boolean;
|
| 14 |
+
autoCloseDuration?: number;
|
| 15 |
+
action?: {
|
| 16 |
+
label: string;
|
| 17 |
+
onClick: () => void;
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
|
| 22 |
+
(
|
| 23 |
+
{
|
| 24 |
+
type,
|
| 25 |
+
message,
|
| 26 |
+
title,
|
| 27 |
+
onClose,
|
| 28 |
+
autoClose = true,
|
| 29 |
+
autoCloseDuration = 5000,
|
| 30 |
+
action,
|
| 31 |
+
},
|
| 32 |
+
ref
|
| 33 |
+
) => {
|
| 34 |
+
const [isVisible, setIsVisible] = React.useState(true);
|
| 35 |
+
|
| 36 |
+
React.useEffect(() => {
|
| 37 |
+
if (!autoClose) return;
|
| 38 |
+
const timer = setTimeout(() => {
|
| 39 |
+
setIsVisible(false);
|
| 40 |
+
onClose?.();
|
| 41 |
+
}, autoCloseDuration);
|
| 42 |
+
return () => clearTimeout(timer);
|
| 43 |
+
}, [autoClose, autoCloseDuration, onClose]);
|
| 44 |
+
|
| 45 |
+
const icons = {
|
| 46 |
+
success: <CheckCircle className="w-5 h-5" />,
|
| 47 |
+
error: <AlertCircle className="w-5 h-5" />,
|
| 48 |
+
warning: <AlertCircle className="w-5 h-5" />,
|
| 49 |
+
info: <Info className="w-5 h-5" />,
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const colors = {
|
| 53 |
+
success: 'bg-emerald-500/20 border-emerald-500/30 text-emerald-200',
|
| 54 |
+
error: 'bg-red-500/20 border-red-500/30 text-red-200',
|
| 55 |
+
warning: 'bg-yellow-500/20 border-yellow-500/30 text-yellow-200',
|
| 56 |
+
info: 'bg-blue-500/20 border-blue-500/30 text-blue-200',
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const iconColors = {
|
| 60 |
+
success: 'text-emerald-400',
|
| 61 |
+
error: 'text-red-400',
|
| 62 |
+
warning: 'text-yellow-400',
|
| 63 |
+
info: 'text-blue-400',
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<AnimatePresence>
|
| 68 |
+
{isVisible && (
|
| 69 |
+
<motion.div
|
| 70 |
+
ref={ref}
|
| 71 |
+
initial={{ opacity: 0, y: 20 }}
|
| 72 |
+
animate={{ opacity: 1, y: 0 }}
|
| 73 |
+
exit={{ opacity: 0, y: 20 }}
|
| 74 |
+
className={cn(
|
| 75 |
+
'flex items-start gap-3 w-full max-w-md px-4 py-3 rounded-lg border',
|
| 76 |
+
colors[type]
|
| 77 |
+
)}
|
| 78 |
+
>
|
| 79 |
+
<div className={cn('mt-0.5 flex-shrink-0', iconColors[type])}>
|
| 80 |
+
{icons[type]}
|
| 81 |
+
</div>
|
| 82 |
+
<div className="flex-1 min-w-0">
|
| 83 |
+
{title && (
|
| 84 |
+
<p className="font-semibold text-sm mb-0.5">{title}</p>
|
| 85 |
+
)}
|
| 86 |
+
<p className="text-sm opacity-90">{message}</p>
|
| 87 |
+
{action && (
|
| 88 |
+
<motion.button
|
| 89 |
+
whileHover={{ x: 2 }}
|
| 90 |
+
onClick={action.onClick}
|
| 91 |
+
className="text-xs font-medium mt-2 opacity-80 hover:opacity-100 transition-opacity"
|
| 92 |
+
>
|
| 93 |
+
{action.label}
|
| 94 |
+
</motion.button>
|
| 95 |
+
)}
|
| 96 |
+
</div>
|
| 97 |
+
{onClose && (
|
| 98 |
+
<motion.button
|
| 99 |
+
whileHover={{ scale: 1.1 }}
|
| 100 |
+
whileTap={{ scale: 0.95 }}
|
| 101 |
+
onClick={() => {
|
| 102 |
+
setIsVisible(false);
|
| 103 |
+
onClose?.();
|
| 104 |
+
}}
|
| 105 |
+
className="flex-shrink-0 opacity-50 hover:opacity-100 transition-opacity"
|
| 106 |
+
>
|
| 107 |
+
<X className="w-4 h-4" />
|
| 108 |
+
</motion.button>
|
| 109 |
+
)}
|
| 110 |
+
</motion.div>
|
| 111 |
+
)}
|
| 112 |
+
</AnimatePresence>
|
| 113 |
+
);
|
| 114 |
+
}
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
Toast.displayName = 'Toast';
|
| 118 |
+
|
| 119 |
+
export default Toast;
|
frontend/src/components/common/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Export all common components
|
| 2 |
+
export { default as Button } from './Button';
|
| 3 |
+
export { default as Card } from './Card';
|
| 4 |
+
export { default as Input } from './Input';
|
| 5 |
+
export { default as Select } from './Select';
|
| 6 |
+
export { default as Badge } from './Badge';
|
| 7 |
+
export { default as Toast } from './Toast';
|
| 8 |
+
export { default as Spinner } from './Spinner';
|
| 9 |
+
export { default as Modal } from './Modal';
|
| 10 |
+
export { default as ProgressBar } from './ProgressBar';
|
| 11 |
+
export { default as Tabs } from './Tabs';
|
frontend/src/components/pages/HomePage.tsx
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { motion } from "framer-motion";
|
| 3 |
+
import { ArrowRight, Zap, Brain, Zap as FastIcon, Layers } from "lucide-react";
|
| 4 |
+
import { Button, Card } from "@/components/common";
|
| 5 |
+
import Header from "@/components/sections/Header";
|
| 6 |
+
import Footer from "@/components/sections/Footer";
|
| 7 |
+
|
| 8 |
+
interface HomePageProps {
|
| 9 |
+
onGetStarted?: () => void;
|
| 10 |
+
onSettingsClick?: () => void;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const HomePage: React.FC<HomePageProps> = ({
|
| 14 |
+
onGetStarted,
|
| 15 |
+
onSettingsClick,
|
| 16 |
+
}) => {
|
| 17 |
+
const features = [
|
| 18 |
+
{
|
| 19 |
+
icon: Zap,
|
| 20 |
+
title: "Smart Job Scraping",
|
| 21 |
+
description:
|
| 22 |
+
"Automatically search and collect job listings from LinkedIn",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
icon: Brain,
|
| 26 |
+
title: "AI-Powered Analysis",
|
| 27 |
+
description: "Extract key skills and requirements from job descriptions",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
icon: FastIcon,
|
| 31 |
+
title: "CV Tailoring",
|
| 32 |
+
description: "Automatically customize your CV for each job application",
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
icon: Layers,
|
| 36 |
+
title: "Batch Processing",
|
| 37 |
+
description: "Generate multiple tailored CVs for different jobs at once",
|
| 38 |
+
},
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
const containerVariants = {
|
| 42 |
+
hidden: { opacity: 0 },
|
| 43 |
+
visible: {
|
| 44 |
+
opacity: 1,
|
| 45 |
+
transition: {
|
| 46 |
+
staggerChildren: 0.1,
|
| 47 |
+
delayChildren: 0.2,
|
| 48 |
+
},
|
| 49 |
+
},
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const itemVariants = {
|
| 53 |
+
hidden: { opacity: 0, y: 20 },
|
| 54 |
+
visible: {
|
| 55 |
+
opacity: 1,
|
| 56 |
+
y: 0,
|
| 57 |
+
transition: { duration: 0.5 },
|
| 58 |
+
},
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="min-h-screen bg-gradient-saas">
|
| 63 |
+
<Header onSettingsClick={onSettingsClick} />
|
| 64 |
+
|
| 65 |
+
{/* Hero Section */}
|
| 66 |
+
<motion.section
|
| 67 |
+
initial={{ opacity: 0 }}
|
| 68 |
+
animate={{ opacity: 1 }}
|
| 69 |
+
transition={{ duration: 0.6 }}
|
| 70 |
+
className="relative px-4 sm:px-6 lg:px-8 py-20 lg:py-32 max-w-7xl mx-auto"
|
| 71 |
+
>
|
| 72 |
+
{/* Background gradient orbs */}
|
| 73 |
+
<div className="absolute top-0 left-1/4 w-96 h-96 bg-emerald-500/20 rounded-full blur-3xl -z-10" />
|
| 74 |
+
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl -z-10" />
|
| 75 |
+
|
| 76 |
+
<div className="text-center mb-16">
|
| 77 |
+
<motion.div
|
| 78 |
+
variants={itemVariants}
|
| 79 |
+
className="mb-6 inline-flex items-center gap-2 bg-emerald-500/10 border border-emerald-500/30 rounded-full px-4 py-2"
|
| 80 |
+
>
|
| 81 |
+
<Zap className="w-4 h-4 text-emerald-400" />
|
| 82 |
+
<span className="text-sm font-medium text-emerald-200">
|
| 83 |
+
Powered by AI
|
| 84 |
+
</span>
|
| 85 |
+
</motion.div>
|
| 86 |
+
|
| 87 |
+
<motion.h1
|
| 88 |
+
variants={itemVariants}
|
| 89 |
+
className="text-5xl lg:text-7xl font-bold text-white mb-6 leading-tight"
|
| 90 |
+
>
|
| 91 |
+
Get Your Dream Job
|
| 92 |
+
<br />
|
| 93 |
+
<span className="bg-gradient-emerald bg-clip-text text-transparent">
|
| 94 |
+
Faster
|
| 95 |
+
</span>
|
| 96 |
+
</motion.h1>
|
| 97 |
+
|
| 98 |
+
<motion.p
|
| 99 |
+
variants={itemVariants}
|
| 100 |
+
className="text-xl text-slate-300 max-w-2xl mx-auto mb-8"
|
| 101 |
+
>
|
| 102 |
+
Automate your job application process with intelligent CV tailoring.
|
| 103 |
+
Search jobs, analyze requirements, and generate tailored
|
| 104 |
+
applications in minutes.
|
| 105 |
+
</motion.p>
|
| 106 |
+
|
| 107 |
+
<motion.div
|
| 108 |
+
variants={itemVariants}
|
| 109 |
+
className="flex flex-col sm:flex-row gap-4 justify-center"
|
| 110 |
+
>
|
| 111 |
+
<Button
|
| 112 |
+
size="lg"
|
| 113 |
+
onClick={onGetStarted}
|
| 114 |
+
icon={<ArrowRight className="w-5 h-5" />}
|
| 115 |
+
>
|
| 116 |
+
Get Started
|
| 117 |
+
</Button>
|
| 118 |
+
<Button size="lg" variant="outline" onClick={onSettingsClick}>
|
| 119 |
+
Learn More
|
| 120 |
+
</Button>
|
| 121 |
+
</motion.div>
|
| 122 |
+
</div>
|
| 123 |
+
</motion.section>
|
| 124 |
+
|
| 125 |
+
{/* Features Section */}
|
| 126 |
+
<motion.section
|
| 127 |
+
variants={containerVariants}
|
| 128 |
+
initial="hidden"
|
| 129 |
+
whileInView="visible"
|
| 130 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 131 |
+
className="px-4 sm:px-6 lg:px-8 py-20 max-w-7xl mx-auto"
|
| 132 |
+
>
|
| 133 |
+
<div className="text-center mb-16">
|
| 134 |
+
<h2 className="text-4xl font-bold text-white mb-4">
|
| 135 |
+
Why Choose SkillSync?
|
| 136 |
+
</h2>
|
| 137 |
+
<p className="text-slate-400 max-w-2xl mx-auto">
|
| 138 |
+
Leverage cutting-edge AI and automation to transform your job search
|
| 139 |
+
experience.
|
| 140 |
+
</p>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
| 144 |
+
{features.map((feature, index) => (
|
| 145 |
+
<motion.div key={index} variants={itemVariants}>
|
| 146 |
+
<Card hover glow innerGlow>
|
| 147 |
+
<div className="mb-4">
|
| 148 |
+
<div className="w-12 h-12 bg-emerald-500/20 rounded-lg flex items-center justify-center">
|
| 149 |
+
<feature.icon className="w-6 h-6 text-emerald-400" />
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
<h3 className="text-lg font-semibold text-white mb-2">
|
| 153 |
+
{feature.title}
|
| 154 |
+
</h3>
|
| 155 |
+
<p className="text-slate-400 text-sm">{feature.description}</p>
|
| 156 |
+
</Card>
|
| 157 |
+
</motion.div>
|
| 158 |
+
))}
|
| 159 |
+
</div>
|
| 160 |
+
</motion.section>
|
| 161 |
+
|
| 162 |
+
{/* Stats Section */}
|
| 163 |
+
<motion.section
|
| 164 |
+
initial={{ opacity: 0 }}
|
| 165 |
+
whileInView={{ opacity: 1 }}
|
| 166 |
+
viewport={{ once: true }}
|
| 167 |
+
className="px-4 sm:px-6 lg:px-8 py-20 max-w-7xl mx-auto"
|
| 168 |
+
>
|
| 169 |
+
<Card
|
| 170 |
+
glow
|
| 171 |
+
className="bg-gradient-to-br from-emerald-500/10 to-slate-900"
|
| 172 |
+
>
|
| 173 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
| 174 |
+
{[
|
| 175 |
+
{ number: "10K+", label: "Jobs Processed" },
|
| 176 |
+
{ number: "95%", label: "Success Rate" },
|
| 177 |
+
{ number: "45min", label: "Avg Time Saved" },
|
| 178 |
+
].map((stat, index) => (
|
| 179 |
+
<div key={index}>
|
| 180 |
+
<motion.p
|
| 181 |
+
className="text-4xl font-bold text-emerald-400 mb-2"
|
| 182 |
+
initial={{ opacity: 0, y: 10 }}
|
| 183 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 184 |
+
transition={{ delay: index * 0.1 }}
|
| 185 |
+
>
|
| 186 |
+
{stat.number}
|
| 187 |
+
</motion.p>
|
| 188 |
+
<p className="text-slate-400">{stat.label}</p>
|
| 189 |
+
</div>
|
| 190 |
+
))}
|
| 191 |
+
</div>
|
| 192 |
+
</Card>
|
| 193 |
+
</motion.section>
|
| 194 |
+
|
| 195 |
+
<Footer />
|
| 196 |
+
</div>
|
| 197 |
+
);
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
export default HomePage;
|
frontend/src/components/pages/JobListPage.tsx
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 3 |
+
import {
|
| 4 |
+
Briefcase,
|
| 5 |
+
Building2,
|
| 6 |
+
MapPin,
|
| 7 |
+
Clock,
|
| 8 |
+
ChevronDown,
|
| 9 |
+
Zap,
|
| 10 |
+
Cpu,
|
| 11 |
+
ExternalLink,
|
| 12 |
+
} from "lucide-react";
|
| 13 |
+
import { Card, Button, Badge, Spinner } from "@/components/common";
|
| 14 |
+
import { useJobStore } from "@/store/appStore";
|
| 15 |
+
import { jobsAPI } from "@/utils/api";
|
| 16 |
+
import { downloadFile } from "@/utils/helpers";
|
| 17 |
+
import type { Job } from "@/types";
|
| 18 |
+
import { JobFitAnalyzer } from "../sections/JobFitAnalyzer";
|
| 19 |
+
|
| 20 |
+
interface JobListPageProps {
|
| 21 |
+
onBack?: () => void;
|
| 22 |
+
onHomeClick?: () => void;
|
| 23 |
+
onSettingsClick?: () => void;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const JobListPage: React.FC<JobListPageProps> = ({
|
| 27 |
+
onBack,
|
| 28 |
+
onHomeClick,
|
| 29 |
+
onSettingsClick,
|
| 30 |
+
}) => {
|
| 31 |
+
const {
|
| 32 |
+
jobs,
|
| 33 |
+
setJobs, // Temporary
|
| 34 |
+
selectedJobIds,
|
| 35 |
+
toggleJobSelection,
|
| 36 |
+
selectAllJobs,
|
| 37 |
+
deselectAllJobs,
|
| 38 |
+
isGenerating,
|
| 39 |
+
setIsGenerating,
|
| 40 |
+
batchProgress,
|
| 41 |
+
setBatchProgress,
|
| 42 |
+
addGeneratedCV,
|
| 43 |
+
setNotification,
|
| 44 |
+
tailoringMode,
|
| 45 |
+
llmProvider,
|
| 46 |
+
enableProfessionalSummary,
|
| 47 |
+
includeCoverLetters,
|
| 48 |
+
} = useJobStore();
|
| 49 |
+
|
| 50 |
+
const [expandedJobId, setExpandedJobId] = React.useState<string | null>(null);
|
| 51 |
+
const [generatingJobId, setGeneratingJobId] = React.useState<string | null>(
|
| 52 |
+
null
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
const selectedCount = selectedJobIds.size;
|
| 56 |
+
const allSelected = selectedCount === jobs.length && jobs.length > 0;
|
| 57 |
+
|
| 58 |
+
const getLLMProviderLabel = (provider: string) => {
|
| 59 |
+
const labels: Record<string, string> = {
|
| 60 |
+
ollama: "Ollama",
|
| 61 |
+
groq: "Groq",
|
| 62 |
+
grok: "Grok",
|
| 63 |
+
openai: "OpenAI",
|
| 64 |
+
};
|
| 65 |
+
return labels[provider] || provider;
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const handleGenerateSingleCV = async (job: Job) => {
|
| 69 |
+
setGeneratingJobId(job.id);
|
| 70 |
+
try {
|
| 71 |
+
const response = await jobsAPI.generateCV(job.id);
|
| 72 |
+
if (response.success) {
|
| 73 |
+
// Temporary if statement below
|
| 74 |
+
if (response.job) {
|
| 75 |
+
const updatedJobs = jobs.map((j) =>
|
| 76 |
+
j.id === job.id ? { ...j, ...response.job } : j
|
| 77 |
+
);
|
| 78 |
+
setJobs(updatedJobs as Job[]);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
addGeneratedCV({
|
| 82 |
+
jobId: job.id,
|
| 83 |
+
jobTitle: job.title,
|
| 84 |
+
company: job.company,
|
| 85 |
+
filename: response.filename,
|
| 86 |
+
url: response.url,
|
| 87 |
+
coverLetterFilename: response.cover_letter_filename,
|
| 88 |
+
coverLetterUrl: response.cover_letter_url,
|
| 89 |
+
generatedAt: new Date().toISOString(),
|
| 90 |
+
status: "success",
|
| 91 |
+
});
|
| 92 |
+
const hasCoverLetter = !!response.cover_letter_filename;
|
| 93 |
+
setNotification({
|
| 94 |
+
type: "success",
|
| 95 |
+
message: hasCoverLetter
|
| 96 |
+
? `CV + Cover Letter generated for ${job.company} - ${job.title}`
|
| 97 |
+
: `CV generated for ${job.company} - ${job.title}`,
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
// Download the file
|
| 101 |
+
const blob = await jobsAPI.downloadFile(response.filename);
|
| 102 |
+
downloadFile(blob, response.filename);
|
| 103 |
+
|
| 104 |
+
if (response.cover_letter_filename) {
|
| 105 |
+
const coverLetterBlob = await jobsAPI.downloadFile(
|
| 106 |
+
response.cover_letter_filename
|
| 107 |
+
);
|
| 108 |
+
downloadFile(coverLetterBlob, response.cover_letter_filename);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
} catch (error) {
|
| 112 |
+
setNotification({
|
| 113 |
+
type: "error",
|
| 114 |
+
message: "Failed to generate CV",
|
| 115 |
+
});
|
| 116 |
+
} finally {
|
| 117 |
+
setGeneratingJobId(null);
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const handleGenerateAllCVs = async () => {
|
| 122 |
+
if (selectedCount === 0) {
|
| 123 |
+
setNotification({
|
| 124 |
+
type: "error",
|
| 125 |
+
message: "Please select at least one job",
|
| 126 |
+
});
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
setIsGenerating(true);
|
| 131 |
+
setBatchProgress({
|
| 132 |
+
total: selectedCount,
|
| 133 |
+
completed: 0,
|
| 134 |
+
failed: 0,
|
| 135 |
+
isProcessing: true,
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
try {
|
| 139 |
+
const jobIds = Array.from(selectedJobIds);
|
| 140 |
+
const response = await jobsAPI.generateAllCVs(jobIds);
|
| 141 |
+
|
| 142 |
+
setBatchProgress({
|
| 143 |
+
total: selectedCount,
|
| 144 |
+
completed: response.successful.length,
|
| 145 |
+
failed: response.failed.length,
|
| 146 |
+
isProcessing: false,
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
response.successful.forEach((cv) => {
|
| 150 |
+
addGeneratedCV(cv);
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
setNotification({
|
| 154 |
+
type: "success",
|
| 155 |
+
message: `Generated ${response.successful.length} CVs successfully`,
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
// Download the ZIP file
|
| 159 |
+
const blob = await jobsAPI.downloadFile(response.zip_filename);
|
| 160 |
+
downloadFile(blob, response.zip_filename);
|
| 161 |
+
} catch (error) {
|
| 162 |
+
setNotification({
|
| 163 |
+
type: "error",
|
| 164 |
+
message: "Batch generation failed",
|
| 165 |
+
});
|
| 166 |
+
} finally {
|
| 167 |
+
setIsGenerating(false);
|
| 168 |
+
}
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
if (jobs.length === 0) {
|
| 172 |
+
return (
|
| 173 |
+
<div className="min-h-screen bg-gradient-saas flex items-center justify-center px-4">
|
| 174 |
+
<Card className="text-center max-w-md border-emerald-500/20 shadow-[0_0_60px_rgba(34,197,94,0.08)]">
|
| 175 |
+
<div className="w-16 h-16 bg-emerald-500/20 rounded-lg flex items-center justify-center mx-auto mb-4">
|
| 176 |
+
<Briefcase className="w-8 h-8 text-emerald-400" />
|
| 177 |
+
</div>
|
| 178 |
+
<h2 className="text-2xl font-bold text-white mb-2">No Jobs Found</h2>
|
| 179 |
+
<p className="text-slate-400 mb-6">
|
| 180 |
+
Search for jobs to get started with CV generation
|
| 181 |
+
</p>
|
| 182 |
+
<div className="flex gap-3">
|
| 183 |
+
<Button variant="secondary" onClick={onHomeClick} fullWidth>
|
| 184 |
+
Home
|
| 185 |
+
</Button>
|
| 186 |
+
<Button onClick={onBack} fullWidth>
|
| 187 |
+
Back to Search
|
| 188 |
+
</Button>
|
| 189 |
+
</div>
|
| 190 |
+
</Card>
|
| 191 |
+
</div>
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// TODO: Add website link that opens up the company's own website to apply to instead of applying on linkedIn
|
| 196 |
+
// BUG: The Selenium Scrapper data does not show up correctly sometims on the frontend
|
| 197 |
+
|
| 198 |
+
return (
|
| 199 |
+
<div className="min-h-screen bg-gradient-saas px-4 sm:px-6 lg:px-8 py-8 max-w-7xl mx-auto">
|
| 200 |
+
{/* Header */}
|
| 201 |
+
<motion.div
|
| 202 |
+
initial={{ opacity: 0, y: -20 }}
|
| 203 |
+
animate={{ opacity: 1, y: 0 }}
|
| 204 |
+
className="mb-8"
|
| 205 |
+
>
|
| 206 |
+
<div className="flex items-center justify-between mb-4">
|
| 207 |
+
<Button variant="ghost" size="sm" onClick={onHomeClick}>
|
| 208 |
+
Home
|
| 209 |
+
</Button>
|
| 210 |
+
<div className="flex items-center gap-3">
|
| 211 |
+
<div className="text-xs uppercase tracking-[0.3em] text-emerald-200/60">
|
| 212 |
+
Tailored CVs
|
| 213 |
+
</div>
|
| 214 |
+
{tailoringMode === "api" && (
|
| 215 |
+
<div className="flex items-center gap-1 px-2 py-1 bg-emerald-500/15 rounded-full border border-emerald-500/30">
|
| 216 |
+
<Cpu className="w-3 h-3 text-emerald-400" />
|
| 217 |
+
<span className="text-xs text-emerald-300 font-medium">
|
| 218 |
+
{getLLMProviderLabel(llmProvider || "groq")}
|
| 219 |
+
</span>
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
<div className="px-2 py-1 rounded-full border text-xs font-medium border-emerald-500/20 text-emerald-200/75">
|
| 223 |
+
Summary: {enableProfessionalSummary ? "On" : "Off"}
|
| 224 |
+
</div>
|
| 225 |
+
<div className="px-2 py-1 rounded-full border text-xs font-medium border-emerald-500/20 text-emerald-200/75">
|
| 226 |
+
Cover Letters: {includeCoverLetters ? "On" : "Off"}
|
| 227 |
+
</div>
|
| 228 |
+
{onSettingsClick && (
|
| 229 |
+
<Button variant="ghost" size="sm" onClick={onSettingsClick}>
|
| 230 |
+
Settings
|
| 231 |
+
</Button>
|
| 232 |
+
)}
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
<div className="flex items-center justify-between mb-6">
|
| 236 |
+
<div>
|
| 237 |
+
<h1 className="text-3xl font-bold text-white mb-2">
|
| 238 |
+
Found {jobs.length} Jobs
|
| 239 |
+
</h1>
|
| 240 |
+
<p className="text-slate-400">
|
| 241 |
+
Select jobs to generate tailored CVs
|
| 242 |
+
</p>
|
| 243 |
+
</div>
|
| 244 |
+
<Button variant="secondary" onClick={onBack}>
|
| 245 |
+
Back to Search
|
| 246 |
+
</Button>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
{/* Selection Controls */}
|
| 250 |
+
<Card className="flex items-center justify-between border-emerald-500/10 shadow-[0_0_40px_rgba(34,197,94,0.08)]">
|
| 251 |
+
<div className="flex items-center gap-4">
|
| 252 |
+
<input
|
| 253 |
+
type="checkbox"
|
| 254 |
+
checked={allSelected}
|
| 255 |
+
onChange={() => {
|
| 256 |
+
if (allSelected) {
|
| 257 |
+
deselectAllJobs();
|
| 258 |
+
} else {
|
| 259 |
+
selectAllJobs();
|
| 260 |
+
}
|
| 261 |
+
}}
|
| 262 |
+
className="w-5 h-5 rounded border-slate-600 cursor-pointer"
|
| 263 |
+
/>
|
| 264 |
+
<span className="text-slate-300">
|
| 265 |
+
{selectedCount > 0 ? (
|
| 266 |
+
<>
|
| 267 |
+
<strong>{selectedCount}</strong> job
|
| 268 |
+
{selectedCount !== 1 ? "s" : ""} selected
|
| 269 |
+
</>
|
| 270 |
+
) : (
|
| 271 |
+
"Select jobs to generate CVs"
|
| 272 |
+
)}
|
| 273 |
+
</span>
|
| 274 |
+
</div>
|
| 275 |
+
{selectedCount > 0 && (
|
| 276 |
+
<Button
|
| 277 |
+
size="sm"
|
| 278 |
+
loading={isGenerating}
|
| 279 |
+
onClick={handleGenerateAllCVs}
|
| 280 |
+
icon={<Zap className="w-4 h-4" />}
|
| 281 |
+
>
|
| 282 |
+
Generate All ({selectedCount})
|
| 283 |
+
</Button>
|
| 284 |
+
)}
|
| 285 |
+
</Card>
|
| 286 |
+
</motion.div>
|
| 287 |
+
|
| 288 |
+
{/* Jobs List */}
|
| 289 |
+
<motion.div
|
| 290 |
+
initial={{ opacity: 0 }}
|
| 291 |
+
animate={{ opacity: 1 }}
|
| 292 |
+
transition={{ staggerChildren: 0.1 }}
|
| 293 |
+
className="space-y-4"
|
| 294 |
+
>
|
| 295 |
+
{jobs.map((job, index) => {
|
| 296 |
+
// --- SAFETIES ADDED HERE ---
|
| 297 |
+
const matchedSkills = job.matched_skills || [];
|
| 298 |
+
const matchedCategories = job.matched_categories || {};
|
| 299 |
+
const isRawData = matchedSkills.length === 0;
|
| 300 |
+
|
| 301 |
+
console.log(job);
|
| 302 |
+
|
| 303 |
+
return (
|
| 304 |
+
<motion.div
|
| 305 |
+
key={job.id || index} // Fallback to index if id is missing in raw data
|
| 306 |
+
initial={{ opacity: 0, y: 20 }}
|
| 307 |
+
animate={{ opacity: 1, y: 0 }}
|
| 308 |
+
transition={{ delay: index * 0.05 }}
|
| 309 |
+
>
|
| 310 |
+
<Card hover noPadding className="overflow-hidden">
|
| 311 |
+
<div className="p-6">
|
| 312 |
+
{/* ========================================== */}
|
| 313 |
+
{/* TOP SECTION: Header, Meta, and Action Footer */}
|
| 314 |
+
{/* ========================================== */}
|
| 315 |
+
<div className="flex items-start gap-4">
|
| 316 |
+
<input
|
| 317 |
+
type="checkbox"
|
| 318 |
+
checked={selectedJobIds.has(job.id)}
|
| 319 |
+
onChange={() => toggleJobSelection(job.id)}
|
| 320 |
+
className="w-5 h-5 rounded border-slate-600 cursor-pointer mt-1 shrink-0"
|
| 321 |
+
/>
|
| 322 |
+
|
| 323 |
+
<div className="flex-1 min-w-0 w-full">
|
| 324 |
+
{/* Top Row: Title, Company & Expand Chevron */}
|
| 325 |
+
<div className="flex items-start justify-between mb-2">
|
| 326 |
+
<div className="pr-4">
|
| 327 |
+
<h3 className="text-lg font-semibold text-white truncate">
|
| 328 |
+
{job.title}
|
| 329 |
+
</h3>
|
| 330 |
+
<p className="text-emerald-400 font-medium">
|
| 331 |
+
{job.company}
|
| 332 |
+
</p>
|
| 333 |
+
</div>
|
| 334 |
+
<motion.button
|
| 335 |
+
onClick={() =>
|
| 336 |
+
setExpandedJobId(
|
| 337 |
+
expandedJobId === job.id ? null : job.id
|
| 338 |
+
)
|
| 339 |
+
}
|
| 340 |
+
animate={{
|
| 341 |
+
rotate: expandedJobId === job.id ? 180 : 0,
|
| 342 |
+
}}
|
| 343 |
+
className="text-slate-400 hover:text-white transition-colors shrink-0 p-1"
|
| 344 |
+
>
|
| 345 |
+
<ChevronDown className="w-5 h-5" />
|
| 346 |
+
</motion.button>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
{/* Job Meta with Safeties for missing data */}
|
| 350 |
+
<div className="flex flex-wrap gap-4 text-sm text-slate-400 mb-3">
|
| 351 |
+
{job.location && (
|
| 352 |
+
<div className="flex items-center gap-1">
|
| 353 |
+
<MapPin className="w-4 h-4" />
|
| 354 |
+
{job.location}
|
| 355 |
+
</div>
|
| 356 |
+
)}
|
| 357 |
+
<div className="flex items-center gap-1">
|
| 358 |
+
<Clock className="w-4 h-4" />
|
| 359 |
+
{job.posted_days_ago} days ago
|
| 360 |
+
</div>
|
| 361 |
+
<div className="flex items-center gap-1">
|
| 362 |
+
<Building2 className="w-4 h-4" />
|
| 363 |
+
{job.source}
|
| 364 |
+
</div>
|
| 365 |
+
{job.link && (
|
| 366 |
+
<motion.a
|
| 367 |
+
href={job.link}
|
| 368 |
+
target="_blank"
|
| 369 |
+
rel="noopener noreferrer"
|
| 370 |
+
whileHover={{ x: 2 }}
|
| 371 |
+
className="flex items-center gap-1 text-emerald-400 hover:text-emerald-300 transition-colors font-medium"
|
| 372 |
+
>
|
| 373 |
+
View on LinkedIn
|
| 374 |
+
<ExternalLink className="w-4 h-4" />
|
| 375 |
+
</motion.a>
|
| 376 |
+
)}
|
| 377 |
+
</div>
|
| 378 |
+
|
| 379 |
+
{/* Skills Preview with Safeties */}
|
| 380 |
+
<div className="flex flex-wrap gap-2 mb-4">
|
| 381 |
+
{matchedSkills.slice(0, 3).map((skill) => (
|
| 382 |
+
<Badge key={skill} variant="primary" size="sm">
|
| 383 |
+
{skill}
|
| 384 |
+
</Badge>
|
| 385 |
+
))}
|
| 386 |
+
{matchedSkills.length > 3 && (
|
| 387 |
+
<Badge variant="secondary" size="sm">
|
| 388 |
+
+{matchedSkills.length - 3} more
|
| 389 |
+
</Badge>
|
| 390 |
+
)}
|
| 391 |
+
{/* Show a badge indicating this is fast/raw data */}
|
| 392 |
+
{isRawData && (
|
| 393 |
+
<Badge
|
| 394 |
+
variant="secondary"
|
| 395 |
+
size="sm"
|
| 396 |
+
className="opacity-70 border-slate-600"
|
| 397 |
+
>
|
| 398 |
+
Ready for AI CV Tailoring
|
| 399 |
+
</Badge>
|
| 400 |
+
)}
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
{/* --- FULL-WIDTH ACTION FOOTER --- */}
|
| 404 |
+
<div className="w-full pt-4 mt-2 border-t border-slate-700/50">
|
| 405 |
+
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 w-full">
|
| 406 |
+
{/* Left Side: Analyzer Pill & Skills Drawer */}
|
| 407 |
+
<div className="flex-1 w-full min-w-0">
|
| 408 |
+
<JobFitAnalyzer jobId={job.id} />
|
| 409 |
+
</div>
|
| 410 |
+
|
| 411 |
+
{/* Right Side: Generate CV Button */}
|
| 412 |
+
<div className="shrink-0 mt-1">
|
| 413 |
+
<Button
|
| 414 |
+
size="sm"
|
| 415 |
+
loading={generatingJobId === job.id}
|
| 416 |
+
onClick={() => handleGenerateSingleCV(job)}
|
| 417 |
+
disabled={generatingJobId !== null}
|
| 418 |
+
className="bg-emerald-500 hover:bg-emerald-600 text-white shadow-lg shadow-emerald-500/20"
|
| 419 |
+
>
|
| 420 |
+
Generate CV
|
| 421 |
+
</Button>
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
|
| 428 |
+
{/* ========================================== */}
|
| 429 |
+
{/* BOTTOM SECTION: Collapsible Job Description */}
|
| 430 |
+
{/* ========================================== */}
|
| 431 |
+
<AnimatePresence>
|
| 432 |
+
{expandedJobId === job.id && (
|
| 433 |
+
<motion.div
|
| 434 |
+
initial={{ opacity: 0, height: 0 }}
|
| 435 |
+
animate={{ opacity: 1, height: "auto" }}
|
| 436 |
+
exit={{ opacity: 0, height: 0 }}
|
| 437 |
+
className="border-t border-slate-700 pt-4 mt-6"
|
| 438 |
+
>
|
| 439 |
+
{/* LinkedIn Link Button */}
|
| 440 |
+
{(job.apply_url || job.link) && (
|
| 441 |
+
<motion.a
|
| 442 |
+
href={job.apply_url}
|
| 443 |
+
target="_blank"
|
| 444 |
+
rel="noopener noreferrer"
|
| 445 |
+
whileHover={{ scale: 1.02 }}
|
| 446 |
+
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 hover:border-emerald-500/50 text-emerald-300 hover:text-emerald-200 rounded-lg transition-all duration-200 font-medium text-sm mb-4"
|
| 447 |
+
>
|
| 448 |
+
<ExternalLink className="w-4 h-4" />
|
| 449 |
+
Visit Job Post Page
|
| 450 |
+
</motion.a>
|
| 451 |
+
)}
|
| 452 |
+
|
| 453 |
+
{/* Skill Categories with Safeties */}
|
| 454 |
+
{Object.keys(matchedCategories).length > 0 && (
|
| 455 |
+
<div className="mb-4">
|
| 456 |
+
<h4 className="font-semibold text-white mb-3">
|
| 457 |
+
Matched Skills
|
| 458 |
+
</h4>
|
| 459 |
+
<div className="space-y-3">
|
| 460 |
+
{Object.entries(matchedCategories).map(
|
| 461 |
+
([category, skills]) => (
|
| 462 |
+
<div key={category}>
|
| 463 |
+
<p className="text-sm font-medium text-slate-300 mb-2">
|
| 464 |
+
{category}
|
| 465 |
+
</p>
|
| 466 |
+
<div className="flex flex-wrap gap-2">
|
| 467 |
+
{(skills as string[]).map((skill) => (
|
| 468 |
+
<Badge
|
| 469 |
+
key={skill}
|
| 470 |
+
variant="success"
|
| 471 |
+
size="sm"
|
| 472 |
+
>
|
| 473 |
+
{skill}
|
| 474 |
+
</Badge>
|
| 475 |
+
))}
|
| 476 |
+
</div>
|
| 477 |
+
</div>
|
| 478 |
+
)
|
| 479 |
+
)}
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
)}
|
| 483 |
+
|
| 484 |
+
{/* Job Description Preview */}
|
| 485 |
+
<div>
|
| 486 |
+
<h4 className="font-semibold text-white mb-2">
|
| 487 |
+
Job Description
|
| 488 |
+
</h4>
|
| 489 |
+
<p className="text-sm text-slate-300 whitespace-pre-wrap">
|
| 490 |
+
{job.description || "No description provided."}
|
| 491 |
+
</p>
|
| 492 |
+
</div>
|
| 493 |
+
</motion.div>
|
| 494 |
+
)}
|
| 495 |
+
</AnimatePresence>
|
| 496 |
+
</div>
|
| 497 |
+
</Card>
|
| 498 |
+
</motion.div>
|
| 499 |
+
);
|
| 500 |
+
})}
|
| 501 |
+
</motion.div>
|
| 502 |
+
|
| 503 |
+
{/* Batch Progress */}
|
| 504 |
+
{batchProgress.isProcessing && (
|
| 505 |
+
<div className="fixed bottom-4 right-4">
|
| 506 |
+
<Card glow>
|
| 507 |
+
<div className="flex items-center gap-3">
|
| 508 |
+
<Spinner size="sm" />
|
| 509 |
+
<div>
|
| 510 |
+
<p className="text-sm font-medium text-white">
|
| 511 |
+
{batchProgress.completed} / {batchProgress.total} CVs
|
| 512 |
+
Generated
|
| 513 |
+
</p>
|
| 514 |
+
{batchProgress.current && (
|
| 515 |
+
<p className="text-xs text-slate-400">
|
| 516 |
+
{batchProgress.current}
|
| 517 |
+
</p>
|
| 518 |
+
)}
|
| 519 |
+
</div>
|
| 520 |
+
</div>
|
| 521 |
+
</Card>
|
| 522 |
+
</div>
|
| 523 |
+
)}
|
| 524 |
+
</div>
|
| 525 |
+
);
|
| 526 |
+
};
|
| 527 |
+
|
| 528 |
+
export default JobListPage;
|
frontend/src/components/pages/SettingsModal.tsx
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { ToggleLeft, ToggleRight, Zap, Check } from 'lucide-react';
|
| 4 |
+
import { Card, Button, Select } from '@/components/common';
|
| 5 |
+
import Modal from '@/components/common/Modal';
|
| 6 |
+
import { useJobStore } from '@/store/appStore';
|
| 7 |
+
import { jobsAPI } from '@/utils/api';
|
| 8 |
+
import type { TailoringMode, LLMProvider } from '@/types';
|
| 9 |
+
|
| 10 |
+
interface SettingsModalProps {
|
| 11 |
+
isOpen: boolean;
|
| 12 |
+
onClose: () => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => {
|
| 16 |
+
const {
|
| 17 |
+
tailoringMode,
|
| 18 |
+
llmProvider,
|
| 19 |
+
setEnableProfessionalSummary: setStoreProfessionalSummary,
|
| 20 |
+
setIncludeCoverLetters: setStoreCoverLetters,
|
| 21 |
+
setTailoringMode,
|
| 22 |
+
setLLMProvider,
|
| 23 |
+
setNotification,
|
| 24 |
+
} = useJobStore();
|
| 25 |
+
|
| 26 |
+
const [draftMode, setDraftMode] = React.useState<TailoringMode>(tailoringMode);
|
| 27 |
+
const [draftProvider, setDraftProvider] = React.useState<LLMProvider>(llmProvider || 'groq');
|
| 28 |
+
const [enableProfessionalSummary, setEnableProfessionalSummary] = React.useState(false);
|
| 29 |
+
const [includeCoverLetters, setIncludeCoverLetters] = React.useState(false);
|
| 30 |
+
const [isSaving, setIsSaving] = React.useState(false);
|
| 31 |
+
const [isLoading, setIsLoading] = React.useState(false);
|
| 32 |
+
|
| 33 |
+
const llmProviders: Array<{ value: LLMProvider; label: string; description: string }> = [
|
| 34 |
+
{ value: 'ollama', label: 'Ollama (Local)', description: 'Runs locally, slowest' },
|
| 35 |
+
{ value: 'groq', label: 'Groq API', description: 'Fast and cost-effective' },
|
| 36 |
+
{ value: 'grok', label: 'Grok API', description: 'Fast and affordable' },
|
| 37 |
+
{ value: 'openai', label: 'OpenAI', description: 'Premium quality' },
|
| 38 |
+
];
|
| 39 |
+
|
| 40 |
+
React.useEffect(() => {
|
| 41 |
+
if (!isOpen) {
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
setDraftMode(tailoringMode);
|
| 46 |
+
setDraftProvider(llmProvider || 'groq');
|
| 47 |
+
|
| 48 |
+
const loadConfig = async () => {
|
| 49 |
+
setIsLoading(true);
|
| 50 |
+
try {
|
| 51 |
+
const config = await jobsAPI.getConfig();
|
| 52 |
+
setDraftMode(config.tailoring_mode);
|
| 53 |
+
setDraftProvider(config.llm_provider);
|
| 54 |
+
setEnableProfessionalSummary(!!config.enable_professional_summary);
|
| 55 |
+
setIncludeCoverLetters(!!config.include_cover_letters);
|
| 56 |
+
setTailoringMode(config.tailoring_mode);
|
| 57 |
+
setLLMProvider(config.llm_provider);
|
| 58 |
+
setStoreProfessionalSummary(!!config.enable_professional_summary);
|
| 59 |
+
setStoreCoverLetters(!!config.include_cover_letters);
|
| 60 |
+
} catch (error) {
|
| 61 |
+
setNotification({
|
| 62 |
+
type: 'error',
|
| 63 |
+
message: 'Could not load settings. Using current values.',
|
| 64 |
+
});
|
| 65 |
+
} finally {
|
| 66 |
+
setIsLoading(false);
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
void loadConfig();
|
| 71 |
+
}, [
|
| 72 |
+
isOpen,
|
| 73 |
+
llmProvider,
|
| 74 |
+
setLLMProvider,
|
| 75 |
+
setNotification,
|
| 76 |
+
setStoreCoverLetters,
|
| 77 |
+
setStoreProfessionalSummary,
|
| 78 |
+
setTailoringMode,
|
| 79 |
+
tailoringMode,
|
| 80 |
+
]);
|
| 81 |
+
|
| 82 |
+
const handleSave = async () => {
|
| 83 |
+
setIsSaving(true);
|
| 84 |
+
try {
|
| 85 |
+
await jobsAPI.saveSettings({
|
| 86 |
+
tailoring_mode: draftMode,
|
| 87 |
+
llm_provider: draftProvider,
|
| 88 |
+
enable_professional_summary: enableProfessionalSummary,
|
| 89 |
+
include_cover_letters: includeCoverLetters,
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
setTailoringMode(draftMode);
|
| 93 |
+
setLLMProvider(draftProvider);
|
| 94 |
+
setStoreProfessionalSummary(enableProfessionalSummary);
|
| 95 |
+
setStoreCoverLetters(includeCoverLetters);
|
| 96 |
+
|
| 97 |
+
setNotification({
|
| 98 |
+
type: 'success',
|
| 99 |
+
message: 'Settings saved successfully',
|
| 100 |
+
});
|
| 101 |
+
onClose();
|
| 102 |
+
} catch (error) {
|
| 103 |
+
setNotification({
|
| 104 |
+
type: 'error',
|
| 105 |
+
message: 'Failed to save settings',
|
| 106 |
+
});
|
| 107 |
+
} finally {
|
| 108 |
+
setIsSaving(false);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<Modal isOpen={isOpen} onClose={onClose} title="Settings" size="md">
|
| 114 |
+
<motion.div className="space-y-6">
|
| 115 |
+
{/* Tailoring Mode Section */}
|
| 116 |
+
<Card>
|
| 117 |
+
<div className="flex items-center justify-between mb-4">
|
| 118 |
+
<div>
|
| 119 |
+
<h3 className="font-semibold text-white flex items-center gap-2">
|
| 120 |
+
<Zap className="w-5 h-5 text-emerald-400" />
|
| 121 |
+
CV Tailoring Mode
|
| 122 |
+
</h3>
|
| 123 |
+
<p className="text-xs text-slate-400 mt-1">
|
| 124 |
+
Choose how your CVs are tailored to job descriptions
|
| 125 |
+
</p>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div className="space-y-3">
|
| 130 |
+
{[
|
| 131 |
+
{
|
| 132 |
+
mode: 'local' as TailoringMode,
|
| 133 |
+
label: 'Smart Mode (Local)',
|
| 134 |
+
description: 'Uses NLP-based skill matching',
|
| 135 |
+
icon: ToggleLeft,
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
mode: 'api' as TailoringMode,
|
| 139 |
+
label: 'AI Mode (API)',
|
| 140 |
+
description: 'Uses advanced AI models for better tailoring',
|
| 141 |
+
icon: ToggleRight,
|
| 142 |
+
},
|
| 143 |
+
].map((option) => (
|
| 144 |
+
<motion.button
|
| 145 |
+
key={option.mode}
|
| 146 |
+
whileHover={{ x: 4 }}
|
| 147 |
+
onClick={() => setDraftMode(option.mode)}
|
| 148 |
+
className={`w-full p-3 rounded-lg border transition-all ${
|
| 149 |
+
draftMode === option.mode
|
| 150 |
+
? 'border-emerald-500 bg-emerald-500/10'
|
| 151 |
+
: 'border-slate-700 bg-slate-800/30 hover:border-emerald-400/30'
|
| 152 |
+
}`}
|
| 153 |
+
disabled={isLoading || isSaving}
|
| 154 |
+
>
|
| 155 |
+
<div className="flex items-center gap-3">
|
| 156 |
+
<option.icon
|
| 157 |
+
className={`w-5 h-5 ${
|
| 158 |
+
draftMode === option.mode
|
| 159 |
+
? 'text-emerald-400'
|
| 160 |
+
: 'text-slate-400'
|
| 161 |
+
}`}
|
| 162 |
+
/>
|
| 163 |
+
<div className="text-left">
|
| 164 |
+
<p className="font-medium text-white text-sm">
|
| 165 |
+
{option.label}
|
| 166 |
+
</p>
|
| 167 |
+
<p className="text-xs text-slate-400">
|
| 168 |
+
{option.description}
|
| 169 |
+
</p>
|
| 170 |
+
</div>
|
| 171 |
+
{draftMode === option.mode && (
|
| 172 |
+
<div className="ml-auto w-2 h-2 bg-emerald-400 rounded-full" />
|
| 173 |
+
)}
|
| 174 |
+
</div>
|
| 175 |
+
</motion.button>
|
| 176 |
+
))}
|
| 177 |
+
</div>
|
| 178 |
+
</Card>
|
| 179 |
+
|
| 180 |
+
{/* LLM Provider Section (if API mode) */}
|
| 181 |
+
{draftMode === 'api' && (
|
| 182 |
+
<Card>
|
| 183 |
+
<h3 className="font-semibold text-white mb-4">
|
| 184 |
+
AI Model Provider
|
| 185 |
+
</h3>
|
| 186 |
+
<Select
|
| 187 |
+
label="Select Provider"
|
| 188 |
+
value={draftProvider}
|
| 189 |
+
onChange={(e) => setDraftProvider(e.target.value as LLMProvider)}
|
| 190 |
+
options={llmProviders.map((p) => ({
|
| 191 |
+
value: p.value,
|
| 192 |
+
label: `${p.label} - ${p.description}`,
|
| 193 |
+
}))}
|
| 194 |
+
/>
|
| 195 |
+
<p className="text-xs text-slate-400 mt-3">
|
| 196 |
+
Each provider has different speeds and quality levels. Get API keys
|
| 197 |
+
from the provider's website.
|
| 198 |
+
</p>
|
| 199 |
+
</Card>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{/* Advanced Settings */}
|
| 203 |
+
<Card>
|
| 204 |
+
<h3 className="font-semibold text-white mb-4">Advanced Options</h3>
|
| 205 |
+
<div className="space-y-3">
|
| 206 |
+
{[
|
| 207 |
+
{
|
| 208 |
+
key: 'summary',
|
| 209 |
+
label: 'Enable Professional Summary',
|
| 210 |
+
description: 'Automatically generate personalized summaries',
|
| 211 |
+
enabled: enableProfessionalSummary,
|
| 212 |
+
onToggle: () => setEnableProfessionalSummary((prev) => !prev),
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
key: 'cover',
|
| 216 |
+
label: 'Include Cover Letters',
|
| 217 |
+
description: 'Generate cover letters along with CVs',
|
| 218 |
+
enabled: includeCoverLetters,
|
| 219 |
+
onToggle: () => setIncludeCoverLetters((prev) => !prev),
|
| 220 |
+
},
|
| 221 |
+
].map((option) => (
|
| 222 |
+
<motion.div
|
| 223 |
+
key={option.key}
|
| 224 |
+
whileHover={{ x: 2 }}
|
| 225 |
+
className={`flex items-center justify-between p-3 bg-slate-800/30 border rounded-lg transition-colors ${
|
| 226 |
+
option.enabled
|
| 227 |
+
? 'border-emerald-500/40'
|
| 228 |
+
: 'border-slate-700 hover:border-slate-600'
|
| 229 |
+
}`}
|
| 230 |
+
>
|
| 231 |
+
<div>
|
| 232 |
+
<p className="font-medium text-white text-sm">
|
| 233 |
+
{option.label}
|
| 234 |
+
</p>
|
| 235 |
+
<p className="text-xs text-slate-400">
|
| 236 |
+
{option.description}
|
| 237 |
+
</p>
|
| 238 |
+
</div>
|
| 239 |
+
<motion.button
|
| 240 |
+
whileTap={{ scale: 0.95 }}
|
| 241 |
+
onClick={option.onToggle}
|
| 242 |
+
disabled={isLoading || isSaving}
|
| 243 |
+
className={`w-7 h-7 rounded-md border-2 flex items-center justify-center transition-colors ${
|
| 244 |
+
option.enabled
|
| 245 |
+
? 'bg-emerald-500/20 border-emerald-400 text-emerald-300'
|
| 246 |
+
: 'border-slate-600 text-transparent hover:border-slate-500'
|
| 247 |
+
}`}
|
| 248 |
+
aria-label={`Toggle ${option.label}`}
|
| 249 |
+
>
|
| 250 |
+
<Check className="w-4 h-4" />
|
| 251 |
+
</motion.button>
|
| 252 |
+
</motion.div>
|
| 253 |
+
))}
|
| 254 |
+
</div>
|
| 255 |
+
</Card>
|
| 256 |
+
|
| 257 |
+
{/* Action Buttons */}
|
| 258 |
+
<div className="flex gap-3 pt-4 border-t border-slate-700">
|
| 259 |
+
<Button variant="secondary" onClick={onClose} fullWidth>
|
| 260 |
+
Close
|
| 261 |
+
</Button>
|
| 262 |
+
<Button onClick={handleSave} fullWidth loading={isSaving}>
|
| 263 |
+
Save Changes
|
| 264 |
+
</Button>
|
| 265 |
+
</div>
|
| 266 |
+
</motion.div>
|
| 267 |
+
</Modal>
|
| 268 |
+
);
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
export default SettingsModal;
|
frontend/src/components/pages/WorkflowPage.tsx
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { motion } from "framer-motion";
|
| 3 |
+
import { Upload, Search, Check, ArrowRight } from "lucide-react";
|
| 4 |
+
import { Card, Button } from "@/components/common";
|
| 5 |
+
import Header from "@/components/sections/Header";
|
| 6 |
+
import CVUpload from "@/components/sections/CVUpload";
|
| 7 |
+
import { AutoApplyProfile } from "@/components/sections/AutoApplyProfile";
|
| 8 |
+
import JobSearch from "@/components/sections/JobSearch";
|
| 9 |
+
import { useJobStore } from "@/store/appStore";
|
| 10 |
+
|
| 11 |
+
type StepValue = "upload" | "search" | "review";
|
| 12 |
+
|
| 13 |
+
interface WorkflowPageProps {
|
| 14 |
+
onJobsFound?: () => void;
|
| 15 |
+
onSettingsClick?: () => void;
|
| 16 |
+
onHomeClick?: () => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const WorkflowPage: React.FC<WorkflowPageProps> = ({
|
| 20 |
+
onJobsFound,
|
| 21 |
+
onSettingsClick,
|
| 22 |
+
onHomeClick,
|
| 23 |
+
}) => {
|
| 24 |
+
const [currentStep, setCurrentStep] = React.useState<StepValue>("upload");
|
| 25 |
+
const { cvTemplate, jobs } = useJobStore();
|
| 26 |
+
|
| 27 |
+
const steps = [
|
| 28 |
+
{
|
| 29 |
+
value: "upload" as StepValue,
|
| 30 |
+
label: "Upload CV",
|
| 31 |
+
icon: Upload,
|
| 32 |
+
completed: !!cvTemplate,
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
value: "search" as StepValue,
|
| 36 |
+
label: "Search Jobs",
|
| 37 |
+
icon: Search,
|
| 38 |
+
completed: jobs.length > 0,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
value: "review" as StepValue,
|
| 42 |
+
label: "Review & Generate",
|
| 43 |
+
icon: Check,
|
| 44 |
+
completed: false,
|
| 45 |
+
},
|
| 46 |
+
];
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="min-h-screen bg-gradient-saas">
|
| 50 |
+
<Header onSettingsClick={onSettingsClick} onHomeClick={onHomeClick} />
|
| 51 |
+
|
| 52 |
+
<div className="px-4 sm:px-6 lg:px-8 py-12 max-w-6xl mx-auto">
|
| 53 |
+
{/* Progress Steps */}
|
| 54 |
+
<motion.div
|
| 55 |
+
initial={{ opacity: 0 }}
|
| 56 |
+
animate={{ opacity: 1 }}
|
| 57 |
+
className="mb-12"
|
| 58 |
+
>
|
| 59 |
+
<div className="flex items-center justify-between mb-8">
|
| 60 |
+
{steps.map((step, index) => (
|
| 61 |
+
<React.Fragment key={step.value}>
|
| 62 |
+
<motion.button
|
| 63 |
+
whileHover={{ scale: 1.05 }}
|
| 64 |
+
onClick={() => setCurrentStep(step.value)}
|
| 65 |
+
className={`flex flex-col items-center gap-2 cursor-pointer transition-all ${
|
| 66 |
+
currentStep === step.value
|
| 67 |
+
? "opacity-100"
|
| 68 |
+
: "opacity-60 hover:opacity-80"
|
| 69 |
+
}`}
|
| 70 |
+
>
|
| 71 |
+
<div
|
| 72 |
+
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${
|
| 73 |
+
step.completed || currentStep === step.value
|
| 74 |
+
? "bg-gradient-emerald text-white"
|
| 75 |
+
: "bg-slate-700 text-slate-300"
|
| 76 |
+
}`}
|
| 77 |
+
>
|
| 78 |
+
{step.completed && currentStep !== step.value ? (
|
| 79 |
+
<Check className="w-6 h-6" />
|
| 80 |
+
) : (
|
| 81 |
+
<step.icon className="w-6 h-6" />
|
| 82 |
+
)}
|
| 83 |
+
</div>
|
| 84 |
+
<span className="text-sm font-medium text-slate-300">
|
| 85 |
+
{step.label}
|
| 86 |
+
</span>
|
| 87 |
+
</motion.button>
|
| 88 |
+
|
| 89 |
+
{index < steps.length - 1 && (
|
| 90 |
+
<div className="flex-1 h-1 mx-4 bg-slate-700 rounded-full overflow-hidden">
|
| 91 |
+
<motion.div
|
| 92 |
+
layoutId="progress"
|
| 93 |
+
className="h-full bg-gradient-emerald"
|
| 94 |
+
initial={{ width: 0 }}
|
| 95 |
+
animate={{
|
| 96 |
+
width:
|
| 97 |
+
currentStep === steps[index + 1].value
|
| 98 |
+
? "100%"
|
| 99 |
+
: "0%",
|
| 100 |
+
}}
|
| 101 |
+
transition={{ duration: 0.5 }}
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
+
</React.Fragment>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
</motion.div>
|
| 109 |
+
|
| 110 |
+
{/* Content */}
|
| 111 |
+
<motion.div
|
| 112 |
+
key={currentStep}
|
| 113 |
+
initial={{ opacity: 0, y: 20 }}
|
| 114 |
+
animate={{ opacity: 1, y: 0 }}
|
| 115 |
+
exit={{ opacity: 0, y: -20 }}
|
| 116 |
+
transition={{ duration: 0.3 }}
|
| 117 |
+
>
|
| 118 |
+
{currentStep === "upload" && (
|
| 119 |
+
// <div>
|
| 120 |
+
// <Card className="mb-8">
|
| 121 |
+
// <h2 className="text-2xl font-bold text-white mb-2">
|
| 122 |
+
// Upload Your CV
|
| 123 |
+
// </h2>
|
| 124 |
+
// <p className="text-slate-400 mb-6">
|
| 125 |
+
// Start by uploading your base CV template. We'll use this to
|
| 126 |
+
// create tailored versions for each job.
|
| 127 |
+
// </p>
|
| 128 |
+
// <CVUpload
|
| 129 |
+
// onSuccess={() => {
|
| 130 |
+
// // Auto-proceed to next step
|
| 131 |
+
// setTimeout(() => {
|
| 132 |
+
// setCurrentStep('search');
|
| 133 |
+
// }, 500);
|
| 134 |
+
// }}
|
| 135 |
+
// onError={() => {
|
| 136 |
+
// // Stay on current step on error
|
| 137 |
+
// }}
|
| 138 |
+
// />
|
| 139 |
+
// </Card>
|
| 140 |
+
|
| 141 |
+
// {cvTemplate && (
|
| 142 |
+
// <motion.div
|
| 143 |
+
// initial={{ opacity: 0, y: 10 }}
|
| 144 |
+
// animate={{ opacity: 1, y: 0 }}
|
| 145 |
+
// className="flex justify-end"
|
| 146 |
+
// >
|
| 147 |
+
// <Button
|
| 148 |
+
// onClick={() => setCurrentStep('search')}
|
| 149 |
+
// icon={<ArrowRight className="w-4 h-4" />}
|
| 150 |
+
// iconPosition="right"
|
| 151 |
+
// >
|
| 152 |
+
// Proceed to Job Search
|
| 153 |
+
// </Button>
|
| 154 |
+
// </motion.div>
|
| 155 |
+
// )}
|
| 156 |
+
// </div>
|
| 157 |
+
|
| 158 |
+
<div className="space-y-6">
|
| 159 |
+
{" "}
|
| 160 |
+
{/* Added spacing container */}
|
| 161 |
+
<Card>
|
| 162 |
+
<h2 className="text-2xl font-bold text-white mb-2">
|
| 163 |
+
Upload Your CV
|
| 164 |
+
</h2>
|
| 165 |
+
<p className="text-slate-400 mb-6">
|
| 166 |
+
Start by uploading your base CV template. We'll use this to
|
| 167 |
+
create tailored versions for each job.
|
| 168 |
+
</p>
|
| 169 |
+
<CVUpload
|
| 170 |
+
onSuccess={() => {
|
| 171 |
+
// REMOVED the setTimeout auto-proceed!
|
| 172 |
+
// We want the user to stay here so they can optionally setup Auto-Apply.
|
| 173 |
+
console.log("CV Uploaded Successfully");
|
| 174 |
+
}}
|
| 175 |
+
onError={() => {
|
| 176 |
+
// Stay on current step on error
|
| 177 |
+
}}
|
| 178 |
+
/>
|
| 179 |
+
</Card>
|
| 180 |
+
{/* --- NEW OPTIONAL AUTO-APPLY SECTION --- */}
|
| 181 |
+
<AutoApplyProfile />
|
| 182 |
+
{/* PROCEED BUTTON */}
|
| 183 |
+
{cvTemplate && (
|
| 184 |
+
<motion.div
|
| 185 |
+
initial={{ opacity: 0, y: 10 }}
|
| 186 |
+
animate={{ opacity: 1, y: 0 }}
|
| 187 |
+
className="flex justify-end pt-4"
|
| 188 |
+
>
|
| 189 |
+
<Button
|
| 190 |
+
onClick={() => setCurrentStep("search")}
|
| 191 |
+
icon={<ArrowRight className="w-4 h-4" />}
|
| 192 |
+
iconPosition="right"
|
| 193 |
+
>
|
| 194 |
+
Proceed to Job Search
|
| 195 |
+
</Button>
|
| 196 |
+
</motion.div>
|
| 197 |
+
)}
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
|
| 201 |
+
{currentStep === "search" && (
|
| 202 |
+
<div>
|
| 203 |
+
<Card className="mb-8">
|
| 204 |
+
<h2 className="text-2xl font-bold text-white mb-2">
|
| 205 |
+
Search for Jobs
|
| 206 |
+
</h2>
|
| 207 |
+
<p className="text-slate-400 mb-6">
|
| 208 |
+
Find job opportunities that match your skills and interests.
|
| 209 |
+
</p>
|
| 210 |
+
<JobSearch
|
| 211 |
+
onJobsFound={() => {
|
| 212 |
+
onJobsFound?.();
|
| 213 |
+
// Auto-proceed to next step after small delay
|
| 214 |
+
setTimeout(() => {
|
| 215 |
+
setCurrentStep("review");
|
| 216 |
+
}, 800);
|
| 217 |
+
}}
|
| 218 |
+
/>
|
| 219 |
+
</Card>
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
|
| 223 |
+
{currentStep === "review" && (
|
| 224 |
+
<div>
|
| 225 |
+
<Card>
|
| 226 |
+
<h2 className="text-2xl font-bold text-white mb-2">
|
| 227 |
+
Ready to Generate CVs
|
| 228 |
+
</h2>
|
| 229 |
+
<p className="text-slate-400 mb-6">
|
| 230 |
+
You have {jobs.length} jobs ready. Select the ones you want to
|
| 231 |
+
generate tailored CVs for and download them all at once.
|
| 232 |
+
</p>
|
| 233 |
+
|
| 234 |
+
{jobs.length > 0 ? (
|
| 235 |
+
<motion.div
|
| 236 |
+
initial={{ opacity: 0, y: 10 }}
|
| 237 |
+
animate={{ opacity: 1, y: 0 }}
|
| 238 |
+
>
|
| 239 |
+
<Button
|
| 240 |
+
fullWidth
|
| 241 |
+
size="lg"
|
| 242 |
+
icon={<ArrowRight className="w-5 h-5" />}
|
| 243 |
+
iconPosition="right"
|
| 244 |
+
onClick={() => {
|
| 245 |
+
console.log(
|
| 246 |
+
"View Jobs button clicked! Jobs count:",
|
| 247 |
+
jobs.length
|
| 248 |
+
);
|
| 249 |
+
onJobsFound?.();
|
| 250 |
+
}}
|
| 251 |
+
>
|
| 252 |
+
View Jobs & Generate CVs
|
| 253 |
+
</Button>
|
| 254 |
+
</motion.div>
|
| 255 |
+
) : (
|
| 256 |
+
<div className="p-4 bg-slate-800/30 border border-slate-700 rounded-lg text-center">
|
| 257 |
+
<p className="text-slate-400 text-sm">
|
| 258 |
+
No jobs found. Please try searching again.
|
| 259 |
+
</p>
|
| 260 |
+
</div>
|
| 261 |
+
)}
|
| 262 |
+
</Card>
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
</motion.div>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
);
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
export default WorkflowPage;
|
frontend/src/components/pages/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Export all page components
|
| 2 |
+
export { default as HomePage } from './HomePage';
|
| 3 |
+
export { default as WorkflowPage } from './WorkflowPage';
|
| 4 |
+
export { default as JobListPage } from './JobListPage';
|
| 5 |
+
export { default as SettingsModal } from './SettingsModal';
|