Spaces:
Runtime error
Runtime error
initial commit
Browse files- LICENSE +21 -0
- README.md +197 -9
- app/__init__.py +23 -0
- app/__pycache__/__init__.cpython-313.pyc +0 -0
- app/__pycache__/code_generator.cpython-313.pyc +0 -0
- app/__pycache__/email_utils.cpython-313.pyc +0 -0
- app/__pycache__/models.cpython-313.pyc +0 -0
- app/__pycache__/ranker.cpython-313.pyc +0 -0
- app/__pycache__/routes.cpython-313.pyc +0 -0
- app/__pycache__/utils.cpython-313.pyc +0 -0
- app/code_generator.py +24 -0
- app/decorators.py +22 -0
- app/email_utils.py +37 -0
- app/models.py +30 -0
- app/ranker.py +22 -0
- app/routes.py +210 -0
- app/routes/api.py +25 -0
- app/templates/base.html +22 -0
- app/templates/dashboard.html +34 -0
- app/templates/docs.html +118 -0
- app/templates/generate_api.html +9 -0
- app/templates/generated_code.html +13 -0
- app/templates/login.html +20 -0
- app/templates/register.html +18 -0
- app/templates/resend_verification.html +12 -0
- app/utils.py +49 -0
- dockerfile +23 -0
- main.py +11 -0
- requirements.txt +12 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Webcodelab
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🧠 AI Resume Ranker API
|
| 2 |
+
|
| 3 |
+
An intelligent, production-ready Resume Ranking API for developers, recruiters, and HR tech platforms.
|
| 4 |
+
Inspired by Stripe & Paystack API experiences — complete with authentication, dashboard, and live testing interface.
|
| 5 |
+
|
| 6 |
+
Built with **Flask + Sentence Transformers + PDF Parsing + Semantic AI**, this API helps you automatically score and rank resumes against any job description using powerful language understanding models.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 🚀 Features
|
| 11 |
+
|
| 12 |
+
- ✅ **API Key Authentication** with secure Bearer token format
|
| 13 |
+
- 🧾 **Semantic Resume Ranking** using transformer embeddings (not just keyword matching)
|
| 14 |
+
- 📤 Supports **batch PDF uploads**
|
| 15 |
+
- 📬 **JSON API responses** with ranked relevance scores
|
| 16 |
+
- 🧪 **Live Testing Interface** on the dashboard
|
| 17 |
+
- 🔐 **Secure User System** with login, signup, hashed passwords
|
| 18 |
+
- 🔁 **API Key Regeneration** from the dashboard
|
| 19 |
+
- 📚 Beautiful **API Documentation Page** with copyable code samples
|
| 20 |
+
- 🧰 **Try It Now**: Upload resumes + job text and see live scoring
|
| 21 |
+
- 📈 Ready for usage tracking, analytics, or rate limiting
|
| 22 |
+
- 💼 Built for scaling into a full SaaS product
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 🌐 Live Demo
|
| 27 |
+
|
| 28 |
+
> Coming soon: hosted on Render or Railway (e.g., [https://resume-ranker.example.com](https://resume-ranker.example.com))
|
| 29 |
+
|
| 30 |
---
|
| 31 |
+
|
| 32 |
+
## 📦 API Overview
|
| 33 |
+
|
| 34 |
+
### 🔗 Endpoint
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
POST /api/rank-resumes
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### 🔐 Authentication
|
| 43 |
+
|
| 44 |
+
Send your API key in the request header:
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Authorization: Bearer amn=your\_api\_key\_here
|
| 49 |
+
|
| 50 |
+
````
|
| 51 |
+
|
| 52 |
+
### 📤 Request Parameters
|
| 53 |
+
|
| 54 |
+
| Name | Type | Required | Description |
|
| 55 |
+
|-----------------|------------------|----------|--------------------------------------|
|
| 56 |
+
| resumes | `file[] (PDF)` | ✅ Yes | One or more PDF resumes |
|
| 57 |
+
| job_description | `string` | ✅ Yes | Job description text to compare with |
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## 📥 Example Usage
|
| 62 |
+
|
| 63 |
+
### 🧪 Try It via cURL
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
curl -X POST https://yourdomain.com/api/rank-resumes \
|
| 67 |
+
-H "Authorization: Bearer amn=sk_live_abc123xyz" \
|
| 68 |
+
-F "resumes=@resume1.pdf" \
|
| 69 |
+
-F "resumes=@resume2.pdf" \
|
| 70 |
+
-F "job_description=We are hiring a backend Django developer..."
|
| 71 |
+
````
|
| 72 |
+
|
| 73 |
+
### 📦 Sample Response
|
| 74 |
+
|
| 75 |
+
```json
|
| 76 |
+
{
|
| 77 |
+
"results": [
|
| 78 |
+
{
|
| 79 |
+
"filename": "resume1.pdf",
|
| 80 |
+
"score": 0.8745
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"filename": "resume2.pdf",
|
| 84 |
+
"score": 0.6721
|
| 85 |
+
}
|
| 86 |
+
],
|
| 87 |
+
"count": 2,
|
| 88 |
+
"requested_by": "user@example.com"
|
| 89 |
+
}
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## 🛠 Tech Stack
|
| 95 |
+
|
| 96 |
+
* **Backend:** Flask, Blueprints, Jinja2
|
| 97 |
+
* **AI:** SentenceTransformers (`all-MiniLM-L6-v2`)
|
| 98 |
+
* **PDF Parsing:** PyMuPDF
|
| 99 |
+
* **Auth:** Flask sessions + hashed passwords
|
| 100 |
+
* **Database:** SQLite (dev) / PostgreSQL-ready
|
| 101 |
+
* **Frontend:** Bootstrap 5 + custom Jinja templates
|
| 102 |
+
* **Hosting:** Render / Railway / PythonAnywhere
|
| 103 |
+
* **Docs UI:** Fully embedded HTML + live form
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## 🧪 Try It Now (via Dashboard)
|
| 108 |
+
|
| 109 |
+
* Register/Login
|
| 110 |
+
* View and copy your API Key
|
| 111 |
+
* Paste a job description
|
| 112 |
+
* Upload 1–5 resumes (PDF)
|
| 113 |
+
* Get AI-scored ranking results instantly
|
| 114 |
+
|
| 115 |
---
|
| 116 |
|
| 117 |
+
## 🧑💻 Developer Setup
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
git clone https://github.com/yourusername/resume-ranker-api.git
|
| 121 |
+
cd resume-ranker-api
|
| 122 |
+
python -m venv venv
|
| 123 |
+
source venv/bin/activate # or venv\Scripts\activate on Windows
|
| 124 |
+
pip install -r requirements.txt
|
| 125 |
+
flask run
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Perfect — thanks for the clarification.
|
| 129 |
+
|
| 130 |
+
You're right to **remove the real email password** before pushing to GitHub — sensitive credentials should never be committed. Instead, include **placeholder values** in the `.env` section of your `README.md`.
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## ✅ Updated `.env` Example for README
|
| 135 |
+
|
| 136 |
+
Here's the correct `.env` block to include in the `README.md`:
|
| 137 |
+
|
| 138 |
+
```ini
|
| 139 |
+
FLASK_ENV=development
|
| 140 |
+
SECRET_KEY=your_secret_key_here
|
| 141 |
+
DATABASE_URL=sqlite:///db.sqlite3
|
| 142 |
+
|
| 143 |
+
# ✅ Email credentials for verification system
|
| 144 |
+
EMAIL_USER=your_email@gmail.com
|
| 145 |
+
EMAIL_PASS=your_app_password_here
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
> 📌 **Note:** Use an **App Password** (not your actual Gmail password) if you're using Gmail SMTP. App passwords are safer and Gmail-compliant.
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## 🔒 Additional Security Tip
|
| 153 |
+
|
| 154 |
+
To prevent accidental exposure:
|
| 155 |
+
|
| 156 |
+
* Add `.env` to your `.gitignore`
|
| 157 |
+
* Use environment variables in production (Render, Railway, Fly.io all support this)
|
| 158 |
+
|
| 159 |
+
**`.gitignore` entry:**
|
| 160 |
+
|
| 161 |
+
.env
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
---
|
| 165 |
+
|
| 166 |
+
## ✅ Bonus (Optional): Mention in README
|
| 167 |
+
|
| 168 |
+
You can also add a small note under **Developer Setup** in your README:
|
| 169 |
+
|
| 170 |
+
> 📧 **Note:** To enable email verification links, set `EMAIL_USER` and `EMAIL_PASS` in your `.env`. We recommend using an App Password with Gmail or a transactional email provider like Mailgun or SendGrid.
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
## 💡 Future Features
|
| 174 |
+
|
| 175 |
+
* ✅ API usage tracking
|
| 176 |
+
* ⏳ Rate limiting (per API key)
|
| 177 |
+
* 📊 Dashboard analytics
|
| 178 |
+
* 🧠 Resume summarization API
|
| 179 |
+
* 📁 CSV/JSON result export
|
| 180 |
+
* 🧩 SDKs for Python & JS
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## 🤝 Contributing
|
| 185 |
+
|
| 186 |
+
Pull requests are welcome! Let's build the future of AI recruiting tools together.
|
| 187 |
+
|
| 188 |
+
---
|
| 189 |
+
|
| 190 |
+
## 📜 License
|
| 191 |
+
|
| 192 |
+
MIT License
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## 📬 Contact
|
| 197 |
+
|
| 198 |
+
Made with ❤️ by [Muhammad Aminu Umar](mailto:webcodelabb@gmail.com)
|
| 199 |
+
🔗 [LinkedIn](https://linkedin.com/in/webcodelab) | [GitHub](https://github.com/webcodelabb)
|
| 200 |
+
|
app/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask
|
| 2 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
db = SQLAlchemy()
|
| 6 |
+
|
| 7 |
+
def create_app():
|
| 8 |
+
app = Flask(__name__)
|
| 9 |
+
app.config['SECRET_KEY'] = 'your-secret-key'
|
| 10 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
|
| 11 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 12 |
+
|
| 13 |
+
db.init_app(app)
|
| 14 |
+
|
| 15 |
+
with app.app_context():
|
| 16 |
+
from .models import User
|
| 17 |
+
db.create_all()
|
| 18 |
+
|
| 19 |
+
# ✅ Import and register the blueprint
|
| 20 |
+
from .routes import auth
|
| 21 |
+
app.register_blueprint(auth)
|
| 22 |
+
|
| 23 |
+
return app
|
app/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (1.14 kB). View file
|
|
|
app/__pycache__/code_generator.cpython-313.pyc
ADDED
|
Binary file (1.09 kB). View file
|
|
|
app/__pycache__/email_utils.cpython-313.pyc
ADDED
|
Binary file (1.74 kB). View file
|
|
|
app/__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (2.85 kB). View file
|
|
|
app/__pycache__/ranker.cpython-313.pyc
ADDED
|
Binary file (1.44 kB). View file
|
|
|
app/__pycache__/routes.cpython-313.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
app/__pycache__/utils.cpython-313.pyc
ADDED
|
Binary file (3.21 kB). View file
|
|
|
app/code_generator.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def generate_ranker_code():
|
| 2 |
+
flask_code = '''@app.route('/rank-resumes', methods=['POST'])
|
| 3 |
+
def rank_resumes():
|
| 4 |
+
resumes = request.files.getlist('resumes')
|
| 5 |
+
job_description = request.form.get('job_description')
|
| 6 |
+
|
| 7 |
+
# Placeholder logic — replace with your ranking algorithm
|
| 8 |
+
ranked = [resume.filename for resume in resumes]
|
| 9 |
+
return jsonify({
|
| 10 |
+
"job_description": job_description,
|
| 11 |
+
"ranked_resumes": ranked
|
| 12 |
+
})'''
|
| 13 |
+
|
| 14 |
+
html_code = '''<form method="POST" action="/rank-resumes" enctype="multipart/form-data">
|
| 15 |
+
<label>Upload Resumes:</label><br>
|
| 16 |
+
<input type="file" name="resumes" multiple required><br><br>
|
| 17 |
+
|
| 18 |
+
<label>Paste Job Description:</label><br>
|
| 19 |
+
<textarea name="job_description" rows="6" cols="50" required></textarea><br><br>
|
| 20 |
+
|
| 21 |
+
<button type="submit">Rank Resumes</button>
|
| 22 |
+
</form>'''
|
| 23 |
+
|
| 24 |
+
return flask_code, html_code
|
app/decorators.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# utils/auth.py
|
| 2 |
+
from functools import wraps
|
| 3 |
+
from flask import request, jsonify
|
| 4 |
+
from .models import User # adjust import to your structure
|
| 5 |
+
|
| 6 |
+
def require_api_key(f):
|
| 7 |
+
@wraps(f)
|
| 8 |
+
def decorated_function(*args, **kwargs):
|
| 9 |
+
auth_header = request.headers.get('Authorization')
|
| 10 |
+
|
| 11 |
+
if not auth_header or not auth_header.startswith('Bearer '):
|
| 12 |
+
return jsonify({"error": "Missing or invalid Authorization header."}), 401
|
| 13 |
+
|
| 14 |
+
token = auth_header.split("Bearer ")[1].strip()
|
| 15 |
+
|
| 16 |
+
user = User.query.filter_by(api_key=token).first()
|
| 17 |
+
if not user:
|
| 18 |
+
return jsonify({"error": "Invalid or revoked API key."}), 403
|
| 19 |
+
|
| 20 |
+
request.user = user # attach user to request for downstream use
|
| 21 |
+
return f(*args, **kwargs)
|
| 22 |
+
return decorated_function
|
app/email_utils.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import smtplib
|
| 2 |
+
from email.mime.text import MIMEText
|
| 3 |
+
from email.mime.multipart import MIMEMultipart
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
def send_verification_email(to_email, username, token):
|
| 7 |
+
from_email = os.getenv("EMAIL_USER")
|
| 8 |
+
app_password = os.getenv("EMAIL_PASS")
|
| 9 |
+
|
| 10 |
+
subject = "Verify your account"
|
| 11 |
+
verification_link = f"http://127.0.0.1:5000/verify/{token}"
|
| 12 |
+
body = f"""
|
| 13 |
+
Hi {username},
|
| 14 |
+
|
| 15 |
+
Thanks for registering! Please verify your email by clicking the link below:
|
| 16 |
+
|
| 17 |
+
{verification_link}
|
| 18 |
+
|
| 19 |
+
If you didn't sign up, you can ignore this email.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
msg = MIMEMultipart()
|
| 23 |
+
msg['From'] = from_email
|
| 24 |
+
msg['To'] = to_email
|
| 25 |
+
msg['Subject'] = subject
|
| 26 |
+
|
| 27 |
+
msg.attach(MIMEText(body, 'plain'))
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
|
| 31 |
+
server.login(from_email, app_password)
|
| 32 |
+
server.send_message(msg)
|
| 33 |
+
print(f"Verification email sent to {to_email}")
|
| 34 |
+
return True
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"Failed to send email: {e}")
|
| 37 |
+
return False
|
app/models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from . import db
|
| 2 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 3 |
+
import uuid
|
| 4 |
+
import secrets
|
| 5 |
+
|
| 6 |
+
class User(db.Model):
|
| 7 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 8 |
+
username = db.Column(db.String(150), unique=True, nullable=False)
|
| 9 |
+
email = db.Column(db.String(150), unique=True, nullable=False)
|
| 10 |
+
password_hash = db.Column(db.String(256), nullable=False)
|
| 11 |
+
|
| 12 |
+
is_verified = db.Column(db.Boolean, default=False)
|
| 13 |
+
verification_token = db.Column(db.String(64), unique=True, nullable=True, default=lambda: str(uuid.uuid4()))
|
| 14 |
+
|
| 15 |
+
api_key = db.Column(db.String(64), unique=True, nullable=True, default=lambda: secrets.token_hex(32))
|
| 16 |
+
|
| 17 |
+
def generate_api_key(self):
|
| 18 |
+
raw_key = secrets.token_hex(32)
|
| 19 |
+
self.api_key = f"amn={raw_key}"
|
| 20 |
+
db.session.commit()
|
| 21 |
+
|
| 22 |
+
def generate_verification_token(self):
|
| 23 |
+
self.verification_token = str(uuid.uuid4())
|
| 24 |
+
db.session.commit()
|
| 25 |
+
|
| 26 |
+
def set_password(self, password):
|
| 27 |
+
self.password_hash = generate_password_hash(password)
|
| 28 |
+
|
| 29 |
+
def check_password(self, password):
|
| 30 |
+
return check_password_hash(self.password_hash, password)
|
app/ranker.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# utils/ranker.py
|
| 2 |
+
from sentence_transformers import SentenceTransformer, util
|
| 3 |
+
from .utils import extract_text_from_pdf
|
| 4 |
+
|
| 5 |
+
model = SentenceTransformer('paraphrase-MiniLM-L6-v2')
|
| 6 |
+
# You can load this once globally
|
| 7 |
+
|
| 8 |
+
def rank_resumes_by_semantic_similarity(resume_files, job_description):
|
| 9 |
+
resume_texts = [extract_text_from_pdf(f) for f in resume_files]
|
| 10 |
+
job_embedding = model.encode(job_description, convert_to_tensor=True)
|
| 11 |
+
|
| 12 |
+
results = []
|
| 13 |
+
for file, resume_text in zip(resume_files, resume_texts):
|
| 14 |
+
resume_embedding = model.encode(resume_text, convert_to_tensor=True)
|
| 15 |
+
similarity = util.cos_sim(job_embedding, resume_embedding).item()
|
| 16 |
+
results.append({
|
| 17 |
+
"filename": file.filename,
|
| 18 |
+
"score": round(similarity, 4)
|
| 19 |
+
})
|
| 20 |
+
|
| 21 |
+
results.sort(key=lambda x: x['score'], reverse=True)
|
| 22 |
+
return results
|
app/routes.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, abort, jsonify
|
| 2 |
+
from .models import User
|
| 3 |
+
from . import db
|
| 4 |
+
from .email_utils import send_verification_email
|
| 5 |
+
from .code_generator import generate_ranker_code
|
| 6 |
+
from .utils import rank_resumes_by_similarity
|
| 7 |
+
import requests
|
| 8 |
+
from werkzeug.utils import secure_filename
|
| 9 |
+
from .ranker import rank_resumes_by_semantic_similarity
|
| 10 |
+
|
| 11 |
+
auth = Blueprint('auth', __name__)
|
| 12 |
+
|
| 13 |
+
@auth.route('/')
|
| 14 |
+
def home():
|
| 15 |
+
return jsonify({"message": "Flask API is live on Hugging Face!"})
|
| 16 |
+
|
| 17 |
+
@auth.route('/embed', methods=['POST'])
|
| 18 |
+
def embed():
|
| 19 |
+
data = request.json
|
| 20 |
+
text = data.get("text", "")
|
| 21 |
+
return jsonify({"embedding": [0.1, 0.2, 0.3]}) # Dummy
|
| 22 |
+
|
| 23 |
+
@auth.route('/rank-resumes', methods=['POST'])
|
| 24 |
+
def rank_resumes():
|
| 25 |
+
auth_header = request.headers.get('Authorization')
|
| 26 |
+
if not auth_header or not auth_header.startswith('Bearer '):
|
| 27 |
+
return jsonify({"error": "Missing or invalid Authorization header"}), 401
|
| 28 |
+
|
| 29 |
+
api_key = auth_header.split(" ")[1]
|
| 30 |
+
user = User.query.filter_by(api_key=api_key).first()
|
| 31 |
+
if not user:
|
| 32 |
+
return jsonify({"error": "Invalid API key"}), 403
|
| 33 |
+
|
| 34 |
+
resumes = request.files.getlist('resumes')
|
| 35 |
+
job_description = request.form.get('job_description')
|
| 36 |
+
|
| 37 |
+
if not resumes or not job_description:
|
| 38 |
+
return jsonify({"error": "Missing resumes or job description"}), 400
|
| 39 |
+
|
| 40 |
+
ranked = rank_resumes_by_semantic_similarity(resumes, job_description)
|
| 41 |
+
|
| 42 |
+
return jsonify({
|
| 43 |
+
"job_description": job_description,
|
| 44 |
+
"ranked_resumes": ranked
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@auth.route('/register', methods=['GET', 'POST'])
|
| 50 |
+
def register():
|
| 51 |
+
if request.method == 'POST':
|
| 52 |
+
username = request.form['username']
|
| 53 |
+
email = request.form['email']
|
| 54 |
+
password = request.form['password']
|
| 55 |
+
|
| 56 |
+
if User.query.filter_by(email=email).first():
|
| 57 |
+
flash('Email already registered.')
|
| 58 |
+
return redirect(url_for('auth.register'))
|
| 59 |
+
|
| 60 |
+
new_user = User(username=username, email=email)
|
| 61 |
+
new_user.set_password(password)
|
| 62 |
+
new_user.generate_verification_token()
|
| 63 |
+
db.session.add(new_user)
|
| 64 |
+
db.session.commit()
|
| 65 |
+
|
| 66 |
+
# Send verification email
|
| 67 |
+
if send_verification_email(email, username, new_user.verification_token):
|
| 68 |
+
flash('Registration successful! Please check your email to verify your account.')
|
| 69 |
+
else:
|
| 70 |
+
flash('Registration succeeded, but failed to send verification email.')
|
| 71 |
+
new_user.generate_api_key()
|
| 72 |
+
db.session.commit()
|
| 73 |
+
return redirect(url_for('auth.login'))
|
| 74 |
+
|
| 75 |
+
return render_template('register.html')
|
| 76 |
+
|
| 77 |
+
@auth.route('/login', methods=['GET', 'POST'])
|
| 78 |
+
def login():
|
| 79 |
+
if request.method == 'POST':
|
| 80 |
+
email = request.form['email']
|
| 81 |
+
password = request.form['password']
|
| 82 |
+
user = User.query.filter_by(email=email).first()
|
| 83 |
+
|
| 84 |
+
if user and user.check_password(password):
|
| 85 |
+
if not user.is_verified:
|
| 86 |
+
flash('Please verify your email before logging in.')
|
| 87 |
+
return redirect(url_for('auth.login'))
|
| 88 |
+
|
| 89 |
+
session['user_id'] = user.id
|
| 90 |
+
session['username'] = user.username
|
| 91 |
+
flash('Logged in successfully.')
|
| 92 |
+
return redirect(url_for('auth.dashboard'))
|
| 93 |
+
else:
|
| 94 |
+
flash('Invalid email or password.')
|
| 95 |
+
return redirect(url_for('auth.login'))
|
| 96 |
+
|
| 97 |
+
return render_template('login.html')
|
| 98 |
+
|
| 99 |
+
@auth.route('/resend-verification', methods=['GET', 'POST'])
|
| 100 |
+
def resend_verification():
|
| 101 |
+
if request.method == 'POST':
|
| 102 |
+
email = request.form['email']
|
| 103 |
+
user = User.query.filter_by(email=email).first()
|
| 104 |
+
|
| 105 |
+
if user:
|
| 106 |
+
if user.is_verified:
|
| 107 |
+
flash('Your email is already verified. You can log in.')
|
| 108 |
+
return redirect(url_for('auth.login'))
|
| 109 |
+
|
| 110 |
+
user.generate_verification_token()
|
| 111 |
+
db.session.commit()
|
| 112 |
+
if send_verification_email(user.email, user.username, user.verification_token):
|
| 113 |
+
flash('Verification email resent. Please check your inbox.')
|
| 114 |
+
else:
|
| 115 |
+
flash('Failed to send verification email. Try again later.')
|
| 116 |
+
else:
|
| 117 |
+
flash('No account found with that email.')
|
| 118 |
+
|
| 119 |
+
return redirect(url_for('auth.login'))
|
| 120 |
+
|
| 121 |
+
return render_template('resend_verification.html')
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@auth.route('/dashboard')
|
| 125 |
+
def dashboard():
|
| 126 |
+
if 'user_id' not in session:
|
| 127 |
+
flash('Please log in to access the dashboard.')
|
| 128 |
+
return redirect(url_for('auth.login'))
|
| 129 |
+
|
| 130 |
+
user = User.query.get(session['user_id'])
|
| 131 |
+
return render_template('dashboard.html', user=user)
|
| 132 |
+
|
| 133 |
+
@auth.route('/generate-api', methods=['GET', 'POST'])
|
| 134 |
+
def generate_api():
|
| 135 |
+
if 'user_id' not in session:
|
| 136 |
+
flash('Please log in to access the API generator.')
|
| 137 |
+
return redirect(url_for('auth.login'))
|
| 138 |
+
|
| 139 |
+
if request.method == 'POST':
|
| 140 |
+
flask_code, html_code = generate_ranker_code()
|
| 141 |
+
return render_template('generated_code.html', flask_code=flask_code, html_code=html_code)
|
| 142 |
+
|
| 143 |
+
return render_template('generate_api.html')
|
| 144 |
+
|
| 145 |
+
@auth.route('/regenerate-api-key', methods=['POST'])
|
| 146 |
+
def regenerate_api_key():
|
| 147 |
+
if 'user_id' not in session:
|
| 148 |
+
flash('Please log in.')
|
| 149 |
+
return redirect(url_for('auth.login'))
|
| 150 |
+
|
| 151 |
+
user = User.query.get(session['user_id'])
|
| 152 |
+
user.generate_api_key()
|
| 153 |
+
db.session.commit()
|
| 154 |
+
flash('API key regenerated.')
|
| 155 |
+
return redirect(url_for('auth.dashboard'))
|
| 156 |
+
|
| 157 |
+
@auth.route('/logout')
|
| 158 |
+
def logout():
|
| 159 |
+
session.clear()
|
| 160 |
+
flash('You have been logged out.')
|
| 161 |
+
return redirect(url_for('auth.login'))
|
| 162 |
+
|
| 163 |
+
@auth.route('/verify/<token>')
|
| 164 |
+
def verify_email(token):
|
| 165 |
+
user = User.query.filter_by(verification_token=token).first()
|
| 166 |
+
if user:
|
| 167 |
+
user.is_verified = True
|
| 168 |
+
user.verification_token = None
|
| 169 |
+
db.session.commit()
|
| 170 |
+
flash('Email verified! You can now log in.')
|
| 171 |
+
return redirect(url_for('auth.login'))
|
| 172 |
+
else:
|
| 173 |
+
flash('Invalid or expired verification link.')
|
| 174 |
+
return redirect(url_for('auth.register'))
|
| 175 |
+
|
| 176 |
+
@auth.route('/docs')
|
| 177 |
+
def docs():
|
| 178 |
+
return render_template('docs.html', response=None)
|
| 179 |
+
|
| 180 |
+
# Inside your auth.py or views.py
|
| 181 |
+
|
| 182 |
+
from flask import request, render_template, redirect, url_for, flash
|
| 183 |
+
from .models import User
|
| 184 |
+
from .utils import rank_resumes_by_similarity # import your real function
|
| 185 |
+
|
| 186 |
+
@auth.route('/try-api', methods=['POST'])
|
| 187 |
+
def try_api():
|
| 188 |
+
api_key = request.form.get('api_key')
|
| 189 |
+
resumes = request.files.getlist('resumes')
|
| 190 |
+
job_description = request.form.get('job_description')
|
| 191 |
+
|
| 192 |
+
if not api_key or not resumes or not job_description:
|
| 193 |
+
flash("All fields are required.")
|
| 194 |
+
return redirect(url_for('auth.docs'))
|
| 195 |
+
|
| 196 |
+
user = User.query.filter_by(api_key=api_key).first()
|
| 197 |
+
if not user:
|
| 198 |
+
return render_template('docs.html', response=" Invalid API key.")
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
# ✅ Use the actual ranking logic
|
| 202 |
+
results = rank_resumes_by_semantic_similarity(resumes, job_description)
|
| 203 |
+
|
| 204 |
+
return render_template(
|
| 205 |
+
'docs.html',
|
| 206 |
+
response=results,
|
| 207 |
+
job_description=job_description
|
| 208 |
+
)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
return render_template('docs.html', response=f" Error: {str(e)}")
|
app/routes/api.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# routes/api.py
|
| 2 |
+
from flask import Blueprint, request, jsonify
|
| 3 |
+
from ..ranker import rank_resumes_by_semantic_similarity # <-- updated import
|
| 4 |
+
from ..decorators import require_api_key
|
| 5 |
+
|
| 6 |
+
api = Blueprint('api', __name__)
|
| 7 |
+
|
| 8 |
+
@api.route('/api/rank-resumes', methods=['POST'])
|
| 9 |
+
@require_api_key
|
| 10 |
+
def rank_resumes():
|
| 11 |
+
job_description = request.form.get('job_description')
|
| 12 |
+
resume_files = request.files.getlist('resumes')
|
| 13 |
+
|
| 14 |
+
if not job_description or not resume_files:
|
| 15 |
+
return jsonify({'error': 'Missing job description or resumes'}), 400
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
results = rank_resumes_by_semantic_similarity(resume_files, job_description)
|
| 19 |
+
return jsonify({
|
| 20 |
+
'results': results,
|
| 21 |
+
'count': len(results),
|
| 22 |
+
'requested_by': request.user.email
|
| 23 |
+
})
|
| 24 |
+
except Exception as e:
|
| 25 |
+
return jsonify({'error': str(e)}), 500
|
app/templates/base.html
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<title>{% block title %}Auth App{% endblock %}</title>
|
| 5 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 6 |
+
</head>
|
| 7 |
+
<body class="container mt-5">
|
| 8 |
+
|
| 9 |
+
{% with messages = get_flashed_messages() %}
|
| 10 |
+
{% if messages %}
|
| 11 |
+
<div class="alert alert-info">
|
| 12 |
+
{% for message in messages %}
|
| 13 |
+
<div>{{ message }}</div>
|
| 14 |
+
{% endfor %}
|
| 15 |
+
</div>
|
| 16 |
+
{% endif %}
|
| 17 |
+
{% endwith %}
|
| 18 |
+
|
| 19 |
+
{% block content %}{% endblock %}
|
| 20 |
+
|
| 21 |
+
</body>
|
| 22 |
+
</html>
|
app/templates/dashboard.html
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Dashboard{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>Welcome, {{ user.username }}!</h2>
|
| 5 |
+
<p>This is your protected dashboard.</p>
|
| 6 |
+
<h5>Your API Key</h5>
|
| 7 |
+
<div class="input-group mb-3">
|
| 8 |
+
<input type="text" class="form-control" id="apiKey" value="{{ user.api_key }}" readonly>
|
| 9 |
+
<button class="btn btn-outline-secondary" type="button" onclick="copyApiKey()">Copy</button>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<form method="POST" action="{{ url_for('auth.regenerate_api_key') }}">
|
| 13 |
+
<button class="btn btn-warning">Regenerate API Key</button>
|
| 14 |
+
</form>
|
| 15 |
+
<div>
|
| 16 |
+
<p><a href="{{ url_for('auth.docs') }}">📘 View API Documentation</a></p>
|
| 17 |
+
|
| 18 |
+
</div>
|
| 19 |
+
<!-- <p><a href="{{ url_for('auth.docs') }}">📘 View API Documentation</a></p> -->
|
| 20 |
+
|
| 21 |
+
<a href="{{ url_for('auth.logout') }}" class="btn btn-danger">Logout</a>
|
| 22 |
+
|
| 23 |
+
<script>
|
| 24 |
+
function copyApiKey() {
|
| 25 |
+
const input = document.getElementById("apiKey");
|
| 26 |
+
input.select();
|
| 27 |
+
input.setSelectionRange(0, 99999); // For mobile
|
| 28 |
+
document.execCommand("copy");
|
| 29 |
+
alert("API key copied to clipboard!");
|
| 30 |
+
}
|
| 31 |
+
</script>
|
| 32 |
+
|
| 33 |
+
{% endblock %}
|
| 34 |
+
|
app/templates/docs.html
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}API Documentation{% endblock %}
|
| 3 |
+
|
| 4 |
+
{% block content %}
|
| 5 |
+
<h2 class="mb-4">📘 Resume Ranker API Documentation</h2>
|
| 6 |
+
|
| 7 |
+
<h4>🔗 Endpoint</h4>
|
| 8 |
+
<pre><code class="copyable">POST https://yourdomain.com/api/rank-resumes</code></pre>
|
| 9 |
+
|
| 10 |
+
<h4>🔐 Authentication</h4>
|
| 11 |
+
<p>Include your API key in the <code>Authorization</code> header:</p>
|
| 12 |
+
<pre><code class="copyable">Authorization: Bearer amn=your_api_key_here</code></pre>
|
| 13 |
+
|
| 14 |
+
<h4>📤 Request Parameters</h4>
|
| 15 |
+
<ul>
|
| 16 |
+
<li><strong>resumes</strong> (file[], required): One or more PDF resumes</li>
|
| 17 |
+
<li><strong>job_description</strong> (text, required): The job description to match against</li>
|
| 18 |
+
</ul>
|
| 19 |
+
|
| 20 |
+
<h4>📥 Example Request (curl)</h4>
|
| 21 |
+
<pre><code class="copyable">curl -X POST https://yourdomain.com/api/rank-resumes \
|
| 22 |
+
-H "Authorization: Bearer amn=your_api_key_here" \
|
| 23 |
+
-F "resumes=@cv1.pdf" \
|
| 24 |
+
-F "resumes=@cv2.pdf" \
|
| 25 |
+
-F "job_description=We are hiring a backend engineer..."</code></pre>
|
| 26 |
+
|
| 27 |
+
<h4>📥 Example Request (Python)</h4>
|
| 28 |
+
<pre><code class="copyable">import requests
|
| 29 |
+
|
| 30 |
+
headers = {
|
| 31 |
+
"Authorization": "Bearer amn=your_api_key_here"
|
| 32 |
+
}
|
| 33 |
+
files = [
|
| 34 |
+
('resumes', ('cv1.pdf', open('cv1.pdf', 'rb'), 'application/pdf')),
|
| 35 |
+
('resumes', ('cv2.pdf', open('cv2.pdf', 'rb'), 'application/pdf')),
|
| 36 |
+
]
|
| 37 |
+
data = {
|
| 38 |
+
"job_description": "We are hiring a backend engineer..."
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
response = requests.post("https://yourdomain.com/api/rank-resumes", headers=headers, files=files, data=data)
|
| 42 |
+
print(response.json())</code></pre>
|
| 43 |
+
|
| 44 |
+
<h4>📦 Sample Response</h4>
|
| 45 |
+
<pre><code class="copyable">{
|
| 46 |
+
"results": [
|
| 47 |
+
{
|
| 48 |
+
"filename": "cv2.pdf",
|
| 49 |
+
"score": 0.8745
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"filename": "cv1.pdf",
|
| 53 |
+
"score": 0.6721
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
"count": 2,
|
| 57 |
+
"requested_by": "user@example.com"
|
| 58 |
+
}</code></pre>
|
| 59 |
+
|
| 60 |
+
<hr class="my-5">
|
| 61 |
+
|
| 62 |
+
<h4>🧪 Try It Now</h4>
|
| 63 |
+
<form method="POST" action="{{ url_for('auth.try_api') }}" enctype="multipart/form-data">
|
| 64 |
+
<div class="mb-3">
|
| 65 |
+
<label for="api_key" class="form-label">API Key</label>
|
| 66 |
+
<input type="text" class="form-control" id="api_key" name="api_key" placeholder="amn=your_api_key_here" required>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="mb-3">
|
| 69 |
+
<label for="resumes" class="form-label">Upload Resumes (PDF)</label>
|
| 70 |
+
<input type="file" class="form-control" id="resumes" name="resumes" multiple required>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="mb-3">
|
| 73 |
+
<label for="job_description" class="form-label">Job Description</label>
|
| 74 |
+
<textarea class="form-control" id="job_description" name="job_description" rows="4" required></textarea>
|
| 75 |
+
</div>
|
| 76 |
+
<button type="submit" class="btn btn-success">Submit Test Request</button>
|
| 77 |
+
</form>
|
| 78 |
+
|
| 79 |
+
{% if response is iterable and response[0] is mapping %}
|
| 80 |
+
<hr class="my-5">
|
| 81 |
+
<h5>📬 API Response</h5>
|
| 82 |
+
<table class="table table-striped">
|
| 83 |
+
<thead>
|
| 84 |
+
<tr><th>Filename</th><th>Score</th></tr>
|
| 85 |
+
</thead>
|
| 86 |
+
<tbody>
|
| 87 |
+
{% for item in response %}
|
| 88 |
+
<tr>
|
| 89 |
+
<td>{{ item.filename }}</td>
|
| 90 |
+
<td>{{ item.score }}</td>
|
| 91 |
+
</tr>
|
| 92 |
+
{% endfor %}
|
| 93 |
+
</tbody>
|
| 94 |
+
</table>
|
| 95 |
+
{% elif response %}
|
| 96 |
+
<hr class="my-5">
|
| 97 |
+
<h5>📬 API Response</h5>
|
| 98 |
+
<pre><code>{{ response }}</code></pre>
|
| 99 |
+
{% endif %}
|
| 100 |
+
|
| 101 |
+
<p class="mt-4"><a href="{{ url_for('auth.dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a></p>
|
| 102 |
+
|
| 103 |
+
<!-- ✅ Copy Button Script -->
|
| 104 |
+
<script>
|
| 105 |
+
document.querySelectorAll('code.copyable').forEach(block => {
|
| 106 |
+
const button = document.createElement('button');
|
| 107 |
+
button.innerText = 'Copy';
|
| 108 |
+
button.className = 'btn btn-sm btn-outline-primary mb-2 float-end';
|
| 109 |
+
button.addEventListener('click', () => {
|
| 110 |
+
const text = block.innerText;
|
| 111 |
+
navigator.clipboard.writeText(text);
|
| 112 |
+
button.innerText = 'Copied!';
|
| 113 |
+
setTimeout(() => button.innerText = 'Copy', 1500);
|
| 114 |
+
});
|
| 115 |
+
block.parentElement.insertBefore(button, block);
|
| 116 |
+
});
|
| 117 |
+
</script>
|
| 118 |
+
{% endblock %}
|
app/templates/generate_api.html
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Generate Resume Ranker API{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>Generate Resume Ranker API</h2>
|
| 5 |
+
<p>This will generate a single endpoint that accepts resumes and a job description, and returns ranked results.</p>
|
| 6 |
+
<form method="POST">
|
| 7 |
+
<button type="submit" class="btn btn-primary">Generate API</button>
|
| 8 |
+
</form>
|
| 9 |
+
{% endblock %}
|
app/templates/generated_code.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Generated Code{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>Generated Flask Code</h2>
|
| 5 |
+
<pre><code>{{ flask_code }}</code></pre>
|
| 6 |
+
|
| 7 |
+
{% if html_code %}
|
| 8 |
+
<h2>Generated HTML Form</h2>
|
| 9 |
+
<pre><code>{{ html_code }}</code></pre>
|
| 10 |
+
{% endif %}
|
| 11 |
+
|
| 12 |
+
<a href="{{ url_for('auth.generate_api') }}" class="btn btn-secondary mt-3">Generate Another</a>
|
| 13 |
+
{% endblock %}
|
app/templates/login.html
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Login{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>Login</h2>
|
| 5 |
+
<form method="POST">
|
| 6 |
+
<div class="mb-3">
|
| 7 |
+
<input name="email" type="email" class="form-control" placeholder="Email" required>
|
| 8 |
+
</div>
|
| 9 |
+
<div class="mb-3">
|
| 10 |
+
<input name="password" type="password" class="form-control" placeholder="Password" required>
|
| 11 |
+
</div>
|
| 12 |
+
<button type="submit" class="btn btn-primary">Login</button>
|
| 13 |
+
</form>
|
| 14 |
+
<p class="mt-3">
|
| 15 |
+
Didn't get the verification email?
|
| 16 |
+
<a href="{{ url_for('auth.resend_verification') }}">Resend it</a>
|
| 17 |
+
</p>
|
| 18 |
+
|
| 19 |
+
<p class="mt-3">Don't have an account? <a href="{{ url_for('auth.register') }}">Register</a></p>
|
| 20 |
+
{% endblock %}
|
app/templates/register.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Register{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>Register</h2>
|
| 5 |
+
<form method="POST">
|
| 6 |
+
<div class="mb-3">
|
| 7 |
+
<input name="username" class="form-control" placeholder="Username" required>
|
| 8 |
+
</div>
|
| 9 |
+
<div class="mb-3">
|
| 10 |
+
<input name="email" type="email" class="form-control" placeholder="Email" required>
|
| 11 |
+
</div>
|
| 12 |
+
<div class="mb-3">
|
| 13 |
+
<input name="password" type="password" class="form-control" placeholder="Password" required>
|
| 14 |
+
</div>
|
| 15 |
+
<button type="submit" class="btn btn-success">Register</button>
|
| 16 |
+
</form>
|
| 17 |
+
<p class="mt-3">Already have an account? <a href="{{ url_for('auth.login') }}">Login</a></p>
|
| 18 |
+
{% endblock %}
|
app/templates/resend_verification.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Resend Verification{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>Resend Verification Email</h2>
|
| 5 |
+
<form method="POST">
|
| 6 |
+
<div class="mb-3">
|
| 7 |
+
<input name="email" type="email" class="form-control" placeholder="Enter your email" required>
|
| 8 |
+
</div>
|
| 9 |
+
<button type="submit" class="btn btn-warning">Resend Email</button>
|
| 10 |
+
</form>
|
| 11 |
+
<p class="mt-3"><a href="{{ url_for('auth.login') }}">Back to login</a></p>
|
| 12 |
+
{% endblock %}
|
app/utils.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fitz # PyMuPDF
|
| 2 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 3 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 4 |
+
|
| 5 |
+
def extract_text_from_pdf(file_storage):
|
| 6 |
+
with fitz.open(stream=file_storage.read(), filetype="pdf") as doc:
|
| 7 |
+
return " ".join([page.get_text() for page in doc])
|
| 8 |
+
|
| 9 |
+
def rank_resumes_by_similarity(resume_files, job_description):
|
| 10 |
+
resume_texts = [extract_text_from_pdf(f) for f in resume_files]
|
| 11 |
+
documents = [job_description] + resume_texts
|
| 12 |
+
|
| 13 |
+
vectorizer = TfidfVectorizer(stop_words='english')
|
| 14 |
+
tfidf_matrix = vectorizer.fit_transform(documents)
|
| 15 |
+
|
| 16 |
+
job_vector = tfidf_matrix[0]
|
| 17 |
+
resume_vectors = tfidf_matrix[1:]
|
| 18 |
+
|
| 19 |
+
similarities = cosine_similarity(job_vector, resume_vectors).flatten()
|
| 20 |
+
ranked = sorted(zip(resume_files, similarities), key=lambda x: x[1], reverse=True)
|
| 21 |
+
|
| 22 |
+
return [
|
| 23 |
+
{"filename": file.filename, "score": round(score, 4)}
|
| 24 |
+
for file, score in ranked
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# utils/auth.py
|
| 29 |
+
from functools import wraps
|
| 30 |
+
from flask import request, jsonify
|
| 31 |
+
from .models import User # adjust import to your structure
|
| 32 |
+
|
| 33 |
+
def require_api_key(f):
|
| 34 |
+
@wraps(f)
|
| 35 |
+
def decorated_function(*args, **kwargs):
|
| 36 |
+
auth_header = request.headers.get('Authorization')
|
| 37 |
+
|
| 38 |
+
if not auth_header or not auth_header.startswith('Bearer '):
|
| 39 |
+
return jsonify({"error": "Missing or invalid Authorization header."}), 401
|
| 40 |
+
|
| 41 |
+
token = auth_header.split("Bearer ")[1].strip()
|
| 42 |
+
|
| 43 |
+
user = User.query.filter_by(api_key=token).first()
|
| 44 |
+
if not user:
|
| 45 |
+
return jsonify({"error": "Invalid or revoked API key."}), 403
|
| 46 |
+
|
| 47 |
+
request.user = user # attach user to request for downstream use
|
| 48 |
+
return f(*args, **kwargs)
|
| 49 |
+
return decorated_function
|
dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dependencies
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy all files
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# Optional: avoid using cached Hugging Face models in root directory
|
| 13 |
+
ENV TRANSFORMERS_CACHE=/app/cache
|
| 14 |
+
ENV HF_HOME=/app/cache
|
| 15 |
+
|
| 16 |
+
# Set PYTHONPATH (useful if you're importing from app/)
|
| 17 |
+
ENV PYTHONPATH=/app
|
| 18 |
+
|
| 19 |
+
# HF Spaces default port is 7860
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
# Start server
|
| 23 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "main:app"]
|
main.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import create_app
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
app = create_app()
|
| 8 |
+
|
| 9 |
+
if __name__ == '__main__':
|
| 10 |
+
port = int(os.environ.get("PORT", 7860))
|
| 11 |
+
app.run(host='0.0.0.0', port=port)
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask
|
| 2 |
+
Flask-SQLAlchemy
|
| 3 |
+
Flask-Migrate
|
| 4 |
+
Werkzeug
|
| 5 |
+
python-dotenv
|
| 6 |
+
PyMuPDF
|
| 7 |
+
scikit-learn
|
| 8 |
+
requests
|
| 9 |
+
sentence-transformers
|
| 10 |
+
gunicorn
|
| 11 |
+
transformers
|
| 12 |
+
torch
|