Spaces:
Sleeping
Sleeping
Neha Singh commited on
Commit ·
263eb11
0
Parent(s):
resume-screening-system
Browse files- .gitattributes +35 -0
- .gitignore +29 -0
- Dockerfile +34 -0
- README.md +179 -0
- app.py +484 -0
- audio_transcriber.py +325 -0
- db.py +143 -0
- job_matcher.py +122 -0
- requirements.txt +15 -0
- resume_parser.py +192 -0
- skill_extractor.py +499 -0
- static/css/style.css +84 -0
- templates/base.html +276 -0
- templates/index.html +181 -0
- templates/login.html +48 -0
- templates/ranking.html +294 -0
- templates/register.html +55 -0
- templates/results.html +316 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.egg-info/
|
| 5 |
+
dist/
|
| 6 |
+
build/
|
| 7 |
+
*.egg
|
| 8 |
+
.pytest_cache/
|
| 9 |
+
|
| 10 |
+
# Virtual environment
|
| 11 |
+
venv/
|
| 12 |
+
env/
|
| 13 |
+
.venv/
|
| 14 |
+
|
| 15 |
+
# IDE
|
| 16 |
+
.vscode/
|
| 17 |
+
.idea/
|
| 18 |
+
*.swp
|
| 19 |
+
*.swo
|
| 20 |
+
|
| 21 |
+
# Uploads (runtime data)
|
| 22 |
+
uploads/
|
| 23 |
+
|
| 24 |
+
# OS files
|
| 25 |
+
.DS_Store
|
| 26 |
+
Thumbs.db
|
| 27 |
+
|
| 28 |
+
# Environment variables
|
| 29 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies — ffmpeg required for Whisper audio processing
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
ffmpeg \
|
| 8 |
+
build-essential \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Copy requirements first (for Docker layer caching)
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Download NLTK data (needed for text processing)
|
| 17 |
+
RUN python -c "import nltk; nltk.download('stopwords'); nltk.download('punkt'); nltk.download('punkt_tab')" 2>/dev/null || true
|
| 18 |
+
|
| 19 |
+
# Copy all project files
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Create upload directories
|
| 23 |
+
RUN mkdir -p uploads/resumes uploads/audio
|
| 24 |
+
|
| 25 |
+
# Expose port required by Hugging Face Spaces (Docker SDK)
|
| 26 |
+
EXPOSE 7860
|
| 27 |
+
|
| 28 |
+
# Environment variables for Flask
|
| 29 |
+
ENV FLASK_RUN_PORT=7860
|
| 30 |
+
ENV FLASK_RUN_HOST=0.0.0.0
|
| 31 |
+
ENV PYTHONUNBUFFERED=1
|
| 32 |
+
|
| 33 |
+
# Production server with gunicorn — 120s timeout for ML model loading + Whisper
|
| 34 |
+
CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app", "--timeout", "120", "--workers", "2", "--threads", "4"]
|
README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: HireScope AI
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: cyan
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
python_version: "3.9"
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# 🔍 HireScope AI — Intelligent Resume Screening
|
| 13 |
+
|
| 14 |
+
An AI-powered web application that parses resumes, extracts 250+ skills, matches candidates to job descriptions using **Sentence-Transformers** semantic embeddings, ranks them with hybrid scoring, and transcribes audio intros with **OpenAI Whisper** — built with Flask, MongoDB Atlas, Cloudinary, and Tailwind CSS.
|
| 15 |
+
|
| 16 |
+

|
| 17 |
+

|
| 18 |
+

|
| 19 |
+

|
| 20 |
+

