Neha Singh commited on
Commit
263eb11
·
0 Parent(s):

resume-screening-system

Browse files
.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
+ ![Python](https://img.shields.io/badge/Python-3.9+-blue)
17
+ ![Flask](https://img.shields.io/badge/Flask-3.x-green)
18
+ ![Whisper](https://img.shields.io/badge/Whisper-OpenAI-orange)
19
+ ![MongoDB](https://img.shields.io/badge/MongoDB-Atlas-green)
20
+ ![Tailwind](https://img.shields.io/badge/Tailwind-CSS_v3-blue)
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 %}