|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## ✨ Features
|
| 25 |
+
|
| 26 |
+
| Feature | Description |
|
| 27 |
+
|---|---|
|
| 28 |
+
| **Resume Upload** | Upload PDF or DOCX files for automatic text extraction (including tables) |
|
| 29 |
+
| **250+ Skill Extraction** | Regex-based extraction across 24 BTech categories (Software, ML, DevOps, Embedded, etc.) |
|
| 30 |
+
| **Skill Normalization** | Infers higher-level skills (e.g., "TensorFlow" → "Deep Learning" added) |
|
| 31 |
+
| **Semantic Matching** | Sentence-Transformer (`all-MiniLM-L6-v2`) embeddings + Cosine Similarity |
|
| 32 |
+
| **Hybrid Scoring** | 50% Semantic Score + 50% Exact Skill Overlap → Score 0–100 |
|
| 33 |
+
| **Skill Gap Analysis** | Shows matched ✅ and missing ❌ skills for each candidate |
|
| 34 |
+
| **Audio Transcription** | Upload voice recordings → transcribed via OpenAI Whisper (local-file-first approach) |
|
| 35 |
+
| **Async Processing** | Audio transcription runs in background thread via ThreadPoolExecutor |
|
| 36 |
+
| **AJAX Polling** | Real-time transcription status updates without page reload |
|
| 37 |
+
| **Candidate Ranking** | Leaderboard with re-ranking via custom JD |
|
| 38 |
+
| **Candidate Profiles** | Click any candidate → full modal with skills, education, experience, audio |
|
| 39 |
+
| **Resume Download** | Cloudinary-hosted with `fl_attachment` for direct download |
|
| 40 |
+
| **Auth System** | Login/Register with Werkzeug password hashing |
|
| 41 |
+
| **Premium UI** | Tailwind CSS light theme + Inter font + glassmorphism + animations |
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## 🗂️ Project Structure
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
HireScope-AI/
|
| 49 |
+
├── app.py # Flask main app (routes, auth, APIs)
|
| 50 |
+
├── resume_parser.py # PDF/DOCX text extraction + name/email/phone extraction
|
| 51 |
+
├── skill_extractor.py # 250+ skills regex extraction + normalization
|
| 52 |
+
├── job_matcher.py # Sentence-Transformers matching + ranking engine
|
| 53 |
+
├── audio_transcriber.py # Whisper speech-to-text (local-file-first approach)
|
| 54 |
+
├── db.py # MongoDB CRUD operations
|
| 55 |
+
├── requirements.txt # Python dependencies
|
| 56 |
+
├── Dockerfile # Docker deployment config (HuggingFace Spaces)
|
| 57 |
+
├── .env # Sensitive credentials (NOT in Git)
|
| 58 |
+
├── docs/
|
| 59 |
+
│ ├── SRS.md # Software Requirements Specification
|
| 60 |
+
│ ├── JIRA_PLAN.md # Sprint planning & bug tracker
|
| 61 |
+
│ └── REVISION.md # Viva revision guide (Hinglish + English)
|
| 62 |
+
├── templates/
|
| 63 |
+
│ ├── base.html # Shared layout (nav, footer, flash messages)
|
| 64 |
+
│ ├── login.html # Auth - sign in
|
| 65 |
+
│ ├── register.html # Auth - sign up
|
| 66 |
+
│ ├── index.html # Dashboard + resume upload (drag & drop)
|
| 67 |
+
│ ├── results.html # Analysis results + audio upload + transcription
|
| 68 |
+
│ └── ranking.html # Candidate leaderboard + profile modal
|
| 69 |
+
├── static/css/style.css # Supplemental CSS
|
| 70 |
+
└── uploads/
|
| 71 |
+
├── resumes/ # Temporary resume storage
|
| 72 |
+
└── audio/ # Temporary audio storage (for Whisper)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## 🚀 How to Run
|
| 78 |
+
|
| 79 |
+
### Prerequisites
|
| 80 |
+
|
| 81 |
+
- **Python 3.9+** installed
|
| 82 |
+
- **ffmpeg** installed (required for Whisper audio processing)
|
| 83 |
+
- Windows: `winget install ffmpeg`
|
| 84 |
+
- Mac: `brew install ffmpeg`
|
| 85 |
+
- Linux: `sudo apt install ffmpeg`
|
| 86 |
+
|
| 87 |
+
### Step-by-Step Setup
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
# 1. Clone the repository
|
| 91 |
+
git clone https://github.com/yourusername/HireScope-AI.git
|
| 92 |
+
cd HireScope-AI
|
| 93 |
+
|
| 94 |
+
# 2. Create a virtual environment
|
| 95 |
+
python -m venv venv
|
| 96 |
+
|
| 97 |
+
# 3. Activate the virtual environment
|
| 98 |
+
# Windows:
|
| 99 |
+
venv\Scripts\activate
|
| 100 |
+
# Mac/Linux:
|
| 101 |
+
source venv/bin/activate
|
| 102 |
+
|
| 103 |
+
# 4. Install dependencies
|
| 104 |
+
pip install -r requirements.txt
|
| 105 |
+
|
| 106 |
+
# 5. Download NLTK data (one-time)
|
| 107 |
+
python -c "import nltk; nltk.download('stopwords'); nltk.download('punkt')"
|
| 108 |
+
|
| 109 |
+
# 6. Create .env file with your credentials
|
| 110 |
+
# MONGO_URI=mongodb+srv://...
|
| 111 |
+
# CLOUDINARY_CLOUD_NAME=...
|
| 112 |
+
# CLOUDINARY_API_KEY=...
|
| 113 |
+
# CLOUDINARY_API_SECRET=...
|
| 114 |
+
# GOOGLE_API_KEY=... (optional)
|
| 115 |
+
# SECRET_KEY=your-secret-key
|
| 116 |
+
|
| 117 |
+
# 7. Run the application
|
| 118 |
+
python app.py
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### Open in Browser
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
http://127.0.0.1:5000
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## 📖 How It Works
|
| 130 |
+
|
| 131 |
+
### 1. Resume Parsing (`resume_parser.py`)
|
| 132 |
+
- **PDF** files parsed using `PyPDF2`
|
| 133 |
+
- **DOCX** files parsed using `python-docx` (paragraphs + tables)
|
| 134 |
+
- Extracts candidate name, email, phone from raw text
|
| 135 |
+
|
| 136 |
+
### 2. Skill Extraction (`skill_extractor.py`)
|
| 137 |
+
- **250+ skills** across 24 categories covering all BTech career paths
|
| 138 |
+
- Regex-based **word-boundary matching** for precision
|
| 139 |
+
- **Skill normalization** infers related skills
|
| 140 |
+
|
| 141 |
+
### 3. Semantic Matching (`job_matcher.py`)
|
| 142 |
+
```
|
| 143 |
+
Resume Text → Sentence Embedding (384-dim) ─┐
|
| 144 |
+
├→ 50% Cosine Similarity + 50% Skill Overlap → Score (0-100)
|
| 145 |
+
Job Description → Sentence Embedding (384-dim) ─┘
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### 4. Audio Transcription (`audio_transcriber.py`)
|
| 149 |
+
- Uses OpenAI's Whisper (`base` model)
|
| 150 |
+
- **Local-file-first approach** — audio saved to disk, transcribed from local path
|
| 151 |
+
- Supports MP3, WAV, M4A, FLAC, OGG, WEBM
|
| 152 |
+
- Runs asynchronously via `ThreadPoolExecutor`
|
| 153 |
+
|
| 154 |
+
### 5. Candidate Ranking
|
| 155 |
+
- Leaderboard with re-ranking via custom JD
|
| 156 |
+
- Clickable candidate profiles with full biodata modal
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## 🛠️ Tech Stack
|
| 161 |
+
|
| 162 |
+
| Component | Technology |
|
| 163 |
+
|---|---|
|
| 164 |
+
| Backend | Python 3.9, Flask 3.x |
|
| 165 |
+
| Text Extraction | PyPDF2, python-docx |
|
| 166 |
+
| NLP | Regex (250+ skills), NLTK |
|
| 167 |
+
| Semantic ML | Sentence-Transformers (`all-MiniLM-L6-v2`) |
|
| 168 |
+
| Audio ASR | OpenAI Whisper (base model) |
|
| 169 |
+
| Database | MongoDB Atlas (PyMongo) |
|
| 170 |
+
| File Storage | Cloudinary |
|
| 171 |
+
| Frontend | Tailwind CSS v3, Inter Font, Vanilla JS, AJAX |
|
| 172 |
+
| Auth | Werkzeug password hashing, Flask sessions |
|
| 173 |
+
| Deployment | Docker, Gunicorn, HuggingFace Spaces |
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## 📄 License
|
| 178 |
+
|
| 179 |
+
This project is for educational purposes. Feel free to use and modify.
|
app.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
------
|
| 4 |
+
Main Flask application for HireScope AI — Resume Screening System.
|
| 5 |
+
Includes MongoDB, Auth, Async Processing, and Sentence Transformers.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
import concurrent.futures
|
| 11 |
+
from functools import wraps
|
| 12 |
+
from flask import (
|
| 13 |
+
Flask, render_template, request, redirect, url_for,
|
| 14 |
+
flash, session, jsonify
|
| 15 |
+
)
|
| 16 |
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
import cloudinary
|
| 19 |
+
import cloudinary.uploader
|
| 20 |
+
import cloudinary.api
|
| 21 |
+
|
| 22 |
+
# Load environment variables
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
+
# Cloudinary Configuration
|
| 26 |
+
cloudinary.config(
|
| 27 |
+
cloud_name=os.getenv("CLOUDINARY_CLOUD_NAME"),
|
| 28 |
+
api_key=os.getenv("CLOUDINARY_API_KEY"),
|
| 29 |
+
api_secret=os.getenv("CLOUDINARY_API_SECRET"),
|
| 30 |
+
secure=True,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
from db import (
|
| 34 |
+
create_user, authenticate_user, insert_candidate,
|
| 35 |
+
get_all_candidates, get_candidate_by_id, update_candidate_audio,
|
| 36 |
+
update_candidate_audio_error, set_candidate_audio_processing,
|
| 37 |
+
clear_all_candidates
|
| 38 |
+
)
|
| 39 |
+
from werkzeug.utils import secure_filename
|
| 40 |
+
from resume_parser import extract_text, clean_text
|
| 41 |
+
from skill_extractor import extract_all, SKILLS_LIST
|
| 42 |
+
from job_matcher import calculate_match_score, find_skill_gaps, rank_candidates
|
| 43 |
+
from audio_transcriber import transcribe_from_local_file
|
| 44 |
+
|
| 45 |
+
app = Flask(__name__)
|
| 46 |
+
logging.basicConfig(
|
| 47 |
+
level=os.getenv("LOG_LEVEL", "INFO"),
|
| 48 |
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
| 49 |
+
)
|
| 50 |
+
logger = logging.getLogger(__name__)
|
| 51 |
+
|
| 52 |
+
# Secret key
|
| 53 |
+
app.secret_key = os.getenv("SECRET_KEY")
|
| 54 |
+
|
| 55 |
+
# Hugging Face / Proxy Configuration
|
| 56 |
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
| 57 |
+
|
| 58 |
+
# Session Configuration for iframe compatibility (Hugging Face)
|
| 59 |
+
app.config.update(
|
| 60 |
+
SESSION_COOKIE_SECURE=True,
|
| 61 |
+
SESSION_COOKIE_SAMESITE='None',
|
| 62 |
+
SESSION_COOKIE_HTTPONLY=True,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Optional: Initialize Google Generative AI if key is present
|
| 66 |
+
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "")
|
| 67 |
+
if GOOGLE_API_KEY:
|
| 68 |
+
try:
|
| 69 |
+
import google.generativeai as genai
|
| 70 |
+
genai.configure(api_key=GOOGLE_API_KEY)
|
| 71 |
+
except Exception:
|
| 72 |
+
pass
|
| 73 |
+
|
| 74 |
+
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
| 75 |
+
UPLOAD_FOLDER_RESUMES = os.path.join(BASE_DIR, "uploads", "resumes")
|
| 76 |
+
UPLOAD_FOLDER_AUDIO = os.path.join(BASE_DIR, "uploads", "audio")
|
| 77 |
+
os.makedirs(UPLOAD_FOLDER_RESUMES, exist_ok=True)
|
| 78 |
+
os.makedirs(UPLOAD_FOLDER_AUDIO, exist_ok=True)
|
| 79 |
+
|
| 80 |
+
ALLOWED_RESUME_EXTENSIONS = {"pdf", "docx"}
|
| 81 |
+
ALLOWED_AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "flac", "ogg", "webm"}
|
| 82 |
+
|
| 83 |
+
# Thread pool for async audio transcription
|
| 84 |
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=2)
|
| 85 |
+
|
| 86 |
+
def allowed_file(filename, allowed_extensions):
|
| 87 |
+
return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed_extensions
|
| 88 |
+
|
| 89 |
+
# ── Authentication Helper ──
|
| 90 |
+
def login_required(f):
|
| 91 |
+
@wraps(f)
|
| 92 |
+
def decorated_function(*args, **kwargs):
|
| 93 |
+
if "user_id" not in session:
|
| 94 |
+
flash("Please log in to access this page.", "warning")
|
| 95 |
+
return redirect(url_for("login"))
|
| 96 |
+
return f(*args, **kwargs)
|
| 97 |
+
return decorated_function
|
| 98 |
+
|
| 99 |
+
@app.route("/login", methods=["GET", "POST"])
|
| 100 |
+
def login():
|
| 101 |
+
if request.method == "POST":
|
| 102 |
+
email = request.form.get("email")
|
| 103 |
+
password = request.form.get("password")
|
| 104 |
+
user = authenticate_user(email, password)
|
| 105 |
+
if user:
|
| 106 |
+
session["user_id"] = user["_id"]
|
| 107 |
+
session["username"] = user["username"]
|
| 108 |
+
session["role"] = user["role"]
|
| 109 |
+
flash("Logged in successfully!", "success")
|
| 110 |
+
return redirect(url_for("index"))
|
| 111 |
+
else:
|
| 112 |
+
flash("Invalid email or password", "error")
|
| 113 |
+
return render_template("login.html")
|
| 114 |
+
|
| 115 |
+
@app.route("/register", methods=["GET", "POST"])
|
| 116 |
+
def register():
|
| 117 |
+
if request.method == "POST":
|
| 118 |
+
username = request.form.get("username")
|
| 119 |
+
email = request.form.get("email")
|
| 120 |
+
password = request.form.get("password")
|
| 121 |
+
success, msg = create_user(username, email, password)
|
| 122 |
+
if success:
|
| 123 |
+
flash("Registration successful. Please login.", "success")
|
| 124 |
+
return redirect(url_for("login"))
|
| 125 |
+
else:
|
| 126 |
+
flash(msg, "error")
|
| 127 |
+
return render_template("register.html")
|
| 128 |
+
|
| 129 |
+
@app.route("/logout")
|
| 130 |
+
def logout():
|
| 131 |
+
session.clear()
|
| 132 |
+
flash("Logged out successfully.", "info")
|
| 133 |
+
return redirect(url_for("login"))
|
| 134 |
+
|
| 135 |
+
@app.route("/")
|
| 136 |
+
@login_required
|
| 137 |
+
def index():
|
| 138 |
+
candidates = get_all_candidates()
|
| 139 |
+
# Calculate stats
|
| 140 |
+
avg_score = 0
|
| 141 |
+
if candidates:
|
| 142 |
+
scores = [c.get("match_score", 0) for c in candidates]
|
| 143 |
+
avg_score = round(sum(scores) / len(scores), 1)
|
| 144 |
+
return render_template(
|
| 145 |
+
"index.html",
|
| 146 |
+
candidate_count=len(candidates),
|
| 147 |
+
avg_score=avg_score,
|
| 148 |
+
recent_candidates=candidates[:5]
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
@app.route("/upload", methods=["POST"])
|
| 152 |
+
@login_required
|
| 153 |
+
def upload_resume():
|
| 154 |
+
if "resume" not in request.files:
|
| 155 |
+
flash("No file selected.", "error")
|
| 156 |
+
return redirect(url_for("index"))
|
| 157 |
+
|
| 158 |
+
file = request.files["resume"]
|
| 159 |
+
if file.filename == "":
|
| 160 |
+
flash("No file selected.", "error")
|
| 161 |
+
return redirect(url_for("index"))
|
| 162 |
+
|
| 163 |
+
if not allowed_file(file.filename, ALLOWED_RESUME_EXTENSIONS):
|
| 164 |
+
flash("Invalid file type. Please upload PDF or DOCX.", "error")
|
| 165 |
+
return redirect(url_for("index"))
|
| 166 |
+
|
| 167 |
+
filename = secure_filename(file.filename)
|
| 168 |
+
if not filename:
|
| 169 |
+
filename = "resume_file"
|
| 170 |
+
filepath = os.path.join(UPLOAD_FOLDER_RESUMES, filename)
|
| 171 |
+
file.save(filepath)
|
| 172 |
+
|
| 173 |
+
raw_text = extract_text(filepath)
|
| 174 |
+
if not raw_text.strip():
|
| 175 |
+
flash("Could not extract text. Please ensure the PDF/DOCX is not just scanned images.", "error")
|
| 176 |
+
return redirect(url_for("index"))
|
| 177 |
+
|
| 178 |
+
cleaned_text = clean_text(raw_text)
|
| 179 |
+
extracted_info = extract_all(cleaned_text)
|
| 180 |
+
|
| 181 |
+
job_description = request.form.get("job_description", "").strip()
|
| 182 |
+
match_score = 0.0
|
| 183 |
+
skill_gaps = {"matched": [], "missing": []}
|
| 184 |
+
jd_skills = []
|
| 185 |
+
|
| 186 |
+
if job_description:
|
| 187 |
+
from skill_extractor import extract_skills
|
| 188 |
+
jd_skills = extract_skills(job_description)
|
| 189 |
+
match_score = calculate_match_score(cleaned_text, job_description, extracted_info["skills"], jd_skills)
|
| 190 |
+
skill_gaps = find_skill_gaps(extracted_info["skills"], job_description, SKILLS_LIST, jd_skills)
|
| 191 |
+
|
| 192 |
+
# --- Cloudinary Upload Resume ---
|
| 193 |
+
resume_url = ""
|
| 194 |
+
try:
|
| 195 |
+
cloudinary_response = cloudinary.uploader.upload(
|
| 196 |
+
filepath,
|
| 197 |
+
resource_type="auto",
|
| 198 |
+
folder="resume_screener/resumes",
|
| 199 |
+
use_filename=True,
|
| 200 |
+
unique_filename=True,
|
| 201 |
+
)
|
| 202 |
+
# Simply use the secure_url provided by Cloudinary
|
| 203 |
+
resume_url = cloudinary_response.get("secure_url", "")
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error("Cloudinary upload failed: %s", e)
|
| 206 |
+
resume_url = ""
|
| 207 |
+
|
| 208 |
+
# Clean up local file after processing
|
| 209 |
+
try:
|
| 210 |
+
os.remove(filepath)
|
| 211 |
+
except Exception:
|
| 212 |
+
pass
|
| 213 |
+
|
| 214 |
+
# AI Summary using Google Gen AI (if configured)
|
| 215 |
+
ai_summary = ""
|
| 216 |
+
if GOOGLE_API_KEY:
|
| 217 |
+
try:
|
| 218 |
+
import google.generativeai as genai
|
| 219 |
+
model = genai.GenerativeModel('gemini-flash-latest')
|
| 220 |
+
prompt = f"Summarize this candidate in 2 to 3 short sentences emphasizing their top skills, experience, and education based on this resume text:\n{cleaned_text[:3000]}"
|
| 221 |
+
response = model.generate_content(prompt)
|
| 222 |
+
ai_summary = response.text.strip()
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error(f"Generative AI Error (Summary): {e}")
|
| 225 |
+
|
| 226 |
+
candidate_name = os.path.splitext(filename)[0].replace("_", " ").replace("-", " ").title()
|
| 227 |
+
candidate_data = {
|
| 228 |
+
"name": candidate_name,
|
| 229 |
+
"filename": filename,
|
| 230 |
+
"resume_url": resume_url,
|
| 231 |
+
"resume_text": cleaned_text,
|
| 232 |
+
"raw_text_preview": raw_text[:500],
|
| 233 |
+
"ai_summary": ai_summary,
|
| 234 |
+
"skills": extracted_info["skills"],
|
| 235 |
+
"education": extracted_info["education"],
|
| 236 |
+
"experience": extracted_info["experience"],
|
| 237 |
+
"match_score": match_score,
|
| 238 |
+
"skill_gaps": skill_gaps,
|
| 239 |
+
"job_description": job_description,
|
| 240 |
+
"audio_transcription": None,
|
| 241 |
+
"uploaded_by": session["user_id"]
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
# Save to MongoDB
|
| 245 |
+
candidate_id = insert_candidate(candidate_data)
|
| 246 |
+
session["last_candidate_id"] = str(candidate_id)
|
| 247 |
+
|
| 248 |
+
flash("Resume analyzed successfully!", "success")
|
| 249 |
+
return redirect(url_for("results"))
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def process_audio_local(candidate_id, local_audio_path, audio_url):
|
| 253 |
+
"""
|
| 254 |
+
Process audio transcription from a LOCAL file (not URL).
|
| 255 |
+
This avoids Cloudinary download issues entirely.
|
| 256 |
+
The audio is saved locally first, transcribed, then cleaned up.
|
| 257 |
+
"""
|
| 258 |
+
logger.info("Starting LOCAL transcription for candidate_id=%s, file=%s", candidate_id, local_audio_path)
|
| 259 |
+
result = transcribe_from_local_file(local_audio_path)
|
| 260 |
+
|
| 261 |
+
if result["success"]:
|
| 262 |
+
update_candidate_audio(candidate_id, result["text"], result["language"], audio_url)
|
| 263 |
+
logger.info("Transcription saved for candidate_id=%s", candidate_id)
|
| 264 |
+
else:
|
| 265 |
+
update_candidate_audio_error(candidate_id, result["error"], audio_url)
|
| 266 |
+
logger.error("Transcription failed for candidate_id=%s: %s", candidate_id, result["error"])
|
| 267 |
+
|
| 268 |
+
# Clean up local audio file after transcription
|
| 269 |
+
try:
|
| 270 |
+
if local_audio_path and os.path.exists(local_audio_path):
|
| 271 |
+
os.remove(local_audio_path)
|
| 272 |
+
logger.info("Cleaned up local audio: %s", local_audio_path)
|
| 273 |
+
except Exception:
|
| 274 |
+
pass
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _handle_transcription_future(future, candidate_id, audio_url):
|
| 278 |
+
exc = future.exception()
|
| 279 |
+
if exc is None:
|
| 280 |
+
return
|
| 281 |
+
error_msg = f"Background transcription crashed: {exc}"
|
| 282 |
+
logger.exception("Unhandled transcription error for candidate_id=%s", candidate_id)
|
| 283 |
+
update_candidate_audio_error(candidate_id, error_msg, audio_url)
|
| 284 |
+
|
| 285 |
+
@app.route("/upload_audio", methods=["POST"])
|
| 286 |
+
@login_required
|
| 287 |
+
def upload_audio():
|
| 288 |
+
# Get candidate_id from form (sent from results page) or session
|
| 289 |
+
candidate_id = request.form.get("candidate_id") or session.get("last_candidate_id")
|
| 290 |
+
|
| 291 |
+
if not candidate_id:
|
| 292 |
+
flash("Please upload and analyze a resume first before attaching audio.", "error")
|
| 293 |
+
return redirect(url_for("index"))
|
| 294 |
+
|
| 295 |
+
candidate = get_candidate_by_id(candidate_id)
|
| 296 |
+
if not candidate:
|
| 297 |
+
flash("Candidate not found. Please upload a resume first.", "error")
|
| 298 |
+
return redirect(url_for("index"))
|
| 299 |
+
|
| 300 |
+
if "audio" not in request.files:
|
| 301 |
+
flash("No audio file selected.", "error")
|
| 302 |
+
return redirect(url_for("results"))
|
| 303 |
+
|
| 304 |
+
file = request.files["audio"]
|
| 305 |
+
if file.filename == "":
|
| 306 |
+
flash("No audio file selected.", "error")
|
| 307 |
+
return redirect(url_for("results"))
|
| 308 |
+
|
| 309 |
+
if not allowed_file(file.filename, ALLOWED_AUDIO_EXTENSIONS):
|
| 310 |
+
flash("Invalid audio format. Supported: MP3, WAV, M4A, FLAC, OGG, WEBM", "error")
|
| 311 |
+
return redirect(url_for("results"))
|
| 312 |
+
|
| 313 |
+
filename = secure_filename(file.filename) or "audio_file"
|
| 314 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 315 |
+
if not ext:
|
| 316 |
+
ext = ".mp3"
|
| 317 |
+
|
| 318 |
+
# === KEY FIX: Save audio LOCALLY first, then transcribe from local file ===
|
| 319 |
+
local_audio_path = os.path.join(UPLOAD_FOLDER_AUDIO, f"{candidate_id}_{filename}")
|
| 320 |
+
file.save(local_audio_path)
|
| 321 |
+
logger.info("Audio saved locally at: %s (%d bytes)", local_audio_path, os.path.getsize(local_audio_path))
|
| 322 |
+
|
| 323 |
+
# Upload to Cloudinary for storage (non-blocking for transcription)
|
| 324 |
+
audio_url = ""
|
| 325 |
+
try:
|
| 326 |
+
cloudinary_response = cloudinary.uploader.upload(
|
| 327 |
+
local_audio_path,
|
| 328 |
+
resource_type="video",
|
| 329 |
+
folder="resume_screener/audio",
|
| 330 |
+
public_id=f"{candidate_id}_{os.path.splitext(filename)[0]}",
|
| 331 |
+
use_filename=False,
|
| 332 |
+
overwrite=True,
|
| 333 |
+
)
|
| 334 |
+
audio_url = cloudinary_response.get("secure_url", "")
|
| 335 |
+
except Exception as exc:
|
| 336 |
+
logger.warning("Audio Cloudinary upload failed (will still transcribe locally): %s", exc)
|
| 337 |
+
audio_url = "" # Not critical — transcription uses local file
|
| 338 |
+
|
| 339 |
+
set_candidate_audio_processing(candidate_id, audio_url)
|
| 340 |
+
|
| 341 |
+
# === Transcribe from LOCAL file (not from Cloudinary URL) ===
|
| 342 |
+
try:
|
| 343 |
+
future = executor.submit(process_audio_local, candidate_id, local_audio_path, audio_url)
|
| 344 |
+
future.add_done_callback(
|
| 345 |
+
lambda f, cid=candidate_id, aurl=audio_url: _handle_transcription_future(f, cid, aurl)
|
| 346 |
+
)
|
| 347 |
+
except Exception as exc:
|
| 348 |
+
error_msg = f"Failed to queue transcription task: {exc}"
|
| 349 |
+
logger.exception(error_msg)
|
| 350 |
+
update_candidate_audio_error(candidate_id, error_msg, audio_url)
|
| 351 |
+
flash(error_msg, "error")
|
| 352 |
+
return redirect(url_for("results"))
|
| 353 |
+
|
| 354 |
+
session["last_candidate_id"] = str(candidate_id)
|
| 355 |
+
session["awaiting_transcription_for"] = str(candidate["_id"])
|
| 356 |
+
logger.info("Queued LOCAL transcription for candidate_id=%s", candidate_id)
|
| 357 |
+
flash("Audio uploaded successfully! Transcription is processing in the background.", "info")
|
| 358 |
+
return redirect(url_for("results"))
|
| 359 |
+
|
| 360 |
+
@app.route("/results")
|
| 361 |
+
@login_required
|
| 362 |
+
def results():
|
| 363 |
+
candidate_id = session.get("last_candidate_id")
|
| 364 |
+
candidate = get_candidate_by_id(candidate_id) if candidate_id else None
|
| 365 |
+
transcription_pending = False
|
| 366 |
+
awaiting_for = session.get("awaiting_transcription_for")
|
| 367 |
+
|
| 368 |
+
if candidate and awaiting_for == str(candidate["_id"]):
|
| 369 |
+
audio_transcription = candidate.get("audio_transcription")
|
| 370 |
+
if audio_transcription and audio_transcription.get("status") in {"completed", "failed"}:
|
| 371 |
+
session.pop("awaiting_transcription_for", None)
|
| 372 |
+
else:
|
| 373 |
+
transcription_pending = True
|
| 374 |
+
elif not candidate:
|
| 375 |
+
session.pop("awaiting_transcription_for", None)
|
| 376 |
+
|
| 377 |
+
candidates = get_all_candidates()
|
| 378 |
+
return render_template(
|
| 379 |
+
"results.html",
|
| 380 |
+
candidate=candidate,
|
| 381 |
+
candidate_count=len(candidates),
|
| 382 |
+
transcription_pending=transcription_pending
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# ── API: Transcription Status (AJAX polling) ──
|
| 386 |
+
@app.route("/api/transcription_status/<candidate_id>")
|
| 387 |
+
@login_required
|
| 388 |
+
def transcription_status(candidate_id):
|
| 389 |
+
candidate = get_candidate_by_id(candidate_id)
|
| 390 |
+
if not candidate:
|
| 391 |
+
return jsonify({"status": "not_found"}), 404
|
| 392 |
+
|
| 393 |
+
audio = candidate.get("audio_transcription")
|
| 394 |
+
if not audio:
|
| 395 |
+
return jsonify({"status": "none"})
|
| 396 |
+
|
| 397 |
+
return jsonify({
|
| 398 |
+
"status": audio.get("status", "unknown"),
|
| 399 |
+
"text": audio.get("text", ""),
|
| 400 |
+
"language": audio.get("language", ""),
|
| 401 |
+
"error": audio.get("error"),
|
| 402 |
+
})
|
| 403 |
+
|
| 404 |
+
# ── API: Candidate Profile (for modal) ──
|
| 405 |
+
@app.route("/api/candidate/<candidate_id>")
|
| 406 |
+
@login_required
|
| 407 |
+
def candidate_profile(candidate_id):
|
| 408 |
+
candidate = get_candidate_by_id(candidate_id)
|
| 409 |
+
if not candidate:
|
| 410 |
+
return jsonify({"error": "not found"}), 404
|
| 411 |
+
|
| 412 |
+
# Don't send the full resume text to keep response small
|
| 413 |
+
return jsonify({
|
| 414 |
+
"_id": candidate["_id"],
|
| 415 |
+
"name": candidate.get("name", "Unknown"),
|
| 416 |
+
"filename": candidate.get("filename", ""),
|
| 417 |
+
"resume_url": candidate.get("resume_url", ""),
|
| 418 |
+
"ai_summary": candidate.get("ai_summary", ""),
|
| 419 |
+
"skills": candidate.get("skills", []),
|
| 420 |
+
"education": candidate.get("education", []),
|
| 421 |
+
"experience": candidate.get("experience", []),
|
| 422 |
+
"match_score": candidate.get("match_score", 0),
|
| 423 |
+
"skill_gaps": candidate.get("skill_gaps", {"matched": [], "missing": []}),
|
| 424 |
+
"job_description": candidate.get("job_description", ""),
|
| 425 |
+
"audio_transcription": candidate.get("audio_transcription"),
|
| 426 |
+
"raw_text_preview": candidate.get("raw_text_preview", ""),
|
| 427 |
+
})
|
| 428 |
+
|
| 429 |
+
@app.route("/ranking", methods=["GET", "POST"])
|
| 430 |
+
@login_required
|
| 431 |
+
def ranking():
|
| 432 |
+
candidates = get_all_candidates()
|
| 433 |
+
job_description = ""
|
| 434 |
+
ranked = list(candidates)
|
| 435 |
+
|
| 436 |
+
if request.method == "POST":
|
| 437 |
+
job_description = request.form.get("job_description", "").strip()
|
| 438 |
+
if job_description and candidates:
|
| 439 |
+
from skill_extractor import extract_skills, SKILLS_LIST
|
| 440 |
+
from job_matcher import calculate_match_score, find_skill_gaps, rank_candidates
|
| 441 |
+
from db import candidates_collection
|
| 442 |
+
from bson.objectid import ObjectId
|
| 443 |
+
jd_skills = extract_skills(job_description)
|
| 444 |
+
|
| 445 |
+
# Recalculate score and gaps for all candidates and update in DB
|
| 446 |
+
for candidate in candidates:
|
| 447 |
+
candidate_id = candidate["_id"]
|
| 448 |
+
candidate_skills = candidate.get("skills", [])
|
| 449 |
+
resume_text = candidate.get("resume_text", "")
|
| 450 |
+
|
| 451 |
+
# Calculate new metrics based on new JD
|
| 452 |
+
new_match_score = calculate_match_score(resume_text, job_description, candidate_skills, jd_skills)
|
| 453 |
+
new_skill_gaps = find_skill_gaps(candidate_skills, job_description, SKILLS_LIST, jd_skills)
|
| 454 |
+
|
| 455 |
+
# Update DB directly
|
| 456 |
+
try:
|
| 457 |
+
candidates_collection.update_one(
|
| 458 |
+
{"_id": ObjectId(candidate_id)},
|
| 459 |
+
{"$set": {
|
| 460 |
+
"job_description": job_description,
|
| 461 |
+
"match_score": new_match_score,
|
| 462 |
+
"skill_gaps": new_skill_gaps
|
| 463 |
+
}}
|
| 464 |
+
)
|
| 465 |
+
except Exception as e:
|
| 466 |
+
logger.error(f"Error updating candidate {candidate_id} during re-ranking: {e}")
|
| 467 |
+
|
| 468 |
+
# Re-fetch the updated candidates from the database
|
| 469 |
+
candidates = get_all_candidates()
|
| 470 |
+
# Rank the newly fetched candidates
|
| 471 |
+
ranked = rank_candidates(candidates, job_description, jd_skills)
|
| 472 |
+
|
| 473 |
+
return render_template("ranking.html", ranked=ranked, job_description=job_description, candidate_count=len(candidates))
|
| 474 |
+
|
| 475 |
+
@app.route("/clear")
|
| 476 |
+
@login_required
|
| 477 |
+
def clear():
|
| 478 |
+
clear_all_candidates()
|
| 479 |
+
session.pop("last_candidate_id", None)
|
| 480 |
+
flash("All candidate data cleared from database.", "info")
|
| 481 |
+
return redirect(url_for("index"))
|
| 482 |
+
|
| 483 |
+
if __name__ == "__main__":
|
| 484 |
+
app.run(debug=True, port=5000)
|
audio_transcriber.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
audio_transcriber.py
|
| 3 |
+
--------------------
|
| 4 |
+
Converts audio files to text using OpenAI's Whisper model.
|
| 5 |
+
Supports both local file paths and URLs.
|
| 6 |
+
Requires ffmpeg to be installed on the system.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import os
|
| 11 |
+
import tempfile
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from urllib.parse import urlparse
|
| 15 |
+
|
| 16 |
+
import requests
|
| 17 |
+
import whisper
|
| 18 |
+
|
| 19 |
+
# ── Auto-inject ffmpeg PATH (Windows) ──
|
| 20 |
+
# If ffmpeg is installed at C:\ffmpeg but not in system PATH (common when
|
| 21 |
+
# installed without admin rights), add it to the current process PATH.
|
| 22 |
+
_FFMPEG_COMMON_PATHS = [
|
| 23 |
+
r"C:\ffmpeg",
|
| 24 |
+
r"C:\ffmpeg\bin",
|
| 25 |
+
r"C:\Program Files\ffmpeg\bin",
|
| 26 |
+
r"C:\Program Files (x86)\ffmpeg\bin",
|
| 27 |
+
]
|
| 28 |
+
for _fp in _FFMPEG_COMMON_PATHS:
|
| 29 |
+
if os.path.isfile(os.path.join(_fp, "ffmpeg.exe")):
|
| 30 |
+
if _fp not in os.environ.get("PATH", ""):
|
| 31 |
+
os.environ["PATH"] = _fp + os.pathsep + os.environ.get("PATH", "")
|
| 32 |
+
break
|
| 33 |
+
|
| 34 |
+
# Cache the model so it's only loaded once
|
| 35 |
+
_model = None
|
| 36 |
+
_model_lock = threading.Lock()
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
# Map Content-Type to file extensions
|
| 40 |
+
CONTENT_TYPE_TO_EXT = {
|
| 41 |
+
"audio/mpeg": ".mp3",
|
| 42 |
+
"audio/mp3": ".mp3",
|
| 43 |
+
"audio/wav": ".wav",
|
| 44 |
+
"audio/x-wav": ".wav",
|
| 45 |
+
"audio/wave": ".wav",
|
| 46 |
+
"audio/x-m4a": ".m4a",
|
| 47 |
+
"audio/mp4": ".m4a",
|
| 48 |
+
"audio/m4a": ".m4a",
|
| 49 |
+
"audio/flac": ".flac",
|
| 50 |
+
"audio/x-flac": ".flac",
|
| 51 |
+
"audio/ogg": ".ogg",
|
| 52 |
+
"audio/webm": ".webm",
|
| 53 |
+
"video/webm": ".webm",
|
| 54 |
+
"video/mp4": ".mp4",
|
| 55 |
+
"application/octet-stream": ".mp3", # fallback for generic binary
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _get_model(model_name="base"):
|
| 60 |
+
"""
|
| 61 |
+
Load and cache the Whisper model.
|
| 62 |
+
|
| 63 |
+
Available models (smallest to largest):
|
| 64 |
+
tiny, base, small, medium, large
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
model_name (str): Which Whisper model to use.
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
whisper.Whisper: The loaded model.
|
| 71 |
+
"""
|
| 72 |
+
global _model
|
| 73 |
+
if _model is None:
|
| 74 |
+
with _model_lock:
|
| 75 |
+
if _model is None:
|
| 76 |
+
logger.info("Loading Whisper model '%s'...", model_name)
|
| 77 |
+
_model = whisper.load_model(model_name)
|
| 78 |
+
logger.info("Whisper model loaded.")
|
| 79 |
+
return _model
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _download_audio_to_tempfile(source_url, timeout=120, max_retries=3):
|
| 83 |
+
"""
|
| 84 |
+
Download audio from a URL to a temporary file.
|
| 85 |
+
Includes retry logic, proper extension detection, and User-Agent header.
|
| 86 |
+
"""
|
| 87 |
+
headers = {
|
| 88 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
last_error = None
|
| 92 |
+
for attempt in range(1, max_retries + 1):
|
| 93 |
+
try:
|
| 94 |
+
logger.info(
|
| 95 |
+
"Downloading audio (attempt %d/%d): %s", attempt, max_retries, source_url
|
| 96 |
+
)
|
| 97 |
+
response = requests.get(
|
| 98 |
+
source_url, stream=True, timeout=timeout, headers=headers
|
| 99 |
+
)
|
| 100 |
+
response.raise_for_status()
|
| 101 |
+
|
| 102 |
+
# Determine file extension from Content-Type header
|
| 103 |
+
content_type = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
| 104 |
+
ext = CONTENT_TYPE_TO_EXT.get(content_type)
|
| 105 |
+
|
| 106 |
+
# Fallback: try to extract extension from URL path
|
| 107 |
+
if not ext:
|
| 108 |
+
parsed = urlparse(source_url)
|
| 109 |
+
url_ext = os.path.splitext(parsed.path)[1].lower()
|
| 110 |
+
if url_ext in {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".webm", ".mp4"}:
|
| 111 |
+
ext = url_ext
|
| 112 |
+
else:
|
| 113 |
+
ext = ".mp3" # safe default for Whisper
|
| 114 |
+
|
| 115 |
+
logger.info("Detected content type: %s -> extension: %s", content_type, ext)
|
| 116 |
+
|
| 117 |
+
# Write to temp file
|
| 118 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
|
| 119 |
+
total_bytes = 0
|
| 120 |
+
try:
|
| 121 |
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
| 122 |
+
if chunk:
|
| 123 |
+
tmp.write(chunk)
|
| 124 |
+
total_bytes += len(chunk)
|
| 125 |
+
finally:
|
| 126 |
+
tmp.close()
|
| 127 |
+
|
| 128 |
+
logger.info("Downloaded %d bytes to %s", total_bytes, tmp.name)
|
| 129 |
+
|
| 130 |
+
if total_bytes == 0:
|
| 131 |
+
os.unlink(tmp.name)
|
| 132 |
+
raise RuntimeError("Downloaded file is empty (0 bytes)")
|
| 133 |
+
|
| 134 |
+
return tmp.name
|
| 135 |
+
|
| 136 |
+
except Exception as e:
|
| 137 |
+
last_error = e
|
| 138 |
+
logger.warning("Download attempt %d failed: %s", attempt, e)
|
| 139 |
+
if attempt < max_retries:
|
| 140 |
+
wait = 2 ** attempt
|
| 141 |
+
logger.info("Retrying in %d seconds...", wait)
|
| 142 |
+
time.sleep(wait)
|
| 143 |
+
|
| 144 |
+
raise RuntimeError(
|
| 145 |
+
f"Failed to download audio after {max_retries} attempts. Last error: {last_error}"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _check_ffmpeg():
|
| 150 |
+
"""Check if ffmpeg is installed and available in PATH."""
|
| 151 |
+
import subprocess
|
| 152 |
+
try:
|
| 153 |
+
subprocess.run(
|
| 154 |
+
["ffmpeg", "-version"],
|
| 155 |
+
stdout=subprocess.DEVNULL,
|
| 156 |
+
stderr=subprocess.DEVNULL,
|
| 157 |
+
timeout=5
|
| 158 |
+
)
|
| 159 |
+
return True
|
| 160 |
+
except (FileNotFoundError, OSError):
|
| 161 |
+
return False
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def transcribe_from_local_file(local_path, model_name="base"):
|
| 165 |
+
"""
|
| 166 |
+
Transcribe audio directly from a local file path.
|
| 167 |
+
This is the PRIMARY method — saves the audio locally first,
|
| 168 |
+
then runs Whisper on it. No network download needed.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
local_path (str): Absolute path to the audio file on disk.
|
| 172 |
+
model_name (str): Whisper model size (default: "base").
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
dict: {
|
| 176 |
+
"text": str, # Full transcription
|
| 177 |
+
"language": str, # Detected language
|
| 178 |
+
"success": bool, # Whether transcription succeeded
|
| 179 |
+
"error": str|None # Error message if failed
|
| 180 |
+
}
|
| 181 |
+
"""
|
| 182 |
+
try:
|
| 183 |
+
# --- Check ffmpeg first (WinError 2 prevention) ---
|
| 184 |
+
if not _check_ffmpeg():
|
| 185 |
+
msg = (
|
| 186 |
+
"ffmpeg not found! Whisper needs ffmpeg to decode audio. "
|
| 187 |
+
"Install it: Windows → 'winget install ffmpeg' then restart your terminal. "
|
| 188 |
+
"Or download from https://ffmpeg.org/download.html and add to PATH."
|
| 189 |
+
)
|
| 190 |
+
logger.error(msg)
|
| 191 |
+
return {"text": "", "language": "", "success": False, "error": msg}
|
| 192 |
+
|
| 193 |
+
if not os.path.exists(local_path):
|
| 194 |
+
return {
|
| 195 |
+
"text": "",
|
| 196 |
+
"language": "",
|
| 197 |
+
"success": False,
|
| 198 |
+
"error": f"Local audio file not found: {local_path}",
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
file_size = os.path.getsize(local_path)
|
| 202 |
+
if file_size == 0:
|
| 203 |
+
return {
|
| 204 |
+
"text": "",
|
| 205 |
+
"language": "",
|
| 206 |
+
"success": False,
|
| 207 |
+
"error": "Audio file is empty (0 bytes)",
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
logger.info("Transcribing local file: %s (%d bytes)", local_path, file_size)
|
| 211 |
+
model = _get_model(model_name)
|
| 212 |
+
result = model.transcribe(local_path)
|
| 213 |
+
text = result.get("text", "").strip()
|
| 214 |
+
language = result.get("language", "unknown")
|
| 215 |
+
logger.info("Transcription complete. Language: %s, Length: %d chars", language, len(text))
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
"text": text,
|
| 219 |
+
"language": language,
|
| 220 |
+
"success": True,
|
| 221 |
+
"error": None,
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
error_msg = str(e)
|
| 226 |
+
logger.exception("Transcription failed for local file: %s", local_path)
|
| 227 |
+
|
| 228 |
+
# WinError 2 = file not found = typically ffmpeg not in PATH
|
| 229 |
+
if "winerror 2" in error_msg.lower() or "[winerror 2]" in error_msg.lower() or "cannot find the file" in error_msg.lower():
|
| 230 |
+
error_msg = (
|
| 231 |
+
"ffmpeg not found in PATH (WinError 2). "
|
| 232 |
+
"Install it: run 'winget install ffmpeg' in PowerShell as Administrator, then restart. "
|
| 233 |
+
"Or download from https://ffmpeg.org/download.html"
|
| 234 |
+
)
|
| 235 |
+
elif "ffmpeg" in error_msg.lower():
|
| 236 |
+
error_msg = (
|
| 237 |
+
"ffmpeg error. Please install ffmpeg and ensure it is in your system PATH. "
|
| 238 |
+
"Windows: 'winget install ffmpeg'"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
return {
|
| 242 |
+
"text": "",
|
| 243 |
+
"language": "",
|
| 244 |
+
"success": False,
|
| 245 |
+
"error": error_msg,
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def transcribe_audio(audio_source, model_name="base"):
|
| 250 |
+
"""
|
| 251 |
+
Transcribe an audio file to text using Whisper.
|
| 252 |
+
|
| 253 |
+
Supported formats: mp3, wav, m4a, flac, ogg, webm
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
audio_source (str): Path or URL to the audio file.
|
| 257 |
+
model_name (str): Whisper model size (default: "base").
|
| 258 |
+
|
| 259 |
+
Returns:
|
| 260 |
+
dict: {
|
| 261 |
+
"text": str, # Full transcription
|
| 262 |
+
"language": str, # Detected language
|
| 263 |
+
"success": bool, # Whether transcription succeeded
|
| 264 |
+
"error": str|None # Error message if failed
|
| 265 |
+
}
|
| 266 |
+
"""
|
| 267 |
+
local_path = None
|
| 268 |
+
is_url = False
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
model = _get_model(model_name)
|
| 272 |
+
is_url = str(audio_source).startswith(("http://", "https://"))
|
| 273 |
+
|
| 274 |
+
if is_url:
|
| 275 |
+
logger.info("Audio source is a URL, downloading: %s", audio_source)
|
| 276 |
+
local_path = _download_audio_to_tempfile(audio_source)
|
| 277 |
+
else:
|
| 278 |
+
local_path = audio_source
|
| 279 |
+
if not os.path.exists(local_path):
|
| 280 |
+
return {
|
| 281 |
+
"text": "",
|
| 282 |
+
"language": "",
|
| 283 |
+
"success": False,
|
| 284 |
+
"error": f"Local audio file not found: {local_path}",
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
logger.info("Starting Whisper transcription on: %s", local_path)
|
| 288 |
+
result = model.transcribe(local_path)
|
| 289 |
+
text = result.get("text", "").strip()
|
| 290 |
+
language = result.get("language", "unknown")
|
| 291 |
+
logger.info("Transcription complete. Language: %s, Length: %d chars", language, len(text))
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
"text": text,
|
| 295 |
+
"language": language,
|
| 296 |
+
"success": True,
|
| 297 |
+
"error": None,
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
except Exception as e:
|
| 301 |
+
error_msg = str(e)
|
| 302 |
+
logger.exception("Transcription failed for source: %s", audio_source)
|
| 303 |
+
|
| 304 |
+
# Check for common ffmpeg error
|
| 305 |
+
if "ffmpeg" in error_msg.lower():
|
| 306 |
+
error_msg = (
|
| 307 |
+
"ffmpeg is not installed or not found in PATH. "
|
| 308 |
+
"Please install ffmpeg: https://ffmpeg.org/download.html"
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
"text": "",
|
| 313 |
+
"language": "",
|
| 314 |
+
"success": False,
|
| 315 |
+
"error": error_msg,
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
finally:
|
| 319 |
+
# Clean up temp file only if we downloaded it
|
| 320 |
+
if is_url and local_path and os.path.exists(local_path):
|
| 321 |
+
try:
|
| 322 |
+
os.remove(local_path)
|
| 323 |
+
logger.info("Cleaned up temp file: %s", local_path)
|
| 324 |
+
except Exception:
|
| 325 |
+
pass
|
db.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
db.py
|
| 3 |
+
-----
|
| 4 |
+
Handles MongoDB connection and operations for HireScope AI.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
from pymongo import MongoClient
|
| 10 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
from bson.objectid import ObjectId
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Initialize MongoDB Connection
|
| 19 |
+
MONGO_URI = os.getenv("MONGO_URI", "")
|
| 20 |
+
if not MONGO_URI:
|
| 21 |
+
logger.error("MONGO_URI environment variable is not set!")
|
| 22 |
+
raise RuntimeError("MONGO_URI environment variable is required. Set it in your .env file.")
|
| 23 |
+
|
| 24 |
+
client = MongoClient(MONGO_URI)
|
| 25 |
+
|
| 26 |
+
# Database Name
|
| 27 |
+
db = client.get_database("resume_screener")
|
| 28 |
+
|
| 29 |
+
# Collections
|
| 30 |
+
users_collection = db.get_collection("users")
|
| 31 |
+
jobs_collection = db.get_collection("jobs")
|
| 32 |
+
candidates_collection = db.get_collection("candidates")
|
| 33 |
+
|
| 34 |
+
# ── User Operations ──
|
| 35 |
+
|
| 36 |
+
def create_user(username, email, password, role="recruiter"):
|
| 37 |
+
if users_collection.find_one({"email": email}):
|
| 38 |
+
return False, "Email already exists"
|
| 39 |
+
|
| 40 |
+
hashed_password = generate_password_hash(password)
|
| 41 |
+
user_data = {
|
| 42 |
+
"username": username,
|
| 43 |
+
"email": email,
|
| 44 |
+
"password": hashed_password,
|
| 45 |
+
"role": role,
|
| 46 |
+
"created_at": datetime.utcnow()
|
| 47 |
+
}
|
| 48 |
+
users_collection.insert_one(user_data)
|
| 49 |
+
return True, "User created successfully"
|
| 50 |
+
|
| 51 |
+
def authenticate_user(email, password):
|
| 52 |
+
user = users_collection.find_one({"email": email})
|
| 53 |
+
if user and check_password_hash(user["password"], password):
|
| 54 |
+
# Don't return the password hash in the user object
|
| 55 |
+
user['_id'] = str(user['_id'])
|
| 56 |
+
del user['password']
|
| 57 |
+
return user
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
# ── Candidate Operations ──
|
| 61 |
+
|
| 62 |
+
def insert_candidate(candidate_data):
|
| 63 |
+
"""
|
| 64 |
+
Candidate data should include name, filename, text, skills, match_score, etc.
|
| 65 |
+
"""
|
| 66 |
+
candidate_data["created_at"] = datetime.utcnow()
|
| 67 |
+
result = candidates_collection.insert_one(candidate_data)
|
| 68 |
+
return str(result.inserted_id)
|
| 69 |
+
|
| 70 |
+
def get_all_candidates():
|
| 71 |
+
candidates = list(candidates_collection.find().sort("match_score", -1))
|
| 72 |
+
for c in candidates:
|
| 73 |
+
c['_id'] = str(c['_id'])
|
| 74 |
+
return candidates
|
| 75 |
+
|
| 76 |
+
def get_candidate_by_id(candidate_id):
|
| 77 |
+
try:
|
| 78 |
+
candidate = candidates_collection.find_one({"_id": ObjectId(candidate_id)})
|
| 79 |
+
if candidate:
|
| 80 |
+
candidate['_id'] = str(candidate['_id'])
|
| 81 |
+
return candidate
|
| 82 |
+
except Exception:
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
def _candidate_filter(candidate_id):
|
| 86 |
+
return {"_id": ObjectId(candidate_id)}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def set_candidate_audio_processing(candidate_id, audio_url=None):
|
| 90 |
+
candidates_collection.update_one(
|
| 91 |
+
_candidate_filter(candidate_id),
|
| 92 |
+
{
|
| 93 |
+
"$set": {
|
| 94 |
+
"audio_transcription": {
|
| 95 |
+
"status": "processing",
|
| 96 |
+
"text": "",
|
| 97 |
+
"language": "",
|
| 98 |
+
"error": None,
|
| 99 |
+
"audio_url": audio_url,
|
| 100 |
+
"updated_at": datetime.utcnow(),
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def update_candidate_audio(candidate_id, audio_text, language, audio_url=None):
|
| 108 |
+
candidates_collection.update_one(
|
| 109 |
+
_candidate_filter(candidate_id),
|
| 110 |
+
{
|
| 111 |
+
"$set": {
|
| 112 |
+
"audio_transcription": {
|
| 113 |
+
"status": "completed",
|
| 114 |
+
"text": audio_text,
|
| 115 |
+
"language": language,
|
| 116 |
+
"error": None,
|
| 117 |
+
"audio_url": audio_url,
|
| 118 |
+
"updated_at": datetime.utcnow(),
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def update_candidate_audio_error(candidate_id, error_msg, audio_url=None):
|
| 126 |
+
candidates_collection.update_one(
|
| 127 |
+
_candidate_filter(candidate_id),
|
| 128 |
+
{
|
| 129 |
+
"$set": {
|
| 130 |
+
"audio_transcription": {
|
| 131 |
+
"status": "failed",
|
| 132 |
+
"text": "",
|
| 133 |
+
"language": "",
|
| 134 |
+
"error": error_msg,
|
| 135 |
+
"audio_url": audio_url,
|
| 136 |
+
"updated_at": datetime.utcnow(),
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
def clear_all_candidates():
|
| 143 |
+
candidates_collection.delete_many({})
|
job_matcher.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
job_matcher.py
|
| 3 |
+
--------------
|
| 4 |
+
Matches resumes to job descriptions using Advanced NLP embeddings (sentence-transformers)
|
| 5 |
+
and performs Exact Skill Overlap measurement. Also provides precise skill gap analysis.
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
from sentence_transformers import SentenceTransformer, util
|
| 10 |
+
|
| 11 |
+
# Load the model once when the file is imported
|
| 12 |
+
# This will download ~90MB on first run.
|
| 13 |
+
print("Loading sentence-transformers model...")
|
| 14 |
+
model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 15 |
+
|
| 16 |
+
def calculate_match_score(resume_text, job_description, resume_skills=None, jd_skills=None):
|
| 17 |
+
"""
|
| 18 |
+
Calculate how well a resume matches a job description using a Hybrid Score:
|
| 19 |
+
50% Sentence Embeddings (Semantic Match) + 50% Exact Skill Overlap.
|
| 20 |
+
"""
|
| 21 |
+
if not resume_text.strip() or not job_description.strip():
|
| 22 |
+
return 0.0
|
| 23 |
+
|
| 24 |
+
# 1. Compute Semantic Score (Cosine Similarity)
|
| 25 |
+
resume_emb = model.encode(resume_text, convert_to_tensor=True)
|
| 26 |
+
job_emb = model.encode(job_description, convert_to_tensor=True)
|
| 27 |
+
cosine_scores = util.cos_sim(resume_emb, job_emb)
|
| 28 |
+
semantic_score = max(0.0, float(cosine_scores[0][0])) * 100
|
| 29 |
+
|
| 30 |
+
# 2. Compute Exact Overlap Score
|
| 31 |
+
overlap_score = 0.0
|
| 32 |
+
if jd_skills is not None and resume_skills is not None:
|
| 33 |
+
jd_skills_lower = [s.lower() for s in jd_skills]
|
| 34 |
+
resume_skills_lower = [s.lower() for s in resume_skills]
|
| 35 |
+
if len(jd_skills_lower) > 0:
|
| 36 |
+
matched_skills = [s for s in jd_skills_lower if s in resume_skills_lower]
|
| 37 |
+
overlap_score = (len(matched_skills) / len(jd_skills_lower)) * 100
|
| 38 |
+
|
| 39 |
+
# Hybrid Approach: Average of semantic and exact overlap
|
| 40 |
+
final_score = (semantic_score + overlap_score) / 2
|
| 41 |
+
else:
|
| 42 |
+
final_score = semantic_score
|
| 43 |
+
|
| 44 |
+
return round(final_score, 1)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def find_skill_gaps(resume_skills, job_description, all_skills_list=None, jd_skills=None):
|
| 48 |
+
"""
|
| 49 |
+
Compare resume skills against skills mentioned in the job description using precise word boundaries.
|
| 50 |
+
"""
|
| 51 |
+
job_lower = job_description.lower()
|
| 52 |
+
resume_skills_lower = [s.lower() for s in resume_skills]
|
| 53 |
+
|
| 54 |
+
# If jd_skills are explicitly provided, use them
|
| 55 |
+
if jd_skills is not None:
|
| 56 |
+
job_skills = jd_skills
|
| 57 |
+
# Otherwise fallback to regex matching against the all_skills_list
|
| 58 |
+
elif all_skills_list:
|
| 59 |
+
job_skills = []
|
| 60 |
+
for skill in all_skills_list:
|
| 61 |
+
pattern = r"(?<![a-z0-9])" + re.escape(skill.lower()) + r"(?![a-z0-9])"
|
| 62 |
+
if re.search(pattern, job_lower):
|
| 63 |
+
job_skills.append(skill)
|
| 64 |
+
else:
|
| 65 |
+
job_skills = []
|
| 66 |
+
|
| 67 |
+
matched = [s for s in job_skills if s.lower() in resume_skills_lower]
|
| 68 |
+
missing = [s for s in job_skills if s.lower() not in resume_skills_lower]
|
| 69 |
+
|
| 70 |
+
return {
|
| 71 |
+
"matched": matched,
|
| 72 |
+
"missing": missing,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def rank_candidates(candidates, job_description, jd_skills=None):
|
| 77 |
+
"""
|
| 78 |
+
Rank multiple candidates based on their semantic and exact skill match to a job description.
|
| 79 |
+
"""
|
| 80 |
+
ranked = []
|
| 81 |
+
|
| 82 |
+
if not job_description.strip():
|
| 83 |
+
return sorted(candidates, key=lambda x: x.get("match_score", 0), reverse=True)
|
| 84 |
+
|
| 85 |
+
# Pre-encode the JD once
|
| 86 |
+
job_emb = model.encode(job_description, convert_to_tensor=True)
|
| 87 |
+
|
| 88 |
+
jd_skills_lower = []
|
| 89 |
+
if jd_skills:
|
| 90 |
+
jd_skills_lower = [s.lower() for s in jd_skills]
|
| 91 |
+
|
| 92 |
+
for candidate in candidates:
|
| 93 |
+
resume_emb = model.encode(candidate["resume_text"], convert_to_tensor=True)
|
| 94 |
+
cosine_scores = util.cos_sim(resume_emb, job_emb)
|
| 95 |
+
semantic_score = max(0.0, float(cosine_scores[0][0])) * 100
|
| 96 |
+
|
| 97 |
+
# Calculate overlap score
|
| 98 |
+
overlap_score = 0.0
|
| 99 |
+
resume_skills = candidate.get("skills", [])
|
| 100 |
+
resume_skills_lower = [s.lower() for s in resume_skills]
|
| 101 |
+
|
| 102 |
+
if jd_skills_lower:
|
| 103 |
+
matched_skills = [s for s in jd_skills_lower if s in resume_skills_lower]
|
| 104 |
+
overlap_score = (len(matched_skills) / len(jd_skills_lower)) * 100
|
| 105 |
+
final_score = (semantic_score + overlap_score) / 2
|
| 106 |
+
else:
|
| 107 |
+
final_score = semantic_score
|
| 108 |
+
|
| 109 |
+
score = round(final_score, 1)
|
| 110 |
+
|
| 111 |
+
ranked.append({
|
| 112 |
+
"_id": candidate["_id"],
|
| 113 |
+
"name": candidate["name"],
|
| 114 |
+
"match_score": score,
|
| 115 |
+
"skill_count": len(resume_skills),
|
| 116 |
+
"skills": resume_skills,
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
# Sort by match score descending, then by skill count descending
|
| 120 |
+
ranked.sort(key=lambda x: (x["match_score"], x["skill_count"]), reverse=True)
|
| 121 |
+
return ranked
|
| 122 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==3.1.*
|
| 2 |
+
PyPDF2>=3.0
|
| 3 |
+
python-docx>=1.0
|
| 4 |
+
scikit-learn>=1.4
|
| 5 |
+
nltk>=3.9
|
| 6 |
+
openai-whisper>=20231117
|
| 7 |
+
pymongo>=4.6
|
| 8 |
+
sentence-transformers>=2.5
|
| 9 |
+
Werkzeug>=3.0
|
| 10 |
+
Flask-Session>=0.5
|
| 11 |
+
google-generativeai>=0.8
|
| 12 |
+
python-dotenv>=1.0.1
|
| 13 |
+
cloudinary>=1.40.0
|
| 14 |
+
gunicorn>=21.2.0
|
| 15 |
+
requests>=2.31
|
resume_parser.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
resume_parser.py
|
| 3 |
+
----------------
|
| 4 |
+
Handles extracting text from PDF and DOCX resume files,
|
| 5 |
+
extracting candidate name, phone, email, and
|
| 6 |
+
cleaning the raw text for further processing.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
import os
|
| 11 |
+
from PyPDF2 import PdfReader
|
| 12 |
+
from docx import Document
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def extract_text_from_pdf(filepath):
|
| 16 |
+
"""
|
| 17 |
+
Extract all text from a PDF file.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
filepath (str): Path to the PDF file.
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
str: Extracted text from all pages.
|
| 24 |
+
"""
|
| 25 |
+
text = ""
|
| 26 |
+
try:
|
| 27 |
+
reader = PdfReader(filepath)
|
| 28 |
+
for page in reader.pages:
|
| 29 |
+
page_text = page.extract_text()
|
| 30 |
+
if page_text:
|
| 31 |
+
text += page_text + "\n"
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"[ERROR] Failed to read PDF: {e}")
|
| 34 |
+
return text
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def extract_text_from_docx(filepath):
|
| 38 |
+
"""
|
| 39 |
+
Extract all text from a DOCX file, including tables.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
filepath (str): Path to the DOCX file.
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
str: Extracted text from all paragraphs and tables.
|
| 46 |
+
"""
|
| 47 |
+
text = ""
|
| 48 |
+
try:
|
| 49 |
+
doc = Document(filepath)
|
| 50 |
+
|
| 51 |
+
# Extract paragraphs
|
| 52 |
+
for para in doc.paragraphs:
|
| 53 |
+
text += para.text + "\n"
|
| 54 |
+
|
| 55 |
+
# Extract text from tables (e.g. skills in tabular format)
|
| 56 |
+
for table in doc.tables:
|
| 57 |
+
for row in table.rows:
|
| 58 |
+
row_text = " | ".join(cell.text.strip() for cell in row.cells if cell.text.strip())
|
| 59 |
+
if row_text:
|
| 60 |
+
text += row_text + "\n"
|
| 61 |
+
except Exception as e:
|
| 62 |
+
print(f"[ERROR] Failed to read DOCX: {e}")
|
| 63 |
+
return text
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def extract_text(filepath):
|
| 67 |
+
"""
|
| 68 |
+
Detect file type and extract text accordingly.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
filepath (str): Path to a PDF or DOCX file.
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
str: Extracted raw text.
|
| 75 |
+
|
| 76 |
+
Raises:
|
| 77 |
+
ValueError: If the file format is not supported.
|
| 78 |
+
"""
|
| 79 |
+
ext = os.path.splitext(filepath)[1].lower()
|
| 80 |
+
|
| 81 |
+
if ext == ".pdf":
|
| 82 |
+
return extract_text_from_pdf(filepath)
|
| 83 |
+
elif ext == ".docx":
|
| 84 |
+
return extract_text_from_docx(filepath)
|
| 85 |
+
else:
|
| 86 |
+
raise ValueError(f"Unsupported file format: {ext}. Use PDF or DOCX.")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def extract_email(raw_text):
|
| 90 |
+
"""
|
| 91 |
+
Extract email addresses from resume text.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
raw_text (str): The raw extracted text.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
str: First email found, or empty string.
|
| 98 |
+
"""
|
| 99 |
+
pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
|
| 100 |
+
emails = re.findall(pattern, raw_text)
|
| 101 |
+
return emails[0] if emails else ""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def extract_phone(raw_text):
|
| 105 |
+
"""
|
| 106 |
+
Extract phone numbers from resume text.
|
| 107 |
+
Supports Indian (+91), US (+1), and international formats.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
raw_text (str): The raw extracted text.
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
str: First phone number found, or empty string.
|
| 114 |
+
"""
|
| 115 |
+
patterns = [
|
| 116 |
+
r'(?:\+91[\s-]?)?[6-9]\d{4}[\s-]?\d{5}', # Indian: +91 98765 43210
|
| 117 |
+
r'(?:\+1[\s-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}', # US: (555) 123-4567
|
| 118 |
+
r'\+?\d{1,3}[\s.-]?\d{3,4}[\s.-]?\d{3,4}[\s.-]?\d{0,4}', # International
|
| 119 |
+
]
|
| 120 |
+
for pattern in patterns:
|
| 121 |
+
phones = re.findall(pattern, raw_text)
|
| 122 |
+
if phones:
|
| 123 |
+
# Return the longest match (most likely a real phone number)
|
| 124 |
+
return max(phones, key=len).strip()
|
| 125 |
+
return ""
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def extract_candidate_name(raw_text):
|
| 129 |
+
"""
|
| 130 |
+
Attempt to extract the candidate's name from the first few lines of the resume.
|
| 131 |
+
Usually the first non-empty, non-email, non-phone line is the name.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
raw_text (str): The raw extracted text.
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
str: Candidate name or empty string.
|
| 138 |
+
"""
|
| 139 |
+
lines = raw_text.strip().split("\n")
|
| 140 |
+
for line in lines[:5]: # Check first 5 lines
|
| 141 |
+
line = line.strip()
|
| 142 |
+
if not line:
|
| 143 |
+
continue
|
| 144 |
+
# Skip if it's an email
|
| 145 |
+
if re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', line):
|
| 146 |
+
continue
|
| 147 |
+
# Skip if it's a phone number
|
| 148 |
+
if re.search(r'[\+]?\d[\d\s\-\(\)]{7,}', line):
|
| 149 |
+
continue
|
| 150 |
+
# Skip common headers
|
| 151 |
+
skip_words = ["resume", "curriculum vitae", "cv", "objective", "summary", "profile"]
|
| 152 |
+
if line.lower().strip() in skip_words:
|
| 153 |
+
continue
|
| 154 |
+
# If line is short and contains mostly letters, it's likely a name
|
| 155 |
+
if len(line) < 60 and re.match(r'^[A-Za-z\s\.\-]+$', line):
|
| 156 |
+
return line.title()
|
| 157 |
+
return ""
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def clean_text(raw_text):
|
| 161 |
+
"""
|
| 162 |
+
Clean and normalize extracted text.
|
| 163 |
+
|
| 164 |
+
Steps:
|
| 165 |
+
1. Convert to lowercase
|
| 166 |
+
2. Remove URLs
|
| 167 |
+
3. Remove email addresses
|
| 168 |
+
4. Remove special characters (keep letters, numbers, spaces, and +, #, -, ., /)
|
| 169 |
+
5. Collapse multiple spaces into one
|
| 170 |
+
6. Strip leading/trailing whitespace
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
raw_text (str): The raw extracted text.
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
str: Cleaned text ready for NLP processing.
|
| 177 |
+
"""
|
| 178 |
+
text = raw_text.lower()
|
| 179 |
+
|
| 180 |
+
# Remove URLs
|
| 181 |
+
text = re.sub(r"http\S+|www\.\S+", "", text)
|
| 182 |
+
|
| 183 |
+
# Remove email addresses
|
| 184 |
+
text = re.sub(r"\S+@\S+\.\S+", "", text)
|
| 185 |
+
|
| 186 |
+
# Remove special characters but keep letters, numbers, spaces, and specific symbols (+, #, -, ., /)
|
| 187 |
+
text = re.sub(r"[^a-z0-9\s\+\#\-\.\/]", " ", text)
|
| 188 |
+
|
| 189 |
+
# Collapse multiple spaces
|
| 190 |
+
text = re.sub(r"\s+", " ", text)
|
| 191 |
+
|
| 192 |
+
return text.strip()
|
skill_extractor.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
skill_extractor.py (Production v5.0 - BTech All Roles)
|
| 3 |
+
-------------------------------------------------------
|
| 4 |
+
Advanced Resume Skill Extractor:
|
| 5 |
+
- Covers ALL common BTech career roles
|
| 6 |
+
- Regex-based extraction (word-boundary safe)
|
| 7 |
+
- Role-wise organized skill database
|
| 8 |
+
- Smart normalization + inferred skills
|
| 9 |
+
- Clean & deduplicated output
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ─────────────────────────────────────────────
|
| 16 |
+
# 🔹 SKILLS DATABASE — BTech ALL ROLES
|
| 17 |
+
# ─────────────────────────────────────────────
|
| 18 |
+
|
| 19 |
+
SKILLS_LIST = [
|
| 20 |
+
|
| 21 |
+
# ── 1. SOFTWARE DEVELOPMENT ───────────────
|
| 22 |
+
"python", "java", "javascript", "typescript", "c", "c++", "c#",
|
| 23 |
+
"ruby", "php", "swift", "kotlin", "go", "rust", "scala", "r", "matlab",
|
| 24 |
+
"html", "css", "react", "react.js", "next.js", "angular", "vue",
|
| 25 |
+
"redux", "tailwind css", "framer motion",
|
| 26 |
+
"node.js", "nodejs", "express", "express.js",
|
| 27 |
+
"django", "flask", "fastapi", "spring boot",
|
| 28 |
+
"rest api", "rest apis", "websockets", "graphql",
|
| 29 |
+
"jwt", "oauth", "authentication", "authorization",
|
| 30 |
+
"mvc", "state management", "api design",
|
| 31 |
+
"prisma", "mongoose",
|
| 32 |
+
"vercel", "netlify",
|
| 33 |
+
"frontend development", "backend development", "full stack", "web development",
|
| 34 |
+
|
| 35 |
+
# ── 2. DATA SCIENCE / ML / AI ─────────────
|
| 36 |
+
"machine learning", "deep learning", "data science", "data analysis",
|
| 37 |
+
"tensorflow", "pytorch", "keras", "scikit-learn",
|
| 38 |
+
"pandas", "numpy", "matplotlib", "seaborn", "scipy",
|
| 39 |
+
"nlp", "natural language processing", "computer vision",
|
| 40 |
+
"opencv", "hugging face", "llm", "generative ai", "prompt engineering",
|
| 41 |
+
"feature engineering", "model deployment", "mlops",
|
| 42 |
+
"regression", "classification", "clustering", "neural network",
|
| 43 |
+
"random forest", "xgboost", "time series",
|
| 44 |
+
"artificial intelligence", "chatgpt", "copilot",
|
| 45 |
+
|
| 46 |
+
# ── 3. DATA ENGINEERING / ANALYTICS ───────
|
| 47 |
+
"sql", "mysql", "postgresql", "mongodb", "redis", "firebase",
|
| 48 |
+
"oracle", "sqlite", "dynamodb", "cassandra",
|
| 49 |
+
"power bi", "tableau", "excel", "google sheets",
|
| 50 |
+
"etl", "data pipeline", "apache spark", "hadoop", "kafka",
|
| 51 |
+
"airflow", "dbt", "snowflake", "bigquery", "data warehouse",
|
| 52 |
+
"data visualization", "business intelligence",
|
| 53 |
+
|
| 54 |
+
# ── 4. DEVOPS / CLOUD ─────────────────────
|
| 55 |
+
"aws", "azure", "gcp", "docker", "kubernetes", "jenkins",
|
| 56 |
+
"terraform", "ansible", "ci/cd", "linux", "bash", "shell scripting",
|
| 57 |
+
"git", "github", "gitlab", "bitbucket",
|
| 58 |
+
"nginx", "apache", "load balancing", "microservices",
|
| 59 |
+
"serverless", "lambda", "cloud computing",
|
| 60 |
+
|
| 61 |
+
# ── 5. CYBERSECURITY ──────────────────────
|
| 62 |
+
"network security", "ethical hacking", "penetration testing",
|
| 63 |
+
"kali linux", "metasploit", "wireshark", "nmap", "burp suite",
|
| 64 |
+
"cryptography", "ssl", "tls", "firewall", "ids", "ips",
|
| 65 |
+
"siem", "soc", "vulnerability assessment", "owasp",
|
| 66 |
+
"digital forensics", "malware analysis", "incident response",
|
| 67 |
+
"information security", "cyber security",
|
| 68 |
+
|
| 69 |
+
# ── 6. EMBEDDED SYSTEMS / IOT ─────────────
|
| 70 |
+
"embedded c", "arduino", "raspberry pi", "stm32", "esp32",
|
| 71 |
+
"rtos", "freertos", "uart", "spi", "i2c", "can bus",
|
| 72 |
+
"iot", "mqtt", "zigbee", "bluetooth", "wifi module",
|
| 73 |
+
"pcb design", "kicad", "altium", "proteus", "multisim",
|
| 74 |
+
"microcontroller", "microprocessor", "fpga", "vhdl", "verilog",
|
| 75 |
+
"signal processing", "sensor integration",
|
| 76 |
+
|
| 77 |
+
# ── 7. VLSI / CHIP DESIGN ─────────────────
|
| 78 |
+
"vlsi", "verilog", "vhdl", "system verilog",
|
| 79 |
+
"cadence", "synopsys", "mentor graphics",
|
| 80 |
+
"rtl design", "synthesis", "sta", "place and route",
|
| 81 |
+
"digital design", "analog design", "asic", "soc design",
|
| 82 |
+
"dft", "timing analysis", "power analysis",
|
| 83 |
+
|
| 84 |
+
# ── 8. MECHANICAL ENGINEERING ─────────────
|
| 85 |
+
"autocad", "solidworks", "catia", "ansys", "creo",
|
| 86 |
+
"fusion 360", "3d printing", "additive manufacturing",
|
| 87 |
+
"cad", "cam", "cfd", "fea", "finite element analysis",
|
| 88 |
+
"manufacturing processes", "cnc machining",
|
| 89 |
+
"thermodynamics", "fluid mechanics", "heat transfer",
|
| 90 |
+
"robotics", "automation", "plc", "scada", "hmi",
|
| 91 |
+
"lean manufacturing", "six sigma", "quality control",
|
| 92 |
+
|
| 93 |
+
# ── 9. CIVIL ENGINEERING ──────────────────
|
| 94 |
+
"autocad civil", "staad pro", "etabs", "revit", "primavera",
|
| 95 |
+
"ms project", "civil 3d",
|
| 96 |
+
"structural analysis", "structural design", "rcc design",
|
| 97 |
+
"surveying", "gis", "remote sensing", "arcgis",
|
| 98 |
+
"construction management", "project planning",
|
| 99 |
+
"soil mechanics", "geotechnical", "foundation design",
|
| 100 |
+
"highway design", "transportation engineering",
|
| 101 |
+
"water supply", "sanitation", "irrigation",
|
| 102 |
+
|
| 103 |
+
# ── 10. ELECTRICAL ENGINEERING ────────────
|
| 104 |
+
"power systems", "power electronics", "circuit design",
|
| 105 |
+
"matlab simulink", "pspice", "ltspice", "labview",
|
| 106 |
+
"electric vehicles", "battery management system",
|
| 107 |
+
"solar energy", "wind energy", "renewable energy",
|
| 108 |
+
"transformer", "motor drives", "inverter", "rectifier",
|
| 109 |
+
"control systems", "pid controller",
|
| 110 |
+
"high voltage", "switchgear", "protection relay",
|
| 111 |
+
"smart grid", "energy audit",
|
| 112 |
+
|
| 113 |
+
# ── 11. ELECTRONICS & COMMUNICATION ───────
|
| 114 |
+
"signal processing", "dsp", "image processing",
|
| 115 |
+
"communication systems", "wireless communication",
|
| 116 |
+
"5g", "lte", "antenna design", "rf design",
|
| 117 |
+
"hfss", "cst", "ads",
|
| 118 |
+
"optical fiber", "photonics",
|
| 119 |
+
"digital electronics", "analog electronics",
|
| 120 |
+
"oscilloscope", "logic analyzer",
|
| 121 |
+
|
| 122 |
+
# ── 12. ROBOTICS / AUTOMATION ─────────────
|
| 123 |
+
"ros", "ros2", "gazebo", "slam", "path planning",
|
| 124 |
+
"sensor fusion", "robotic arm", "drone", "uav",
|
| 125 |
+
"autonomous systems", "control theory", "motion planning",
|
| 126 |
+
"industrial automation", "plc programming",
|
| 127 |
+
|
| 128 |
+
# ── 13. PRODUCT MANAGEMENT (Tech) ─────────
|
| 129 |
+
"product roadmap", "user stories", "agile", "scrum", "kanban",
|
| 130 |
+
"jira", "confluence", "notion", "trello",
|
| 131 |
+
"wireframing", "prototyping", "figma", "balsamiq",
|
| 132 |
+
"market research", "competitive analysis",
|
| 133 |
+
"a/b testing", "product analytics", "kpi tracking",
|
| 134 |
+
"stakeholder management", "go to market",
|
| 135 |
+
|
| 136 |
+
# ── 14. UI/UX DESIGN ──────────────────────
|
| 137 |
+
"figma", "adobe xd", "sketch", "invision",
|
| 138 |
+
"photoshop", "illustrator", "after effects",
|
| 139 |
+
"user research", "usability testing",
|
| 140 |
+
"design thinking", "information architecture",
|
| 141 |
+
"interaction design", "visual design", "typography",
|
| 142 |
+
"color theory", "responsive design", "accessibility",
|
| 143 |
+
|
| 144 |
+
# ── 15. BUSINESS ANALYST / CONSULTING ─────
|
| 145 |
+
"requirement gathering", "brd", "frd", "use case",
|
| 146 |
+
"uml", "flowchart", "process mapping", "gap analysis",
|
| 147 |
+
"business analysis", "functional testing", "uat",
|
| 148 |
+
|
| 149 |
+
# ── 16. TESTING / QA ──────────────────────
|
| 150 |
+
"manual testing", "automation testing", "selenium",
|
| 151 |
+
"cypress", "playwright", "jest", "pytest",
|
| 152 |
+
"test cases", "test plan", "bug tracking",
|
| 153 |
+
"api testing", "postman", "jmeter", "load testing",
|
| 154 |
+
"performance testing", "regression testing",
|
| 155 |
+
"black box testing", "white box testing",
|
| 156 |
+
|
| 157 |
+
# ── 17. GAME DEVELOPMENT ──────────────────
|
| 158 |
+
"unity", "unreal engine", "godot",
|
| 159 |
+
"game design", "level design", "3d modeling",
|
| 160 |
+
"blender", "maya", "3ds max",
|
| 161 |
+
"ar", "vr", "mixed reality", "xr",
|
| 162 |
+
"physics simulation", "shader programming",
|
| 163 |
+
|
| 164 |
+
# ── 18. BLOCKCHAIN ────────────────────────
|
| 165 |
+
"blockchain", "solidity", "ethereum", "web3.js", "ethers.js",
|
| 166 |
+
"smart contracts", "nft", "defi", "hyperledger",
|
| 167 |
+
"cryptocurrency", "metamask", "truffle", "hardhat",
|
| 168 |
+
"ipfs", "decentralized applications", "dapps",
|
| 169 |
+
|
| 170 |
+
# ── 19. SOFT SKILLS ───────────────────────
|
| 171 |
+
"communication", "leadership", "teamwork", "problem solving",
|
| 172 |
+
"project management", "critical thinking",
|
| 173 |
+
"time management", "presentation", "collaboration",
|
| 174 |
+
"analytical thinking", "attention to detail",
|
| 175 |
+
|
| 176 |
+
# ── 20. MOBILE DEVELOPMENT ────────────────
|
| 177 |
+
"react native", "flutter", "dart", "swiftui", "jetpack compose",
|
| 178 |
+
"android development", "ios development", "mobile development",
|
| 179 |
+
"xcode", "android studio", "expo",
|
| 180 |
+
|
| 181 |
+
# ── 21. DATA / MODERN TOOLS ───────────────
|
| 182 |
+
"streamlit", "gradio", "langchain", "llamaindex", "pinecone",
|
| 183 |
+
"chromadb", "weaviate", "vector database", "rag",
|
| 184 |
+
"data lake", "lakehouse", "delta lake", "databricks",
|
| 185 |
+
"looker", "metabase", "superset",
|
| 186 |
+
|
| 187 |
+
# ── 22. CLOUD CERTIFICATIONS & SERVICES ───
|
| 188 |
+
"aws lambda", "s3", "ec2", "ecs", "eks", "cloudfront",
|
| 189 |
+
"azure devops", "azure functions", "cosmos db",
|
| 190 |
+
"google cloud functions", "cloud run", "vertex ai",
|
| 191 |
+
"heroku", "railway", "render", "fly.io",
|
| 192 |
+
|
| 193 |
+
# ── 23. API & ARCHITECTURE ────────────────
|
| 194 |
+
"grpc", "soap", "swagger", "openapi",
|
| 195 |
+
"event driven", "message queue", "rabbitmq",
|
| 196 |
+
"design patterns", "solid principles", "clean architecture",
|
| 197 |
+
"domain driven design", "system design",
|
| 198 |
+
|
| 199 |
+
# ── 24. VERSION CONTROL & CI/CD ──────────
|
| 200 |
+
"github actions", "circleci", "travis ci", "argo cd",
|
| 201 |
+
"helm", "prometheus", "grafana", "elk stack",
|
| 202 |
+
"datadog", "new relic", "splunk",
|
| 203 |
+
]
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ──────────────────────────���──────────────────
|
| 207 |
+
# 🔥 SKILL NORMALIZATION MAP
|
| 208 |
+
# ─────────────────────────────────────────────
|
| 209 |
+
|
| 210 |
+
SKILL_MAP = {
|
| 211 |
+
# Variations
|
| 212 |
+
"react": ["react.js"],
|
| 213 |
+
"node.js": ["nodejs"],
|
| 214 |
+
"express": ["express.js"],
|
| 215 |
+
"rest api": ["rest apis"],
|
| 216 |
+
"html": ["html5"],
|
| 217 |
+
"css": ["css3"],
|
| 218 |
+
|
| 219 |
+
# Software Dev
|
| 220 |
+
"full stack": ["mern", "fullstack", "mean"],
|
| 221 |
+
"authentication": ["jwt", "oauth", "auth"],
|
| 222 |
+
"frontend development": ["react", "next.js", "angular", "vue", "html", "css"],
|
| 223 |
+
"backend development": ["node.js", "express", "django", "flask", "fastapi", "spring boot"],
|
| 224 |
+
"version control": ["git", "github", "gitlab"],
|
| 225 |
+
"database management": ["sql", "mysql", "postgresql", "mongodb", "redis", "firebase"],
|
| 226 |
+
|
| 227 |
+
# AI / ML
|
| 228 |
+
"deep learning": ["tensorflow", "keras", "pytorch"],
|
| 229 |
+
"data science": ["pandas", "numpy", "data analysis", "machine learning"],
|
| 230 |
+
"nlp": ["llm", "natural language processing", "text classification", "tokenization", "hugging face"],
|
| 231 |
+
"machine learning": ["ml", "scikit-learn", "model training", "regression", "classification", "xgboost"],
|
| 232 |
+
"artificial intelligence": ["ai", "neural network", "deep learning", "machine learning", "generative ai"],
|
| 233 |
+
"computer vision": ["image recognition", "object detection", "opencv", "cnn"],
|
| 234 |
+
"mlops": ["model deployment", "mlflow", "kubeflow"],
|
| 235 |
+
"seaborn": ["matplotlib"],
|
| 236 |
+
"classification": ["classify", "classification", "predict", "model training"],
|
| 237 |
+
|
| 238 |
+
# DevOps / Cloud
|
| 239 |
+
"devops": ["ci/cd", "docker", "kubernetes", "jenkins", "terraform", "ansible"],
|
| 240 |
+
"cloud computing": ["aws", "azure", "gcp", "serverless", "lambda"],
|
| 241 |
+
|
| 242 |
+
# Security
|
| 243 |
+
"cyber security": ["ethical hacking", "penetration testing", "network security", "owasp"],
|
| 244 |
+
"ethical hacking": ["kali linux", "metasploit", "burp suite", "nmap"],
|
| 245 |
+
|
| 246 |
+
# Embedded / IoT
|
| 247 |
+
"iot": ["mqtt", "arduino", "raspberry pi", "esp32", "zigbee"],
|
| 248 |
+
"embedded systems": ["embedded c", "rtos", "microcontroller", "stm32", "esp32"],
|
| 249 |
+
|
| 250 |
+
# VLSI
|
| 251 |
+
"vlsi": ["verilog", "vhdl", "system verilog", "rtl design", "asic"],
|
| 252 |
+
|
| 253 |
+
# Mechanical
|
| 254 |
+
"cad": ["autocad", "solidworks", "catia", "creo", "fusion 360"],
|
| 255 |
+
"simulation": ["ansys", "fea", "cfd", "matlab simulink"],
|
| 256 |
+
"automation": ["plc", "scada", "hmi", "industrial automation"],
|
| 257 |
+
"lean manufacturing": ["six sigma", "quality control", "kaizen"],
|
| 258 |
+
|
| 259 |
+
# Civil
|
| 260 |
+
"structural design": ["staad pro", "etabs", "rcc design"],
|
| 261 |
+
"gis": ["arcgis", "remote sensing", "civil 3d"],
|
| 262 |
+
|
| 263 |
+
# Electrical
|
| 264 |
+
"power electronics": ["inverter", "rectifier", "motor drives", "battery management system"],
|
| 265 |
+
"renewable energy": ["solar energy", "wind energy", "electric vehicles"],
|
| 266 |
+
"control systems": ["pid controller", "matlab simulink", "labview"],
|
| 267 |
+
|
| 268 |
+
# Robotics
|
| 269 |
+
"robotics": ["ros", "ros2", "slam", "path planning", "robotic arm", "drone"],
|
| 270 |
+
|
| 271 |
+
# Testing
|
| 272 |
+
"automation testing": ["selenium", "cypress", "playwright", "jest", "pytest"],
|
| 273 |
+
"api testing": ["postman", "jmeter"],
|
| 274 |
+
|
| 275 |
+
# Game Dev
|
| 276 |
+
"game development": ["unity", "unreal engine", "godot", "game design"],
|
| 277 |
+
"3d modeling": ["blender", "maya", "3ds max"],
|
| 278 |
+
"vr": ["virtual reality", "oculus", "steamvr"],
|
| 279 |
+
"ar": ["augmented reality", "arkit", "arcore"],
|
| 280 |
+
|
| 281 |
+
# Blockchain
|
| 282 |
+
"blockchain": ["solidity", "ethereum", "smart contracts", "web3.js", "dapps"],
|
| 283 |
+
|
| 284 |
+
# Data / Analytics
|
| 285 |
+
"business intelligence": ["power bi", "tableau", "data visualization"],
|
| 286 |
+
"data pipeline": ["apache spark", "kafka", "airflow", "etl"],
|
| 287 |
+
"data warehouse": ["snowflake", "bigquery", "redshift"],
|
| 288 |
+
|
| 289 |
+
# Mobile
|
| 290 |
+
"mobile development": ["react native", "flutter", "android development", "ios development"],
|
| 291 |
+
"android development": ["kotlin", "jetpack compose", "android studio"],
|
| 292 |
+
"ios development": ["swift", "swiftui", "xcode"],
|
| 293 |
+
|
| 294 |
+
# Modern AI/Data
|
| 295 |
+
"rag": ["langchain", "llamaindex", "vector database", "chromadb", "pinecone"],
|
| 296 |
+
"generative ai": ["llm", "chatgpt", "copilot", "prompt engineering", "rag", "langchain"],
|
| 297 |
+
|
| 298 |
+
# Product / Design
|
| 299 |
+
"agile": ["scrum", "kanban", "jira", "sprint"],
|
| 300 |
+
"ui/ux": ["figma", "adobe xd", "wireframing", "prototyping", "user research"],
|
| 301 |
+
|
| 302 |
+
# Soft skills
|
| 303 |
+
"collaboration": ["collaborated", "teamwork", "team", "worked with"],
|
| 304 |
+
|
| 305 |
+
# Feature engineering
|
| 306 |
+
"feature engineering": [
|
| 307 |
+
"data preprocessing",
|
| 308 |
+
"data cleaning",
|
| 309 |
+
"feature extraction",
|
| 310 |
+
"data transformation"
|
| 311 |
+
],
|
| 312 |
+
|
| 313 |
+
# Teamwork
|
| 314 |
+
"teamwork": [
|
| 315 |
+
"team",
|
| 316 |
+
"collaborated",
|
| 317 |
+
"community",
|
| 318 |
+
"worked with",
|
| 319 |
+
"coordinated"
|
| 320 |
+
],
|
| 321 |
+
|
| 322 |
+
# Analytical thinking
|
| 323 |
+
"analytical thinking": [
|
| 324 |
+
"data analysis",
|
| 325 |
+
"problem solving",
|
| 326 |
+
"analysis",
|
| 327 |
+
"model training"
|
| 328 |
+
]
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ─────────────────────────────────────────────
|
| 333 |
+
# 🔹 EDUCATION KEYWORDS
|
| 334 |
+
# ─────────────────────────────────────────────
|
| 335 |
+
|
| 336 |
+
EDUCATION_KEYWORDS = [
|
| 337 |
+
"bachelor", "master", "phd", "doctorate", "diploma",
|
| 338 |
+
"b.tech", "m.tech", "b.sc", "m.sc", "b.e", "m.e",
|
| 339 |
+
"bca", "mca", "bba", "mba", "b.com", "m.com",
|
| 340 |
+
"university", "college", "institute", "school",
|
| 341 |
+
"computer science", "information technology",
|
| 342 |
+
"engineering", "mathematics", "physics", "chemistry",
|
| 343 |
+
"electronics", "electrical", "mechanical", "civil",
|
| 344 |
+
"degree", "graduation", "post graduation", "certification",
|
| 345 |
+
"12th", "10th", "higher secondary", "secondary",
|
| 346 |
+
"iit", "nit", "iiit", "bits", "vit", "lpu", "amity",
|
| 347 |
+
"cgpa", "gpa", "percentage", "aggregate",
|
| 348 |
+
"coursework", "specialization", "minor", "major",
|
| 349 |
+
"data science", "artificial intelligence",
|
| 350 |
+
"biotechnology", "biomedical", "chemical engineering",
|
| 351 |
+
"aerospace", "automobile", "industrial engineering",
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ─────────────────────────────────────────────
|
| 356 |
+
# 🔹 EXPERIENCE KEYWORDS
|
| 357 |
+
# ─────────────────────────────────────────────
|
| 358 |
+
|
| 359 |
+
EXPERIENCE_KEYWORDS = [
|
| 360 |
+
"experience", "years of experience", "worked at", "working at",
|
| 361 |
+
"intern", "internship", "fresher", "junior", "senior",
|
| 362 |
+
"lead", "manager", "director", "team lead",
|
| 363 |
+
"full time", "part time", "freelance", "contract",
|
| 364 |
+
"responsibilities", "achievements", "projects",
|
| 365 |
+
"developed", "implemented", "designed", "managed",
|
| 366 |
+
"built", "created", "maintained", "optimized",
|
| 367 |
+
"deployed", "architected", "collaborated", "researched",
|
| 368 |
+
"analyzed", "tested", "automated", "integrated",
|
| 369 |
+
"mentored", "supervised", "coordinated", "delivered",
|
| 370 |
+
"contributed", "spearheaded", "launched", "scaled",
|
| 371 |
+
"improved", "reduced", "increased", "streamlined",
|
| 372 |
+
"training", "workshop", "hackathon", "open source",
|
| 373 |
+
"startup", "company", "organization", "firm",
|
| 374 |
+
"software engineer", "data analyst", "web developer",
|
| 375 |
+
"ml engineer", "devops engineer", "full stack developer",
|
| 376 |
+
]
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ─────────────────────────────────────────────
|
| 380 |
+
# 🔹 HELPER FUNCTION
|
| 381 |
+
# ─────────────────────────────────────────────
|
| 382 |
+
|
| 383 |
+
def _match(keyword: str, text: str) -> bool:
|
| 384 |
+
"""Word-boundary safe keyword match."""
|
| 385 |
+
pattern = r"(?<![a-z0-9])" + re.escape(keyword.lower()) + r"(?![a-z0-9])"
|
| 386 |
+
return bool(re.search(pattern, text))
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# ─────────────────────────────────────────────
|
| 390 |
+
# 🔹 SKILL EXTRACTION
|
| 391 |
+
# ─────────────────────────────────────────────
|
| 392 |
+
|
| 393 |
+
def extract_skills(text: str) -> list[str]:
|
| 394 |
+
"""
|
| 395 |
+
Extract skills from resume text using regex keyword matching.
|
| 396 |
+
|
| 397 |
+
Args:
|
| 398 |
+
text (str): Cleaned resume text.
|
| 399 |
+
|
| 400 |
+
Returns:
|
| 401 |
+
list[str]: Skills found in the text.
|
| 402 |
+
"""
|
| 403 |
+
text_lower = text.lower()
|
| 404 |
+
return [skill for skill in SKILLS_LIST if _match(skill, text_lower)]
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# ─────────────────────────────────────────────
|
| 408 |
+
# 🔥 NORMALIZATION
|
| 409 |
+
# ─────────────────────────────────────────────
|
| 410 |
+
|
| 411 |
+
def normalize_skills(skills: list[str], text: str) -> list[str]:
|
| 412 |
+
"""
|
| 413 |
+
Infer higher-level skills from related keywords found in text.
|
| 414 |
+
Example: "tensorflow" found → "deep learning" automatically added.
|
| 415 |
+
|
| 416 |
+
Args:
|
| 417 |
+
skills (list[str]): Already extracted raw skills.
|
| 418 |
+
text (str): Original resume text.
|
| 419 |
+
|
| 420 |
+
Returns:
|
| 421 |
+
list[str]: Expanded, deduplicated, sorted skill list.
|
| 422 |
+
"""
|
| 423 |
+
normalized = set(skills)
|
| 424 |
+
text_lower = text.lower()
|
| 425 |
+
|
| 426 |
+
for main_skill, related_keywords in SKILL_MAP.items():
|
| 427 |
+
for keyword in related_keywords:
|
| 428 |
+
if _match(keyword, text_lower):
|
| 429 |
+
normalized.add(main_skill)
|
| 430 |
+
break
|
| 431 |
+
|
| 432 |
+
return sorted(normalized)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# ────────���────────────────────────────────────
|
| 436 |
+
# 🔹 EDUCATION
|
| 437 |
+
# ─────────────────────────────────────────────
|
| 438 |
+
|
| 439 |
+
def extract_education(text: str) -> list[str]:
|
| 440 |
+
"""
|
| 441 |
+
Find education-related keywords in resume text.
|
| 442 |
+
|
| 443 |
+
Args:
|
| 444 |
+
text (str): Cleaned resume text.
|
| 445 |
+
|
| 446 |
+
Returns:
|
| 447 |
+
list[str]: Education keywords found.
|
| 448 |
+
"""
|
| 449 |
+
text_lower = text.lower()
|
| 450 |
+
return list(set([kw for kw in EDUCATION_KEYWORDS if _match(kw, text_lower)]))
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
# ─────────────────────────────────────────────
|
| 454 |
+
# 🔹 EXPERIENCE
|
| 455 |
+
# ─────────────────────────────────────────────
|
| 456 |
+
|
| 457 |
+
def extract_experience(text: str) -> list[str]:
|
| 458 |
+
"""
|
| 459 |
+
Find experience-related keywords in resume text.
|
| 460 |
+
|
| 461 |
+
Args:
|
| 462 |
+
text (str): Cleaned resume text.
|
| 463 |
+
|
| 464 |
+
Returns:
|
| 465 |
+
list[str]: Experience indicators found.
|
| 466 |
+
"""
|
| 467 |
+
text_lower = text.lower()
|
| 468 |
+
return list(set([kw for kw in EXPERIENCE_KEYWORDS if _match(kw, text_lower)]))
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
# ─────────────────────────────────────────────
|
| 472 |
+
# 🔹 MAIN PIPELINE
|
| 473 |
+
# ─────────────────────────────────────────────
|
| 474 |
+
|
| 475 |
+
def extract_all(text: str) -> dict:
|
| 476 |
+
"""
|
| 477 |
+
Full extraction pipeline:
|
| 478 |
+
1. Extract raw skills via regex
|
| 479 |
+
2. Normalize using SKILL_MAP inference
|
| 480 |
+
3. Extract education and experience
|
| 481 |
+
|
| 482 |
+
Args:
|
| 483 |
+
text (str): Cleaned resume text.
|
| 484 |
+
|
| 485 |
+
Returns:
|
| 486 |
+
dict: {
|
| 487 |
+
"skills": [...],
|
| 488 |
+
"education": [...],
|
| 489 |
+
"experience": [...]
|
| 490 |
+
}
|
| 491 |
+
"""
|
| 492 |
+
raw_skills = extract_skills(text)
|
| 493 |
+
final_skills = normalize_skills(raw_skills, text)
|
| 494 |
+
|
| 495 |
+
return {
|
| 496 |
+
"skills": final_skills,
|
| 497 |
+
"education": extract_education(text),
|
| 498 |
+
"experience": extract_experience(text),
|
| 499 |
+
}
|
static/css/style.css
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* HireScope AI — Custom Styles (supplements Tailwind CDN) */
|
| 2 |
+
|
| 3 |
+
/* Base transitions */
|
| 4 |
+
*, *::before, *::after {
|
| 5 |
+
transition-property: color, background-color, border-color, box-shadow, transform, opacity;
|
| 6 |
+
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
| 7 |
+
transition-duration: 150ms;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/* Smooth scroll */
|
| 11 |
+
html {
|
| 12 |
+
scroll-behavior: smooth;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/* Selection color */
|
| 16 |
+
::selection {
|
| 17 |
+
background: rgba(99, 102, 241, 0.2);
|
| 18 |
+
color: #312e81;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/* Focus ring */
|
| 22 |
+
*:focus-visible {
|
| 23 |
+
outline: 2px solid #6366f1;
|
| 24 |
+
outline-offset: 2px;
|
| 25 |
+
border-radius: 8px;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* Form inputs - remove browser defaults */
|
| 29 |
+
input[type="file"] {
|
| 30 |
+
cursor: pointer;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
input[type="file"]::-webkit-file-upload-button {
|
| 34 |
+
cursor: pointer;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Table row hover transition */
|
| 38 |
+
tbody tr {
|
| 39 |
+
transition: background-color 0.2s ease;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* Modal animation */
|
| 43 |
+
#candidate-modal:not(.hidden) #modal-content {
|
| 44 |
+
animation: modalSlideIn 0.3s ease-out;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@keyframes modalSlideIn {
|
| 48 |
+
from {
|
| 49 |
+
opacity: 0;
|
| 50 |
+
transform: scale(0.95) translateY(10px);
|
| 51 |
+
}
|
| 52 |
+
to {
|
| 53 |
+
opacity: 1;
|
| 54 |
+
transform: scale(1) translateY(0);
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* Loading spinner */
|
| 59 |
+
.spinner {
|
| 60 |
+
border: 2px solid rgba(99, 102, 241, 0.2);
|
| 61 |
+
border-top: 2px solid #6366f1;
|
| 62 |
+
border-radius: 50%;
|
| 63 |
+
width: 24px;
|
| 64 |
+
height: 24px;
|
| 65 |
+
animation: spin 0.8s linear infinite;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
@keyframes spin {
|
| 69 |
+
to { transform: rotate(360deg); }
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/* Print styles */
|
| 73 |
+
@media print {
|
| 74 |
+
nav, footer, .btn-primary, .btn-secondary, button {
|
| 75 |
+
display: none !important;
|
| 76 |
+
}
|
| 77 |
+
body {
|
| 78 |
+
background: white !important;
|
| 79 |
+
}
|
| 80 |
+
.glass-card {
|
| 81 |
+
box-shadow: none !important;
|
| 82 |
+
border: 1px solid #e2e8f0 !important;
|
| 83 |
+
}
|
| 84 |
+
}
|
templates/base.html
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>HireScope AI — Intelligent Resume Screening</title>
|
| 7 |
+
<meta name="description" content="AI-powered resume screening system with semantic matching, skill extraction, and audio transcription.">
|
| 8 |
+
|
| 9 |
+
<!-- Google Fonts -->
|
| 10 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 11 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
| 13 |
+
|
| 14 |
+
<!-- Tailwind CSS CDN -->
|
| 15 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 16 |
+
<script>
|
| 17 |
+
tailwind.config = {
|
| 18 |
+
theme: {
|
| 19 |
+
extend: {
|
| 20 |
+
fontFamily: {
|
| 21 |
+
sans: ['Inter', 'system-ui', 'sans-serif'],
|
| 22 |
+
},
|
| 23 |
+
colors: {
|
| 24 |
+
brand: {
|
| 25 |
+
50: '#eef2ff',
|
| 26 |
+
100: '#e0e7ff',
|
| 27 |
+
200: '#c7d2fe',
|
| 28 |
+
300: '#a5b4fc',
|
| 29 |
+
400: '#818cf8',
|
| 30 |
+
500: '#6366f1',
|
| 31 |
+
600: '#4f46e5',
|
| 32 |
+
700: '#4338ca',
|
| 33 |
+
800: '#3730a3',
|
| 34 |
+
900: '#312e81',
|
| 35 |
+
},
|
| 36 |
+
sky: {
|
| 37 |
+
50: '#f0f9ff',
|
| 38 |
+
400: '#38bdf8',
|
| 39 |
+
500: '#0ea5e9',
|
| 40 |
+
600: '#0284c7',
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
</script>
|
| 47 |
+
<style>
|
| 48 |
+
* { font-family: 'Inter', system-ui, sans-serif; }
|
| 49 |
+
|
| 50 |
+
body {
|
| 51 |
+
background: linear-gradient(135deg, #f0f4f8 0%, #e8edf5 50%, #f0f0ff 100%);
|
| 52 |
+
min-height: 100vh;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.glass-card {
|
| 56 |
+
background: rgba(255, 255, 255, 0.85);
|
| 57 |
+
backdrop-filter: blur(20px);
|
| 58 |
+
-webkit-backdrop-filter: blur(20px);
|
| 59 |
+
border: 1px solid rgba(255, 255, 255, 0.9);
|
| 60 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.glass-card:hover {
|
| 64 |
+
box-shadow: 0 8px 40px rgba(99, 102, 241, 0.08), 0 2px 8px rgba(0, 0, 0, 0.06);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.gradient-text {
|
| 68 |
+
background: linear-gradient(135deg, #6366f1, #0ea5e9);
|
| 69 |
+
-webkit-background-clip: text;
|
| 70 |
+
-webkit-text-fill-color: transparent;
|
| 71 |
+
background-clip: text;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.nav-glass {
|
| 75 |
+
background: rgba(255, 255, 255, 0.92);
|
| 76 |
+
backdrop-filter: blur(24px);
|
| 77 |
+
-webkit-backdrop-filter: blur(24px);
|
| 78 |
+
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
@keyframes fadeInUp {
|
| 82 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 83 |
+
to { opacity: 1; transform: translateY(0); }
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
@keyframes slideInRight {
|
| 87 |
+
from { opacity: 0; transform: translateX(20px); }
|
| 88 |
+
to { opacity: 1; transform: translateX(0); }
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
@keyframes pulse-soft {
|
| 92 |
+
0%, 100% { opacity: 1; }
|
| 93 |
+
50% { opacity: 0.6; }
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.animate-fade-in-up { animation: fadeInUp 0.5s ease-out forwards; }
|
| 97 |
+
.animate-fade-in-up-delay { animation: fadeInUp 0.5s ease-out 0.1s forwards; opacity: 0; }
|
| 98 |
+
.animate-fade-in-up-delay-2 { animation: fadeInUp 0.5s ease-out 0.2s forwards; opacity: 0; }
|
| 99 |
+
.animate-slide-in { animation: slideInRight 0.4s ease-out forwards; }
|
| 100 |
+
.animate-pulse-soft { animation: pulse-soft 2s ease-in-out infinite; }
|
| 101 |
+
|
| 102 |
+
/* Custom scrollbar */
|
| 103 |
+
::-webkit-scrollbar { width: 6px; }
|
| 104 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 105 |
+
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
| 106 |
+
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
| 107 |
+
|
| 108 |
+
/* Smooth transitions for interactive elements */
|
| 109 |
+
.btn-primary {
|
| 110 |
+
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
| 111 |
+
transition: all 0.3s ease;
|
| 112 |
+
}
|
| 113 |
+
.btn-primary:hover {
|
| 114 |
+
background: linear-gradient(135deg, #818cf8, #6366f1);
|
| 115 |
+
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.3);
|
| 116 |
+
transform: translateY(-1px);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.btn-secondary {
|
| 120 |
+
background: linear-gradient(135deg, #0ea5e9, #0284c7);
|
| 121 |
+
transition: all 0.3s ease;
|
| 122 |
+
}
|
| 123 |
+
.btn-secondary:hover {
|
| 124 |
+
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
|
| 125 |
+
box-shadow: 0 8px 25px rgba(14, 165, 233, 0.3);
|
| 126 |
+
transform: translateY(-1px);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* Flash message styles */
|
| 130 |
+
.flash-success { background: linear-gradient(135deg, #ecfdf5, #d1fae5); border-left: 4px solid #10b981; }
|
| 131 |
+
.flash-error { background: linear-gradient(135deg, #fef2f2, #fecaca); border-left: 4px solid #ef4444; }
|
| 132 |
+
.flash-info { background: linear-gradient(135deg, #eff6ff, #dbeafe); border-left: 4px solid #3b82f6; }
|
| 133 |
+
.flash-warning { background: linear-gradient(135deg, #fffbeb, #fef3c7); border-left: 4px solid #f59e0b; }
|
| 134 |
+
|
| 135 |
+
/* Mobile menu */
|
| 136 |
+
.mobile-menu { display: none; }
|
| 137 |
+
.mobile-menu.open { display: flex; }
|
| 138 |
+
|
| 139 |
+
/* Modal backdrop */
|
| 140 |
+
.modal-backdrop {
|
| 141 |
+
background: rgba(15, 23, 42, 0.5);
|
| 142 |
+
backdrop-filter: blur(4px);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/* Skill tag animation */
|
| 146 |
+
.skill-tag {
|
| 147 |
+
transition: all 0.2s ease;
|
| 148 |
+
}
|
| 149 |
+
.skill-tag:hover {
|
| 150 |
+
transform: translateY(-1px);
|
| 151 |
+
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
|
| 152 |
+
}
|
| 153 |
+
</style>
|
| 154 |
+
</head>
|
| 155 |
+
<body class="min-h-screen flex flex-col font-sans antialiased text-slate-800">
|
| 156 |
+
|
| 157 |
+
<!-- Navigation -->
|
| 158 |
+
<nav class="nav-glass sticky top-0 z-50">
|
| 159 |
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 160 |
+
<div class="flex items-center justify-between h-16">
|
| 161 |
+
<!-- Logo -->
|
| 162 |
+
<div class="flex items-center gap-3">
|
| 163 |
+
<div class="bg-gradient-to-br from-brand-500 to-sky-500 p-2 rounded-xl shadow-lg shadow-brand-200">
|
| 164 |
+
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 165 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
| 166 |
+
</svg>
|
| 167 |
+
</div>
|
| 168 |
+
<a href="/" class="text-xl font-extrabold gradient-text tracking-tight">HireScope AI</a>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<!-- Desktop Navigation -->
|
| 172 |
+
<div class="hidden md:flex items-center gap-2">
|
| 173 |
+
{% if session.get('user_id') %}
|
| 174 |
+
<a href="/" class="px-4 py-2 text-slate-600 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition font-medium text-sm">
|
| 175 |
+
<span class="flex items-center gap-2">
|
| 176 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
| 177 |
+
Upload
|
| 178 |
+
</span>
|
| 179 |
+
</a>
|
| 180 |
+
<a href="/results" class="px-4 py-2 text-slate-600 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition font-medium text-sm">
|
| 181 |
+
<span class="flex items-center gap-2">
|
| 182 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
| 183 |
+
Results
|
| 184 |
+
</span>
|
| 185 |
+
</a>
|
| 186 |
+
<a href="/ranking" class="px-4 py-2 text-slate-600 hover:text-brand-600 hover:bg-brand-50 rounded-lg transition font-medium text-sm flex items-center gap-2">
|
| 187 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg>
|
| 188 |
+
Leaderboard
|
| 189 |
+
{% if candidate_count is defined and candidate_count > 0 %}
|
| 190 |
+
<span class="bg-brand-500 text-white text-xs px-2 py-0.5 rounded-full font-semibold">{{ candidate_count }}</span>
|
| 191 |
+
{% endif %}
|
| 192 |
+
</a>
|
| 193 |
+
|
| 194 |
+
<div class="pl-3 ml-2 border-l border-slate-200 flex items-center gap-3">
|
| 195 |
+
<div class="flex items-center gap-2 bg-slate-100 px-3 py-1.5 rounded-lg">
|
| 196 |
+
<div class="w-7 h-7 bg-gradient-to-br from-brand-500 to-sky-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
| 197 |
+
{{ session.username[0]|upper }}
|
| 198 |
+
</div>
|
| 199 |
+
<span class="text-sm font-medium text-slate-700">{{ session.username }}</span>
|
| 200 |
+
</div>
|
| 201 |
+
<a href="/logout" class="text-sm text-red-500 hover:text-red-600 hover:bg-red-50 px-3 py-1.5 rounded-lg transition font-medium">Log out</a>
|
| 202 |
+
</div>
|
| 203 |
+
{% else %}
|
| 204 |
+
<a href="/login" class="px-4 py-2 text-slate-600 hover:text-brand-600 font-medium text-sm rounded-lg hover:bg-brand-50 transition">Sign In</a>
|
| 205 |
+
<a href="/register" class="btn-primary text-white px-5 py-2 rounded-lg text-sm font-semibold shadow-md">Sign Up Free</a>
|
| 206 |
+
{% endif %}
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<!-- Mobile Hamburger -->
|
| 210 |
+
<button onclick="document.getElementById('mobile-nav').classList.toggle('open')" class="md:hidden p-2 rounded-lg hover:bg-slate-100 transition">
|
| 211 |
+
<svg class="w-6 h-6 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
| 212 |
+
</button>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<!-- Mobile Menu -->
|
| 216 |
+
<div id="mobile-nav" class="mobile-menu flex-col gap-2 pb-4 md:hidden">
|
| 217 |
+
{% if session.get('user_id') %}
|
| 218 |
+
<a href="/" class="px-4 py-2 text-slate-600 hover:bg-brand-50 rounded-lg font-medium text-sm">Upload</a>
|
| 219 |
+
<a href="/results" class="px-4 py-2 text-slate-600 hover:bg-brand-50 rounded-lg font-medium text-sm">Results</a>
|
| 220 |
+
<a href="/ranking" class="px-4 py-2 text-slate-600 hover:bg-brand-50 rounded-lg font-medium text-sm">Leaderboard</a>
|
| 221 |
+
<a href="/logout" class="px-4 py-2 text-red-500 hover:bg-red-50 rounded-lg font-medium text-sm">Log out</a>
|
| 222 |
+
{% else %}
|
| 223 |
+
<a href="/login" class="px-4 py-2 text-slate-600 hover:bg-brand-50 rounded-lg font-medium text-sm">Sign In</a>
|
| 224 |
+
<a href="/register" class="px-4 py-2 text-brand-600 hover:bg-brand-50 rounded-lg font-medium text-sm">Sign Up</a>
|
| 225 |
+
{% endif %}
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</nav>
|
| 229 |
+
|
| 230 |
+
<!-- Flash Messages -->
|
| 231 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 232 |
+
{% if messages %}
|
| 233 |
+
<div class="max-w-5xl mx-auto mt-6 px-4 w-full space-y-3">
|
| 234 |
+
{% for category, message in messages %}
|
| 235 |
+
<div class="flash-{{ category }} px-5 py-4 rounded-xl flex items-center gap-3 animate-slide-in shadow-sm">
|
| 236 |
+
{% if category == 'success' %}
|
| 237 |
+
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 238 |
+
<span class="text-emerald-800 font-medium text-sm">{{ message }}</span>
|
| 239 |
+
{% elif category == 'error' %}
|
| 240 |
+
<svg class="w-5 h-5 text-red-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 241 |
+
<span class="text-red-800 font-medium text-sm">{{ message }}</span>
|
| 242 |
+
{% elif category == 'warning' %}
|
| 243 |
+
<svg class="w-5 h-5 text-amber-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>
|
| 244 |
+
<span class="text-amber-800 font-medium text-sm">{{ message }}</span>
|
| 245 |
+
{% else %}
|
| 246 |
+
<svg class="w-5 h-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 247 |
+
<span class="text-blue-800 font-medium text-sm">{{ message }}</span>
|
| 248 |
+
{% endif %}
|
| 249 |
+
</div>
|
| 250 |
+
{% endfor %}
|
| 251 |
+
</div>
|
| 252 |
+
{% endif %}
|
| 253 |
+
{% endwith %}
|
| 254 |
+
|
| 255 |
+
<!-- Main Content -->
|
| 256 |
+
<main class="flex-grow flex flex-col pt-6 pb-16">
|
| 257 |
+
{% block content %}{% endblock %}
|
| 258 |
+
</main>
|
| 259 |
+
|
| 260 |
+
<!-- Footer -->
|
| 261 |
+
<footer class="border-t border-slate-200 bg-white/60 backdrop-blur py-6">
|
| 262 |
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 263 |
+
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
| 264 |
+
<div class="flex items-center gap-2">
|
| 265 |
+
<div class="bg-gradient-to-br from-brand-500 to-sky-500 p-1.5 rounded-lg">
|
| 266 |
+
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
| 267 |
+
</div>
|
| 268 |
+
<span class="text-sm font-semibold gradient-text">HireScope AI</span>
|
| 269 |
+
</div>
|
| 270 |
+
<p class="text-xs text-slate-400">Built with Semantic AI, Whisper & Sentence-Transformers</p>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
</footer>
|
| 274 |
+
|
| 275 |
+
</body>
|
| 276 |
+
</html>
|
templates/index.html
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="max-w-6xl mx-auto w-full px-4 sm:px-6 lg:px-8 mt-2">
|
| 4 |
+
|
| 5 |
+
<!-- Hero Section -->
|
| 6 |
+
<div class="text-center mb-10 animate-fade-in-up">
|
| 7 |
+
<h1 class="text-4xl md:text-5xl font-extrabold text-slate-800 mb-4 tracking-tight leading-tight">
|
| 8 |
+
Screen Resumes with<br>
|
| 9 |
+
<span class="gradient-text">Semantic AI Intelligence</span>
|
| 10 |
+
</h1>
|
| 11 |
+
<p class="text-lg text-slate-500 max-w-2xl mx-auto leading-relaxed">Upload resumes and match them against job descriptions using advanced AI embeddings. Get instant skill extraction, scoring, and candidate ranking.</p>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
<!-- Stats Cards -->
|
| 15 |
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-10 animate-fade-in-up-delay">
|
| 16 |
+
<div class="glass-card rounded-2xl p-5 flex items-center gap-4">
|
| 17 |
+
<div class="w-12 h-12 bg-gradient-to-br from-brand-100 to-brand-200 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 18 |
+
<svg class="w-6 h-6 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
|
| 19 |
+
</div>
|
| 20 |
+
<div>
|
| 21 |
+
<p class="text-2xl font-extrabold text-slate-800">{{ candidate_count }}</p>
|
| 22 |
+
<p class="text-xs text-slate-500 font-medium uppercase tracking-wide">Candidates</p>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
<div class="glass-card rounded-2xl p-5 flex items-center gap-4">
|
| 26 |
+
<div class="w-12 h-12 bg-gradient-to-br from-emerald-100 to-emerald-200 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 27 |
+
<svg class="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
| 28 |
+
</div>
|
| 29 |
+
<div>
|
| 30 |
+
<p class="text-2xl font-extrabold text-slate-800">{{ avg_score }}%</p>
|
| 31 |
+
<p class="text-xs text-slate-500 font-medium uppercase tracking-wide">Avg. Score</p>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="glass-card rounded-2xl p-5 flex items-center gap-4">
|
| 35 |
+
<div class="w-12 h-12 bg-gradient-to-br from-violet-100 to-violet-200 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 36 |
+
<svg class="w-6 h-6 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path></svg>
|
| 37 |
+
</div>
|
| 38 |
+
<div>
|
| 39 |
+
<p class="text-2xl font-extrabold text-slate-800">AI</p>
|
| 40 |
+
<p class="text-xs text-slate-500 font-medium uppercase tracking-wide">Embedding Match</p>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<!-- Upload Section -->
|
| 46 |
+
<div class="max-w-3xl mx-auto animate-fade-in-up-delay-2">
|
| 47 |
+
<div class="glass-card p-8 rounded-2xl relative overflow-hidden">
|
| 48 |
+
<!-- Decorative gradient blob -->
|
| 49 |
+
<div class="absolute -top-10 -right-10 w-40 h-40 bg-gradient-to-br from-brand-200/40 to-sky-200/40 rounded-full blur-3xl pointer-events-none"></div>
|
| 50 |
+
<div class="absolute -bottom-10 -left-10 w-32 h-32 bg-gradient-to-br from-violet-200/30 to-brand-200/30 rounded-full blur-3xl pointer-events-none"></div>
|
| 51 |
+
|
| 52 |
+
<div class="relative">
|
| 53 |
+
<h2 class="text-xl font-bold text-slate-800 mb-1 flex items-center gap-3">
|
| 54 |
+
<div class="w-10 h-10 bg-gradient-to-br from-brand-500 to-brand-600 rounded-xl flex items-center justify-center shadow-md shadow-brand-200">
|
| 55 |
+
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
| 56 |
+
</div>
|
| 57 |
+
Upload & Analyze Resume
|
| 58 |
+
</h2>
|
| 59 |
+
<p class="text-slate-500 text-sm mb-6 ml-13 pl-13">Upload a PDF/DOCX resume and optionally provide a job description for AI-powered matching.</p>
|
| 60 |
+
|
| 61 |
+
<form action="/upload" method="POST" enctype="multipart/form-data" class="space-y-5">
|
| 62 |
+
<!-- File Upload Area -->
|
| 63 |
+
<div>
|
| 64 |
+
<label class="block text-sm font-semibold text-slate-700 mb-2">Resume File</label>
|
| 65 |
+
<div id="drop-zone" class="w-full flex justify-center px-6 pt-6 pb-6 border-2 border-slate-200 border-dashed rounded-2xl hover:border-brand-400 hover:bg-brand-50/30 transition-all cursor-pointer group">
|
| 66 |
+
<div class="space-y-2 text-center">
|
| 67 |
+
<div class="mx-auto w-14 h-14 bg-brand-50 rounded-2xl flex items-center justify-center group-hover:bg-brand-100 transition">
|
| 68 |
+
<svg class="w-7 h-7 text-brand-400 group-hover:text-brand-500 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 69 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
| 70 |
+
</svg>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="flex text-sm text-slate-500 justify-center">
|
| 73 |
+
<label for="resume-upload" class="relative cursor-pointer rounded-md font-semibold text-brand-600 hover:text-brand-500 transition">
|
| 74 |
+
<span>Choose file</span>
|
| 75 |
+
<input id="resume-upload" name="resume" type="file" class="sr-only" required accept=".pdf,.docx">
|
| 76 |
+
</label>
|
| 77 |
+
<p class="pl-1">or drag & drop</p>
|
| 78 |
+
</div>
|
| 79 |
+
<p class="text-xs text-slate-400 font-medium" id="resume-filename">PDF or DOCX up to 10MB</p>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<!-- Job Description -->
|
| 85 |
+
<div>
|
| 86 |
+
<label for="job_description" class="block text-sm font-semibold text-slate-700 mb-2">
|
| 87 |
+
Job Description
|
| 88 |
+
<span class="text-slate-400 font-normal">(Optional)</span>
|
| 89 |
+
</label>
|
| 90 |
+
<textarea name="job_description" id="job_description" rows="4"
|
| 91 |
+
class="w-full bg-slate-50 border border-slate-200 rounded-xl p-4 text-slate-800 focus:outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-100 transition resize-none placeholder-slate-400 text-sm"
|
| 92 |
+
placeholder="Paste the job requirements here to generate a semantic match score..."></textarea>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<button type="submit" id="analyze-btn" class="w-full btn-primary text-white font-semibold py-3.5 px-4 rounded-xl shadow-lg text-sm flex justify-center items-center gap-2">
|
| 96 |
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
| 97 |
+
Analyze with AI
|
| 98 |
+
</button>
|
| 99 |
+
</form>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<!-- Recent Candidates -->
|
| 105 |
+
{% if recent_candidates %}
|
| 106 |
+
<div class="max-w-3xl mx-auto mt-10 animate-fade-in-up-delay-2">
|
| 107 |
+
<h3 class="text-lg font-bold text-slate-700 mb-4 flex items-center gap-2">
|
| 108 |
+
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 109 |
+
Recent Candidates
|
| 110 |
+
</h3>
|
| 111 |
+
<div class="space-y-3">
|
| 112 |
+
{% for c in recent_candidates %}
|
| 113 |
+
<div class="glass-card rounded-xl p-4 flex items-center justify-between group hover:shadow-md transition">
|
| 114 |
+
<div class="flex items-center gap-3">
|
| 115 |
+
<div class="w-10 h-10 bg-gradient-to-br from-brand-100 to-sky-100 rounded-xl flex items-center justify-center text-brand-600 font-bold text-sm">
|
| 116 |
+
{{ c.name[0]|upper }}
|
| 117 |
+
</div>
|
| 118 |
+
<div>
|
| 119 |
+
<p class="font-semibold text-slate-700 text-sm">{{ c.name }}</p>
|
| 120 |
+
<p class="text-xs text-slate-400">{{ c.skills|length }} skills detected</p>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="flex items-center gap-3">
|
| 124 |
+
<span class="text-sm font-bold {% if c.match_score >= 70 %}text-emerald-600{% elif c.match_score >= 40 %}text-amber-600{% else %}text-slate-400{% endif %}">
|
| 125 |
+
{{ c.match_score }}%
|
| 126 |
+
</span>
|
| 127 |
+
<div class="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
| 128 |
+
<div class="h-full rounded-full {% if c.match_score >= 70 %}bg-emerald-500{% elif c.match_score >= 40 %}bg-amber-400{% else %}bg-slate-300{% endif %}" style="width: {{ c.match_score }}%"></div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
{% endfor %}
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
{% endif %}
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<script>
|
| 139 |
+
// File name display
|
| 140 |
+
document.getElementById('resume-upload').addEventListener('change', function(e) {
|
| 141 |
+
const name = e.target.files[0]?.name;
|
| 142 |
+
const el = document.getElementById('resume-filename');
|
| 143 |
+
if (name) {
|
| 144 |
+
el.textContent = '📄 ' + name;
|
| 145 |
+
el.classList.add('text-brand-600', 'font-semibold');
|
| 146 |
+
el.classList.remove('text-slate-400');
|
| 147 |
+
}
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
// Drag and drop
|
| 151 |
+
const dropZone = document.getElementById('drop-zone');
|
| 152 |
+
const fileInput = document.getElementById('resume-upload');
|
| 153 |
+
|
| 154 |
+
['dragenter', 'dragover'].forEach(evt => {
|
| 155 |
+
dropZone.addEventListener(evt, function(e) {
|
| 156 |
+
e.preventDefault();
|
| 157 |
+
dropZone.classList.add('border-brand-400', 'bg-brand-50/50');
|
| 158 |
+
});
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
['dragleave', 'drop'].forEach(evt => {
|
| 162 |
+
dropZone.addEventListener(evt, function(e) {
|
| 163 |
+
e.preventDefault();
|
| 164 |
+
dropZone.classList.remove('border-brand-400', 'bg-brand-50/50');
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
dropZone.addEventListener('drop', function(e) {
|
| 169 |
+
e.preventDefault();
|
| 170 |
+
const files = e.dataTransfer.files;
|
| 171 |
+
if (files.length) {
|
| 172 |
+
fileInput.files = files;
|
| 173 |
+
fileInput.dispatchEvent(new Event('change'));
|
| 174 |
+
}
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
dropZone.addEventListener('click', function() {
|
| 178 |
+
fileInput.click();
|
| 179 |
+
});
|
| 180 |
+
</script>
|
| 181 |
+
{% endblock %}
|
templates/login.html
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="flex-grow flex items-center justify-center px-4">
|
| 4 |
+
<div class="w-full max-w-md animate-fade-in-up">
|
| 5 |
+
<!-- Brand Header -->
|
| 6 |
+
<div class="text-center mb-8">
|
| 7 |
+
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-brand-500 to-sky-500 rounded-2xl shadow-lg shadow-brand-200 mb-4">
|
| 8 |
+
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 9 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
| 10 |
+
</svg>
|
| 11 |
+
</div>
|
| 12 |
+
<h1 class="text-2xl font-extrabold text-slate-800 mb-1">Welcome back</h1>
|
| 13 |
+
<p class="text-slate-500 text-sm">Sign in to your HireScope AI account</p>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<!-- Login Card -->
|
| 17 |
+
<div class="glass-card p-8 rounded-2xl">
|
| 18 |
+
<form method="POST" action="/login" class="space-y-5">
|
| 19 |
+
<div>
|
| 20 |
+
<label for="email" class="block text-sm font-semibold text-slate-700 mb-1.5">Email Address</label>
|
| 21 |
+
<input type="email" name="email" id="email" required
|
| 22 |
+
class="w-full bg-slate-50 border border-slate-200 text-slate-800 rounded-xl px-4 py-3 focus:outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-100 transition placeholder-slate-400"
|
| 23 |
+
placeholder="recruiter@company.com">
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div>
|
| 27 |
+
<label for="password" class="block text-sm font-semibold text-slate-700 mb-1.5">Password</label>
|
| 28 |
+
<input type="password" name="password" id="password" required
|
| 29 |
+
class="w-full bg-slate-50 border border-slate-200 text-slate-800 rounded-xl px-4 py-3 focus:outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-100 transition placeholder-slate-400"
|
| 30 |
+
placeholder="••••••••">
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<button type="submit" class="w-full btn-primary text-white font-semibold py-3 rounded-xl shadow-lg text-sm flex items-center justify-center gap-2">
|
| 34 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path></svg>
|
| 35 |
+
Sign In
|
| 36 |
+
</button>
|
| 37 |
+
</form>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- Footer Link -->
|
| 41 |
+
<div class="mt-6 text-center">
|
| 42 |
+
<p class="text-slate-500 text-sm">Don't have an account?
|
| 43 |
+
<a href="/register" class="text-brand-600 hover:text-brand-700 font-semibold transition">Create one free</a>
|
| 44 |
+
</p>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
{% endblock %}
|
templates/ranking.html
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 mt-2 animate-fade-in-up">
|
| 4 |
+
|
| 5 |
+
<!-- Header -->
|
| 6 |
+
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-end mb-8 gap-4">
|
| 7 |
+
<div>
|
| 8 |
+
<h1 class="text-3xl font-extrabold text-slate-800 flex items-center gap-3">
|
| 9 |
+
<div class="w-10 h-10 bg-gradient-to-br from-amber-400 to-orange-500 rounded-xl flex items-center justify-center shadow-md shadow-amber-200">
|
| 10 |
+
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path></svg>
|
| 11 |
+
</div>
|
| 12 |
+
Candidate Leaderboard
|
| 13 |
+
</h1>
|
| 14 |
+
<p class="text-slate-500 mt-1 text-sm ml-13">{{ candidate_count }} candidates processed — click any row to view full profile</p>
|
| 15 |
+
</div>
|
| 16 |
+
<div class="flex gap-3">
|
| 17 |
+
<a href="/" class="px-4 py-2.5 bg-white hover:bg-slate-50 text-slate-700 rounded-xl transition border border-slate-200 shadow-sm font-medium text-sm flex items-center gap-2">
|
| 18 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
| 19 |
+
Add Candidate
|
| 20 |
+
</a>
|
| 21 |
+
<a href="/clear" class="px-4 py-2.5 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition border border-red-200 shadow-sm font-medium text-sm flex items-center gap-2" onclick="return confirm('⚠️ Are you sure? This will permanently delete ALL candidates from the database.')">
|
| 22 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
| 23 |
+
Clear All
|
| 24 |
+
</a>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<!-- Re-Rank Panel -->
|
| 29 |
+
<div class="glass-card p-5 rounded-2xl mb-8 animate-fade-in-up-delay">
|
| 30 |
+
<form method="POST" action="/ranking" class="flex flex-col md:flex-row gap-4 items-end">
|
| 31 |
+
<div class="flex-grow w-full">
|
| 32 |
+
<label for="job_description" class="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Re-Rank via Semantic Similarity</label>
|
| 33 |
+
<input type="text" name="job_description" id="job_description"
|
| 34 |
+
class="w-full bg-slate-50 border border-slate-200 rounded-xl p-3.5 text-slate-800 focus:outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-100 transition text-sm placeholder-slate-400"
|
| 35 |
+
placeholder="Enter keywords or a full job description to re-rank all candidates..."
|
| 36 |
+
value="{{ job_description }}">
|
| 37 |
+
</div>
|
| 38 |
+
<button type="submit" class="w-full md:w-auto whitespace-nowrap btn-primary text-white font-semibold py-3.5 px-6 rounded-xl shadow-md text-sm flex items-center justify-center gap-2">
|
| 39 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
| 40 |
+
Recalculate
|
| 41 |
+
</button>
|
| 42 |
+
</form>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
{% if not ranked %}
|
| 46 |
+
<!-- Empty State -->
|
| 47 |
+
<div class="glass-card p-16 text-center rounded-2xl">
|
| 48 |
+
<div class="w-20 h-20 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
| 49 |
+
<svg class="w-10 h-10 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
| 50 |
+
</div>
|
| 51 |
+
<h3 class="text-xl font-bold text-slate-700 mb-2">No candidates yet</h3>
|
| 52 |
+
<p class="text-slate-400 mb-6">Upload resumes to start building your leaderboard</p>
|
| 53 |
+
<a href="/" class="btn-primary text-white px-6 py-3 rounded-xl shadow-md font-semibold text-sm inline-flex items-center gap-2">
|
| 54 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
| 55 |
+
Upload Resume
|
| 56 |
+
</a>
|
| 57 |
+
</div>
|
| 58 |
+
{% else %}
|
| 59 |
+
<!-- Leaderboard Table -->
|
| 60 |
+
<div class="glass-card rounded-2xl overflow-hidden animate-fade-in-up-delay">
|
| 61 |
+
<div class="overflow-x-auto">
|
| 62 |
+
<table class="min-w-full">
|
| 63 |
+
<thead>
|
| 64 |
+
<tr class="bg-gradient-to-r from-slate-50 to-slate-100 border-b border-slate-200">
|
| 65 |
+
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Rank</th>
|
| 66 |
+
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Candidate</th>
|
| 67 |
+
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Match Score</th>
|
| 68 |
+
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Skills</th>
|
| 69 |
+
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Action</th>
|
| 70 |
+
</tr>
|
| 71 |
+
</thead>
|
| 72 |
+
<tbody class="divide-y divide-slate-100">
|
| 73 |
+
{% for candidate in ranked %}
|
| 74 |
+
<tr class="hover:bg-brand-50/40 transition-colors group cursor-pointer" onclick="openCandidateModal('{{ candidate._id }}')">
|
| 75 |
+
<!-- Rank -->
|
| 76 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 77 |
+
{% if loop.index == 1 %}
|
| 78 |
+
<span class="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-amber-300 to-amber-500 text-white font-bold text-sm shadow-md shadow-amber-200">🥇</span>
|
| 79 |
+
{% elif loop.index == 2 %}
|
| 80 |
+
<span class="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-slate-300 to-slate-400 text-white font-bold text-sm shadow-md shadow-slate-200">🥈</span>
|
| 81 |
+
{% elif loop.index == 3 %}
|
| 82 |
+
<span class="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-amber-600 to-amber-700 text-white font-bold text-sm shadow-md shadow-amber-200">🥉</span>
|
| 83 |
+
{% else %}
|
| 84 |
+
<span class="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-slate-100 text-slate-500 font-bold text-sm">{{ loop.index }}</span>
|
| 85 |
+
{% endif %}
|
| 86 |
+
</td>
|
| 87 |
+
<!-- Candidate Name -->
|
| 88 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 89 |
+
<div class="flex items-center gap-3">
|
| 90 |
+
<div class="w-10 h-10 bg-gradient-to-br from-brand-400 to-sky-400 rounded-xl flex items-center justify-center text-white font-bold text-sm shadow-sm flex-shrink-0">
|
| 91 |
+
{{ candidate.name[0]|upper }}
|
| 92 |
+
</div>
|
| 93 |
+
<div>
|
| 94 |
+
<div class="font-semibold text-slate-800 text-sm">{{ candidate.name }}</div>
|
| 95 |
+
<div class="text-xs text-slate-400">ID: {{ candidate._id[-8:] }}</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</td>
|
| 99 |
+
<!-- Score -->
|
| 100 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 101 |
+
<div class="flex items-center gap-3">
|
| 102 |
+
<span class="text-lg font-extrabold {% if candidate.match_score >= 70 %}text-emerald-600{% elif candidate.match_score >= 40 %}text-amber-600{% else %}text-slate-500{% endif %}">
|
| 103 |
+
{{ candidate.match_score }}%
|
| 104 |
+
</span>
|
| 105 |
+
<div class="w-20 h-2 bg-slate-100 rounded-full overflow-hidden">
|
| 106 |
+
<div class="h-full rounded-full transition-all duration-500 {% if candidate.match_score >= 70 %}bg-gradient-to-r from-emerald-400 to-emerald-500{% elif candidate.match_score >= 40 %}bg-gradient-to-r from-amber-400 to-amber-500{% else %}bg-gradient-to-r from-slate-300 to-slate-400{% endif %}" style="width: {{ candidate.match_score }}%"></div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</td>
|
| 110 |
+
<!-- Skills -->
|
| 111 |
+
<td class="px-6 py-4">
|
| 112 |
+
<div class="flex flex-wrap gap-1.5 max-w-xs">
|
| 113 |
+
{% for skill in candidate.skills[:4] %}
|
| 114 |
+
<span class="px-2 py-0.5 bg-brand-50 text-brand-600 border border-brand-100 rounded-lg text-xs font-medium">{{ skill }}</span>
|
| 115 |
+
{% endfor %}
|
| 116 |
+
{% if candidate.skills|length > 4 %}
|
| 117 |
+
<span class="px-2 py-0.5 bg-slate-100 text-slate-500 rounded-lg text-xs font-medium">+{{ candidate.skills|length - 4 }} more</span>
|
| 118 |
+
{% endif %}
|
| 119 |
+
</div>
|
| 120 |
+
</td>
|
| 121 |
+
<!-- Action -->
|
| 122 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 123 |
+
<button onclick="event.stopPropagation(); openCandidateModal('{{ candidate._id }}')" class="text-brand-600 hover:text-brand-700 font-semibold text-xs flex items-center gap-1 group-hover:underline">
|
| 124 |
+
View Profile
|
| 125 |
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
| 126 |
+
</button>
|
| 127 |
+
</td>
|
| 128 |
+
</tr>
|
| 129 |
+
{% endfor %}
|
| 130 |
+
</tbody>
|
| 131 |
+
</table>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
{% endif %}
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<!-- Candidate Profile Modal -->
|
| 138 |
+
<div id="candidate-modal" class="fixed inset-0 z-[100] hidden">
|
| 139 |
+
<div class="modal-backdrop absolute inset-0" onclick="closeModal()"></div>
|
| 140 |
+
<div class="relative flex items-center justify-center min-h-screen p-4">
|
| 141 |
+
<div id="modal-content" class="relative bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto p-0 animate-fade-in-up" style="animation-duration: 0.3s;">
|
| 142 |
+
<!-- Modal will be populated by JS -->
|
| 143 |
+
<div id="modal-body" class="p-8">
|
| 144 |
+
<div class="flex items-center justify-center py-12">
|
| 145 |
+
<div class="w-8 h-8 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<script>
|
| 153 |
+
function openCandidateModal(candidateId) {
|
| 154 |
+
const modal = document.getElementById('candidate-modal');
|
| 155 |
+
const body = document.getElementById('modal-body');
|
| 156 |
+
modal.classList.remove('hidden');
|
| 157 |
+
document.body.style.overflow = 'hidden';
|
| 158 |
+
|
| 159 |
+
// Show loading
|
| 160 |
+
body.innerHTML = `<div class="flex items-center justify-center py-16"><div class="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div></div>`;
|
| 161 |
+
|
| 162 |
+
fetch(`/api/candidate/${candidateId}`)
|
| 163 |
+
.then(r => r.json())
|
| 164 |
+
.then(data => {
|
| 165 |
+
if (data.error) {
|
| 166 |
+
body.innerHTML = `<p class="text-red-500 text-center py-12">Candidate not found</p>`;
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const scoreColor = data.match_score >= 70 ? 'emerald' : data.match_score >= 40 ? 'amber' : 'slate';
|
| 171 |
+
|
| 172 |
+
let skillsHtml = data.skills.map(s =>
|
| 173 |
+
`<span class="px-2.5 py-1 bg-indigo-50 text-indigo-700 border border-indigo-100 rounded-lg text-xs font-medium">${s}</span>`
|
| 174 |
+
).join('') || '<span class="text-slate-400 text-sm italic">None detected</span>';
|
| 175 |
+
|
| 176 |
+
let eduHtml = data.education.map(e =>
|
| 177 |
+
`<span class="px-2.5 py-1 bg-violet-50 text-violet-700 border border-violet-100 rounded-lg text-xs font-medium">${e}</span>`
|
| 178 |
+
).join('') || '<span class="text-slate-400 text-sm italic">None detected</span>';
|
| 179 |
+
|
| 180 |
+
let expHtml = data.experience.map(e =>
|
| 181 |
+
`<span class="px-2.5 py-1 bg-teal-50 text-teal-700 border border-teal-100 rounded-lg text-xs font-medium">${e}</span>`
|
| 182 |
+
).join('') || '<span class="text-slate-400 text-sm italic">None detected</span>';
|
| 183 |
+
|
| 184 |
+
let matchedHtml = (data.skill_gaps.matched || []).map(s =>
|
| 185 |
+
`<span class="px-2.5 py-1 bg-emerald-50 text-emerald-700 border border-emerald-100 rounded-lg text-xs font-medium">✓ ${s}</span>`
|
| 186 |
+
).join('') || '<span class="text-slate-400 text-xs italic">No JD provided</span>';
|
| 187 |
+
|
| 188 |
+
let missingHtml = (data.skill_gaps.missing || []).map(s =>
|
| 189 |
+
`<span class="px-2.5 py-1 bg-red-50 text-red-600 border border-red-100 rounded-lg text-xs font-medium line-through">${s}</span>`
|
| 190 |
+
).join('') || '<span class="text-slate-400 text-xs italic">No gaps</span>';
|
| 191 |
+
|
| 192 |
+
let audioHtml = '';
|
| 193 |
+
if (data.audio_transcription && data.audio_transcription.text) {
|
| 194 |
+
audioHtml = `
|
| 195 |
+
<div class="mt-6 bg-gradient-to-r from-emerald-50 to-teal-50 p-4 rounded-xl border border-emerald-100">
|
| 196 |
+
<h4 class="text-xs font-bold text-emerald-700 uppercase mb-2 flex items-center gap-1.5">
|
| 197 |
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
|
| 198 |
+
Audio Transcription (${(data.audio_transcription.language || '').toUpperCase()})
|
| 199 |
+
</h4>
|
| 200 |
+
<p class="text-slate-700 text-sm italic leading-relaxed">"${data.audio_transcription.text}"</p>
|
| 201 |
+
</div>`;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
let resumeLinkHtml = '';
|
| 205 |
+
|
| 206 |
+
body.innerHTML = `
|
| 207 |
+
<!-- Header -->
|
| 208 |
+
<div class="flex items-center justify-between mb-6">
|
| 209 |
+
<div class="flex items-center gap-4">
|
| 210 |
+
<div class="w-14 h-14 bg-gradient-to-br from-indigo-500 to-sky-500 rounded-2xl flex items-center justify-center text-white text-xl font-bold shadow-lg shadow-indigo-200">
|
| 211 |
+
${data.name[0].toUpperCase()}
|
| 212 |
+
</div>
|
| 213 |
+
<div>
|
| 214 |
+
<h2 class="text-xl font-bold text-slate-800">${data.name}</h2>
|
| 215 |
+
<p class="text-xs text-slate-400">ID: ${data._id} • ${data.skills.length} skills</p>
|
| 216 |
+
${resumeLinkHtml}
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
<button onclick="closeModal()" class="w-8 h-8 bg-slate-100 hover:bg-slate-200 rounded-lg flex items-center justify-center transition">
|
| 220 |
+
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
| 221 |
+
</button>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
${data.ai_summary ? `
|
| 225 |
+
<!-- Google Gen AI Summary -->
|
| 226 |
+
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 p-4 rounded-xl border border-blue-100 mb-6">
|
| 227 |
+
<div class="text-xs text-blue-600 uppercase font-bold mb-2 flex items-center gap-1.5">
|
| 228 |
+
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
| 229 |
+
AI Profile Summary
|
| 230 |
+
</div>
|
| 231 |
+
<p class="text-slate-700 text-sm italic leading-relaxed">${data.ai_summary}</p>
|
| 232 |
+
</div>
|
| 233 |
+
` : ''}
|
| 234 |
+
|
| 235 |
+
<!-- Score -->
|
| 236 |
+
<div class="flex items-center gap-4 mb-6 p-4 bg-gradient-to-r from-${scoreColor}-50 to-transparent rounded-xl border border-${scoreColor}-100">
|
| 237 |
+
<div class="text-3xl font-extrabold text-${scoreColor}-600">${data.match_score}%</div>
|
| 238 |
+
<div>
|
| 239 |
+
<p class="text-sm font-medium text-slate-700">Semantic Match Score</p>
|
| 240 |
+
<div class="w-32 h-2 bg-slate-100 rounded-full overflow-hidden mt-1">
|
| 241 |
+
<div class="h-full rounded-full bg-${scoreColor}-500" style="width: ${data.match_score}%"></div>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<!-- Skills -->
|
| 247 |
+
<div class="mb-5">
|
| 248 |
+
<h4 class="text-xs font-bold text-slate-500 uppercase mb-2">All Skills (${data.skills.length})</h4>
|
| 249 |
+
<div class="flex flex-wrap gap-1.5">${skillsHtml}</div>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<!-- Education & Experience -->
|
| 253 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-5">
|
| 254 |
+
<div>
|
| 255 |
+
<h4 class="text-xs font-bold text-violet-600 uppercase mb-2">Education</h4>
|
| 256 |
+
<div class="flex flex-wrap gap-1.5">${eduHtml}</div>
|
| 257 |
+
</div>
|
| 258 |
+
<div>
|
| 259 |
+
<h4 class="text-xs font-bold text-teal-600 uppercase mb-2">Experience</h4>
|
| 260 |
+
<div class="flex flex-wrap gap-1.5">${expHtml}</div>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<!-- Skill Gap -->
|
| 265 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 266 |
+
<div>
|
| 267 |
+
<h4 class="text-xs font-bold text-emerald-600 uppercase mb-2">✓ Matched Skills</h4>
|
| 268 |
+
<div class="flex flex-wrap gap-1.5">${matchedHtml}</div>
|
| 269 |
+
</div>
|
| 270 |
+
<div>
|
| 271 |
+
<h4 class="text-xs font-bold text-red-600 uppercase mb-2">✗ Missing Skills</h4>
|
| 272 |
+
<div class="flex flex-wrap gap-1.5">${missingHtml}</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
${audioHtml}
|
| 277 |
+
`;
|
| 278 |
+
})
|
| 279 |
+
.catch(err => {
|
| 280 |
+
body.innerHTML = `<p class="text-red-500 text-center py-12">Error loading candidate data</p>`;
|
| 281 |
+
});
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function closeModal() {
|
| 285 |
+
document.getElementById('candidate-modal').classList.add('hidden');
|
| 286 |
+
document.body.style.overflow = '';
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// Close modal on ESC key
|
| 290 |
+
document.addEventListener('keydown', function(e) {
|
| 291 |
+
if (e.key === 'Escape') closeModal();
|
| 292 |
+
});
|
| 293 |
+
</script>
|
| 294 |
+
{% endblock %}
|
templates/register.html
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="flex-grow flex items-center justify-center px-4 py-10">
|
| 4 |
+
<div class="w-full max-w-md animate-fade-in-up">
|
| 5 |
+
<!-- Brand Header -->
|
| 6 |
+
<div class="text-center mb-8">
|
| 7 |
+
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-brand-500 to-sky-500 rounded-2xl shadow-lg shadow-brand-200 mb-4">
|
| 8 |
+
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 9 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
|
| 10 |
+
</svg>
|
| 11 |
+
</div>
|
| 12 |
+
<h1 class="text-2xl font-extrabold text-slate-800 mb-1">Create your account</h1>
|
| 13 |
+
<p class="text-slate-500 text-sm">Join HireScope AI — the next-gen screening platform</p>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<!-- Register Card -->
|
| 17 |
+
<div class="glass-card p-8 rounded-2xl">
|
| 18 |
+
<form method="POST" action="/register" class="space-y-5">
|
| 19 |
+
<div>
|
| 20 |
+
<label for="username" class="block text-sm font-semibold text-slate-700 mb-1.5">Full Name</label>
|
| 21 |
+
<input type="text" name="username" id="username" required
|
| 22 |
+
class="w-full bg-slate-50 border border-slate-200 text-slate-800 rounded-xl px-4 py-3 focus:outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-100 transition placeholder-slate-400"
|
| 23 |
+
placeholder="Alex Johnson">
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div>
|
| 27 |
+
<label for="email" class="block text-sm font-semibold text-slate-700 mb-1.5">Email Address</label>
|
| 28 |
+
<input type="email" name="email" id="email" required
|
| 29 |
+
class="w-full bg-slate-50 border border-slate-200 text-slate-800 rounded-xl px-4 py-3 focus:outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-100 transition placeholder-slate-400"
|
| 30 |
+
placeholder="alex@company.com">
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div>
|
| 34 |
+
<label for="password" class="block text-sm font-semibold text-slate-700 mb-1.5">Password</label>
|
| 35 |
+
<input type="password" name="password" id="password" required minlength="6"
|
| 36 |
+
class="w-full bg-slate-50 border border-slate-200 text-slate-800 rounded-xl px-4 py-3 focus:outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-100 transition placeholder-slate-400"
|
| 37 |
+
placeholder="Min. 6 characters">
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<button type="submit" class="w-full btn-primary text-white font-semibold py-3 rounded-xl shadow-lg text-sm flex items-center justify-center gap-2">
|
| 41 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path></svg>
|
| 42 |
+
Create Account
|
| 43 |
+
</button>
|
| 44 |
+
</form>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<!-- Footer Link -->
|
| 48 |
+
<div class="mt-6 text-center">
|
| 49 |
+
<p class="text-slate-500 text-sm">Already have an account?
|
| 50 |
+
<a href="/login" class="text-brand-600 hover:text-brand-700 font-semibold transition">Sign In</a>
|
| 51 |
+
</p>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
{% endblock %}
|
templates/results.html
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="max-w-6xl mx-auto w-full px-4 sm:px-6 lg:px-8 mt-2 animate-fade-in-up">
|
| 4 |
+
|
| 5 |
+
<!-- Header -->
|
| 6 |
+
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
| 7 |
+
<div>
|
| 8 |
+
<h1 class="text-3xl font-extrabold text-slate-800 flex items-center gap-3">
|
| 9 |
+
<div class="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center shadow-md shadow-emerald-200">
|
| 10 |
+
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 11 |
+
</div>
|
| 12 |
+
Analysis Results
|
| 13 |
+
</h1>
|
| 14 |
+
<p class="text-slate-500 text-sm mt-1 ml-13">AI-powered resume analysis and skill extraction</p>
|
| 15 |
+
</div>
|
| 16 |
+
<div class="flex gap-3">
|
| 17 |
+
<a href="/" class="px-4 py-2.5 bg-white hover:bg-slate-50 text-slate-700 rounded-xl transition border border-slate-200 shadow-sm font-medium text-sm flex items-center gap-2">
|
| 18 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
| 19 |
+
Upload Another
|
| 20 |
+
</a>
|
| 21 |
+
<a href="/ranking" class="btn-primary text-white px-4 py-2.5 rounded-xl shadow-md font-medium text-sm flex items-center gap-2">
|
| 22 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg>
|
| 23 |
+
View Leaderboard
|
| 24 |
+
</a>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
{% if not candidate %}
|
| 29 |
+
<!-- Empty State -->
|
| 30 |
+
<div class="glass-card p-16 text-center rounded-2xl">
|
| 31 |
+
<div class="w-20 h-20 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
| 32 |
+
<svg class="w-10 h-10 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path></svg>
|
| 33 |
+
</div>
|
| 34 |
+
<h3 class="text-xl font-bold text-slate-700 mb-2">No candidate analyzed yet</h3>
|
| 35 |
+
<p class="text-slate-400 mb-6">Upload a resume to see AI-powered analysis results</p>
|
| 36 |
+
<a href="/" class="btn-primary text-white px-6 py-3 rounded-xl shadow-md font-semibold text-sm inline-flex items-center gap-2">
|
| 37 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
| 38 |
+
Upload Resume
|
| 39 |
+
</a>
|
| 40 |
+
</div>
|
| 41 |
+
{% else %}
|
| 42 |
+
|
| 43 |
+
<!-- Score + Candidate Info Row -->
|
| 44 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
| 45 |
+
|
| 46 |
+
<!-- Semantic Score Card -->
|
| 47 |
+
<div class="glass-card p-6 rounded-2xl flex flex-col items-center justify-center relative overflow-hidden group animate-fade-in-up">
|
| 48 |
+
<div class="absolute inset-0 bg-gradient-to-br from-brand-50/50 to-sky-50/50 opacity-0 group-hover:opacity-100 transition duration-500"></div>
|
| 49 |
+
<h3 class="text-slate-500 font-semibold mb-2 uppercase tracking-wider text-xs relative z-10">Semantic Match</h3>
|
| 50 |
+
|
| 51 |
+
<!-- Circular Score -->
|
| 52 |
+
<div class="relative w-36 h-36 flex items-center justify-center mt-2 z-10">
|
| 53 |
+
<svg class="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
|
| 54 |
+
<path class="text-slate-100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" stroke-width="2.5" />
|
| 55 |
+
<path
|
| 56 |
+
class="{% if candidate.match_score >= 70 %}text-emerald-500{% elif candidate.match_score >= 40 %}text-amber-500{% else %}text-brand-500{% endif %}"
|
| 57 |
+
stroke-dasharray="{{ candidate.match_score }}, 100"
|
| 58 |
+
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
| 59 |
+
fill="none" stroke="currentColor" stroke-width="2.5"
|
| 60 |
+
stroke-linecap="round"
|
| 61 |
+
style="filter: drop-shadow(0 0 6px {% if candidate.match_score >= 70 %}rgba(16,185,129,0.4){% elif candidate.match_score >= 40 %}rgba(245,158,11,0.4){% else %}rgba(99,102,241,0.4){% endif %});"
|
| 62 |
+
/>
|
| 63 |
+
</svg>
|
| 64 |
+
<div class="absolute flex flex-col items-center">
|
| 65 |
+
<span class="text-4xl font-extrabold text-slate-800">{{ candidate.match_score }}<span class="text-lg text-slate-400 font-medium">%</span></span>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
<p class="text-center text-xs text-slate-400 mt-4 px-4 relative z-10 leading-relaxed">
|
| 69 |
+
{% if candidate.job_description %}
|
| 70 |
+
AI Semantic Score against provided Job Description
|
| 71 |
+
{% else %}
|
| 72 |
+
Provide a Job Description to unlock semantic scoring
|
| 73 |
+
{% endif %}
|
| 74 |
+
</p>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<!-- Candidate Info Card -->
|
| 78 |
+
<div class="glass-card p-6 rounded-2xl lg:col-span-2 flex flex-col animate-fade-in-up-delay">
|
| 79 |
+
<h3 class="text-slate-500 font-semibold mb-3 uppercase tracking-wider text-xs">Candidate Profile</h3>
|
| 80 |
+
|
| 81 |
+
<div class="flex items-center gap-4 mb-5">
|
| 82 |
+
<div class="w-14 h-14 bg-gradient-to-br from-brand-500 to-sky-500 rounded-2xl flex items-center justify-center text-white text-xl font-bold shadow-lg shadow-brand-200">
|
| 83 |
+
{{ candidate.name[0]|upper }}
|
| 84 |
+
</div>
|
| 85 |
+
<div>
|
| 86 |
+
<h2 class="text-xl font-bold text-slate-800">{{ candidate.name }}</h2>
|
| 87 |
+
<p class="text-xs text-slate-400">{{ candidate.skills|length }} skills • {{ candidate.education|length }} education keywords • {{ candidate.experience|length }} experience indicators</p>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{% if candidate.ai_summary %}
|
| 92 |
+
<!-- Google Gen AI Summary -->
|
| 93 |
+
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 p-4 rounded-xl border border-blue-100 mb-5">
|
| 94 |
+
<div class="text-xs text-blue-600 uppercase font-bold mb-2 flex items-center gap-1.5">
|
| 95 |
+
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
| 96 |
+
AI Summary Overview
|
| 97 |
+
</div>
|
| 98 |
+
<p class="text-slate-700 text-sm italic leading-relaxed">{{ candidate.ai_summary }}</p>
|
| 99 |
+
</div>
|
| 100 |
+
{% endif %}
|
| 101 |
+
|
| 102 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 flex-grow">
|
| 103 |
+
<!-- Education -->
|
| 104 |
+
<div class="bg-gradient-to-br from-violet-50 to-purple-50 p-4 rounded-xl border border-violet-100">
|
| 105 |
+
<div class="text-xs text-violet-600 uppercase font-bold mb-2 flex items-center gap-1.5">
|
| 106 |
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z"></path></svg>
|
| 107 |
+
Education
|
| 108 |
+
</div>
|
| 109 |
+
{% if candidate.education %}
|
| 110 |
+
<div class="flex flex-wrap gap-1.5">
|
| 111 |
+
{% for e in candidate.education %}
|
| 112 |
+
<span class="px-2.5 py-1 bg-white/80 text-violet-700 border border-violet-200 rounded-lg text-xs font-medium">{{ e }}</span>
|
| 113 |
+
{% endfor %}
|
| 114 |
+
</div>
|
| 115 |
+
{% else %}
|
| 116 |
+
<span class="text-violet-400 text-xs italic">None explicitly detected</span>
|
| 117 |
+
{% endif %}
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<!-- Experience -->
|
| 121 |
+
<div class="bg-gradient-to-br from-teal-50 to-emerald-50 p-4 rounded-xl border border-teal-100">
|
| 122 |
+
<div class="text-xs text-teal-600 uppercase font-bold mb-2 flex items-center gap-1.5">
|
| 123 |
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
|
| 124 |
+
Experience
|
| 125 |
+
</div>
|
| 126 |
+
{% if candidate.experience %}
|
| 127 |
+
<div class="flex flex-wrap gap-1.5">
|
| 128 |
+
{% for ex in candidate.experience %}
|
| 129 |
+
<span class="px-2.5 py-1 bg-white/80 text-teal-700 border border-teal-200 rounded-lg text-xs font-medium">{{ ex }}</span>
|
| 130 |
+
{% endfor %}
|
| 131 |
+
</div>
|
| 132 |
+
{% else %}
|
| 133 |
+
<span class="text-teal-400 text-xs italic">None explicitly detected</span>
|
| 134 |
+
{% endif %}
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<!-- All Skills Section -->
|
| 141 |
+
<div class="glass-card p-6 rounded-2xl mb-6 animate-fade-in-up-delay">
|
| 142 |
+
<h3 class="text-slate-500 font-semibold mb-4 uppercase tracking-wider text-xs flex items-center gap-2">
|
| 143 |
+
<svg class="w-4 h-4 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path></svg>
|
| 144 |
+
All Extracted Skills ({{ candidate.skills|length }})
|
| 145 |
+
</h3>
|
| 146 |
+
<div class="flex flex-wrap gap-2">
|
| 147 |
+
{% for skill in candidate.skills %}
|
| 148 |
+
<span class="skill-tag px-3 py-1.5 bg-gradient-to-r from-brand-50 to-sky-50 text-brand-700 border border-brand-200 rounded-full text-xs font-semibold cursor-default">{{ skill }}</span>
|
| 149 |
+
{% endfor %}
|
| 150 |
+
{% if not candidate.skills %}
|
| 151 |
+
<span class="text-slate-400 text-sm italic">No skills detected</span>
|
| 152 |
+
{% endif %}
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<!-- Skills Gap Analysis -->
|
| 157 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
| 158 |
+
<!-- Matched Skills -->
|
| 159 |
+
<div class="glass-card p-6 rounded-2xl animate-fade-in-up-delay">
|
| 160 |
+
<h3 class="text-slate-500 font-semibold mb-4 uppercase tracking-wider text-xs flex items-center gap-2">
|
| 161 |
+
<span class="w-2.5 h-2.5 rounded-full bg-emerald-400 shadow-sm shadow-emerald-200"></span>
|
| 162 |
+
Matched Skills (JD)
|
| 163 |
+
</h3>
|
| 164 |
+
<div class="flex flex-wrap gap-2">
|
| 165 |
+
{% if candidate.skill_gaps.matched %}
|
| 166 |
+
{% for m in candidate.skill_gaps.matched %}
|
| 167 |
+
<span class="skill-tag px-3 py-1.5 bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-full font-semibold text-xs">✓ {{ m }}</span>
|
| 168 |
+
{% endfor %}
|
| 169 |
+
{% elif not candidate.job_description %}
|
| 170 |
+
<span class="text-slate-400 text-sm italic">Provide a Job Description to see skill matches</span>
|
| 171 |
+
{% else %}
|
| 172 |
+
<span class="text-slate-400 text-sm italic">No exact skill overlaps found</span>
|
| 173 |
+
{% endif %}
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<!-- Missing Skills -->
|
| 178 |
+
<div class="glass-card p-6 rounded-2xl animate-fade-in-up-delay-2">
|
| 179 |
+
<h3 class="text-slate-500 font-semibold mb-4 uppercase tracking-wider text-xs flex items-center gap-2">
|
| 180 |
+
<span class="w-2.5 h-2.5 rounded-full bg-red-400 shadow-sm shadow-red-200"></span>
|
| 181 |
+
Missing Skills (JD)
|
| 182 |
+
</h3>
|
| 183 |
+
<div class="flex flex-wrap gap-2">
|
| 184 |
+
{% if candidate.skill_gaps.missing %}
|
| 185 |
+
{% for m in candidate.skill_gaps.missing %}
|
| 186 |
+
<span class="skill-tag px-3 py-1.5 bg-red-50 text-red-600 border border-red-200 rounded-full font-semibold text-xs line-through decoration-red-300">{{ m }}</span>
|
| 187 |
+
{% endfor %}
|
| 188 |
+
{% elif not candidate.job_description %}
|
| 189 |
+
<span class="text-slate-400 text-sm italic">No Job Description provided for gap analysis</span>
|
| 190 |
+
{% else %}
|
| 191 |
+
<span class="text-emerald-600 text-sm font-medium">🎉 No skill gaps! All JD keywords present.</span>
|
| 192 |
+
{% endif %}
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<!-- Audio Transcription Section -->
|
| 198 |
+
<div id="audio-section" class="glass-card p-6 rounded-2xl mb-6 animate-fade-in-up-delay-2 relative overflow-hidden
|
| 199 |
+
{% if transcription_pending %}border-l-4 border-amber-400
|
| 200 |
+
{% elif candidate.audio_transcription and candidate.audio_transcription.error %}border-l-4 border-red-400
|
| 201 |
+
{% elif candidate.audio_transcription and candidate.audio_transcription.text %}border-l-4 border-emerald-400
|
| 202 |
+
{% else %}border-l-4 border-brand-400{% endif %}">
|
| 203 |
+
|
| 204 |
+
{% if transcription_pending %}
|
| 205 |
+
<!-- Processing State -->
|
| 206 |
+
<div class="flex items-start gap-4">
|
| 207 |
+
<div class="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 208 |
+
<div class="w-5 h-5 border-2 border-amber-500 border-t-transparent rounded-full animate-spin"></div>
|
| 209 |
+
</div>
|
| 210 |
+
<div class="flex-grow">
|
| 211 |
+
<h3 class="text-amber-700 font-bold text-sm mb-1">Transcription in Progress</h3>
|
| 212 |
+
<p class="text-slate-500 text-sm">Your audio is being transcribed with Whisper AI. This usually takes 30-60 seconds.</p>
|
| 213 |
+
<div class="mt-3 bg-amber-50 rounded-lg p-3 border border-amber-100">
|
| 214 |
+
<div class="flex items-center gap-2">
|
| 215 |
+
<div class="flex gap-1">
|
| 216 |
+
<div class="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style="animation-delay: 0s"></div>
|
| 217 |
+
<div class="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
|
| 218 |
+
<div class="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
|
| 219 |
+
</div>
|
| 220 |
+
<span class="text-amber-600 text-xs font-medium" id="poll-status">Checking transcription status...</span>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
<script>
|
| 226 |
+
// AJAX polling instead of full page reload
|
| 227 |
+
const candidateId = "{{ candidate._id }}";
|
| 228 |
+
function pollTranscription() {
|
| 229 |
+
fetch(`/api/transcription_status/${candidateId}`)
|
| 230 |
+
.then(r => r.json())
|
| 231 |
+
.then(data => {
|
| 232 |
+
if (data.status === 'completed' || data.status === 'failed') {
|
| 233 |
+
window.location.reload();
|
| 234 |
+
} else {
|
| 235 |
+
document.getElementById('poll-status').textContent = 'Still processing... checking again in 4s';
|
| 236 |
+
setTimeout(pollTranscription, 4000);
|
| 237 |
+
}
|
| 238 |
+
})
|
| 239 |
+
.catch(() => setTimeout(pollTranscription, 5000));
|
| 240 |
+
}
|
| 241 |
+
setTimeout(pollTranscription, 3000);
|
| 242 |
+
</script>
|
| 243 |
+
|
| 244 |
+
{% elif candidate.audio_transcription and candidate.audio_transcription.error %}
|
| 245 |
+
<!-- Error State -->
|
| 246 |
+
<div class="flex items-start gap-4">
|
| 247 |
+
<div class="w-10 h-10 bg-red-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 248 |
+
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 249 |
+
</div>
|
| 250 |
+
<div class="flex-grow">
|
| 251 |
+
<h3 class="text-red-700 font-bold text-sm mb-1">Transcription Failed</h3>
|
| 252 |
+
<p class="text-red-600 text-sm bg-red-50 p-3 rounded-lg border border-red-100 mt-2">{{ candidate.audio_transcription.error }}</p>
|
| 253 |
+
|
| 254 |
+
<!-- Retry Upload -->
|
| 255 |
+
<div class="mt-4">
|
| 256 |
+
<p class="text-slate-500 text-xs mb-2">Try uploading the audio again:</p>
|
| 257 |
+
<form action="/upload_audio" method="POST" enctype="multipart/form-data" class="flex flex-col sm:flex-row gap-3">
|
| 258 |
+
<input type="hidden" name="candidate_id" value="{{ candidate._id }}">
|
| 259 |
+
<div class="flex-grow">
|
| 260 |
+
<input type="file" name="audio" required accept="audio/*" class="w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-brand-50 file:text-brand-600 hover:file:bg-brand-100 transition cursor-pointer">
|
| 261 |
+
</div>
|
| 262 |
+
<button type="submit" class="btn-secondary text-white px-4 py-2 rounded-xl shadow-sm font-medium text-sm whitespace-nowrap flex items-center gap-2">
|
| 263 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
| 264 |
+
Retry Transcription
|
| 265 |
+
</button>
|
| 266 |
+
</form>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
{% elif candidate.audio_transcription and candidate.audio_transcription.text %}
|
| 272 |
+
<!-- Success State -->
|
| 273 |
+
<div class="flex items-start gap-4">
|
| 274 |
+
<div class="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 275 |
+
<svg class="w-5 h-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
|
| 276 |
+
</div>
|
| 277 |
+
<div class="flex-grow">
|
| 278 |
+
<h3 class="text-emerald-700 font-bold text-sm mb-1 flex items-center gap-2">
|
| 279 |
+
Audio Transcription Complete
|
| 280 |
+
<span class="px-2 py-0.5 bg-emerald-50 text-emerald-600 rounded-full text-xs font-medium border border-emerald-200">{{ candidate.audio_transcription.language|upper }}</span>
|
| 281 |
+
</h3>
|
| 282 |
+
<div class="mt-3 bg-gradient-to-r from-slate-50 to-emerald-50/50 p-4 rounded-xl border border-slate-200">
|
| 283 |
+
<p class="text-slate-700 leading-relaxed text-sm italic">"{{ candidate.audio_transcription.text }}"</p>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
{% else %}
|
| 289 |
+
<!-- Upload Audio State -->
|
| 290 |
+
<div class="flex items-start gap-4">
|
| 291 |
+
<div class="w-10 h-10 bg-brand-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 292 |
+
<svg class="w-5 h-5 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
|
| 293 |
+
</div>
|
| 294 |
+
<div class="flex-grow">
|
| 295 |
+
<h3 class="text-brand-700 font-bold text-sm mb-1">Attach Audio Introduction</h3>
|
| 296 |
+
<p class="text-slate-500 text-sm mb-4">Upload a short audio intro from this candidate. Whisper AI will transcribe it automatically.</p>
|
| 297 |
+
|
| 298 |
+
<form action="/upload_audio" method="POST" enctype="multipart/form-data" class="flex flex-col sm:flex-row gap-3">
|
| 299 |
+
<input type="hidden" name="candidate_id" value="{{ candidate._id }}">
|
| 300 |
+
<div class="flex-grow">
|
| 301 |
+
<input id="audio-file-input" type="file" name="audio" required accept="audio/*" class="w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-brand-50 file:text-brand-600 hover:file:bg-brand-100 transition cursor-pointer">
|
| 302 |
+
</div>
|
| 303 |
+
<button type="submit" class="btn-secondary text-white px-5 py-2.5 rounded-xl shadow-md font-semibold text-sm whitespace-nowrap flex items-center gap-2">
|
| 304 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
|
| 305 |
+
Transcribe Audio
|
| 306 |
+
</button>
|
| 307 |
+
</form>
|
| 308 |
+
<p class="text-xs text-slate-400 mt-2">Supported: MP3, WAV, M4A, FLAC, OGG, WEBM</p>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
{% endif %}
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
{% endif %}
|
| 315 |
+
</div>
|
| 316 |
+
{% endblock %}
|