resume-scanner / api.py
Silvio0's picture
Upload api.py
4aca0fd verified
Raw
History Blame Contribute Delete
21.5 kB
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Optional, Literal
from contextlib import asynccontextmanager
import io, nltk, PyPDF2
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sentence_transformers import SentenceTransformer, util
from function import clean_text # Custom helper for text cleaning
# Global variable to store the AI model in memory.
# It is initialized as None and loaded only once during startup to save resources.
embed_model = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Lifespan Context Manager (Startup & Shutdown Logic).
This function executes before the API starts receiving requests.
It is used to load heavy resources (like AI models) into memory once,
preventing the server from reloading the model for every single request.
"""
global embed_model
try:
# 1. Load the SBERT Model (Heavy Operation)
# We check if it's None to ensure it's loaded only once.
# 'paraphrase-multilingual-MiniLM-L12-v2' is used for semantic understanding.
if embed_model is None:
print("⏳ Loading AI Model...")
embed_model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
print("✅ AI Model Loaded Successfully.")
# 2. Ensure NLTK Stopwords are available
# Used by TF-IDF to remove common words (e.g., "the", "and").
try:
nltk.data.find('corpora/stopwords')
except LookupError:
print("⏳ Downloading NLTK Stopwords...")
nltk.download('stopwords')
print("✅ NLTK Data Ready.")
except Exception as e:
print(f"❌ Critical Error during startup: {e}")
# 'yield' acts as a separator.
# The code above runs on STARTUP.
# The app runs while 'yield' pauses here.
# Any code below 'yield' would run on SHUTDOWN.
yield
# Initialize the FastAPI application with the lifespan logic defined above
app = FastAPI(name="Resume Scanner", version="1.0.1", lifespan=lifespan)
# --- CORS CONFIGURATION ---
# Configure Cross-Origin Resource Sharing (CORS).
# This is essential to allow frontend applications (running on different ports/domains)
# to communicate with this backend API without security blocking.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows all origins (for development). Restrict in production.
allow_credentials=True, # Allows cookies and credentials.
allow_methods=["*"], # Allows all HTTP methods (GET, POST, etc.).
allow_headers=["*"], # Allows all headers.
)
@app.get("/")
def home():
"""
✅ **Root Endpoint: API Documentation**
This endpoint serves as the self-documentation for the API.
It details exactly how to interact with the endpoints, what keys to send,
and how to interpret the logic behind the 'Critical Skills Check'.
"""
base_url = "https://silvio0-resume-scanner.hf.space"
return {
"meta": {
"api_name": "Resume Scanner Pro API",
"version": "1.0.1",
"description": "Dual-engine ATS Scanner using TF-IDF (Strict Mode) and SBERT (Flexible AI Mode).",
"status": "Online",
"maintained_by": "Silvio Christian, Joe",
"base_url": base_url,
"documentation_url": f"{base_url}/docs"
},
"endpoints": {
# ------------------------------------------------------------------
# ENDPOINT 1: EXTRACT (PDF -> TEXT)
# ------------------------------------------------------------------
"/extract": {
"method": "POST",
"full_url": f"{base_url}/extract",
"summary": "PDF Text Extraction",
"description": "Upload a PDF resume to extract raw text. Checks for page count warnings.",
"content_type": "multipart/form-data",
"usage_example": f"curl -X POST '{base_url}/extract' -F 'cv_file=@/path/to/resume.pdf'",
"request_body": {
"cv_file": {
"type": "File (Binary)",
"required": True,
"format": ".pdf",
"description": "The resume file to be processed. Must be a valid PDF document."
}
},
"response_structure": {
"total_pages": "Integer - Total number of pages (Page Count) detected in the PDF.",
"Info": "String - Feedback advice based on the total page count (e.g., 'Optimal' if 1 page, 'Warning' if multiple pages).",
"cv_text": "String - The full raw text extracted from all pages, joined by newlines."
},
"error_responses": {
"400 - Encrypted PDF": "Returns '🔒 Encrypted PDF Detected' if the file is password protected.",
"400 - Invalid File": "Returns '❌ Invalid or Corrupt File' if the upload is not a valid PDF or header is broken.",
"400 - Empty File": "Returns '⚠️ Empty File' if the uploaded file contains no data (0 bytes).",
"400 - Processing Error": "Returns '⚠️ Processing Error' for unexpected parsing issues (e.g., PyPDF2 failure)."
}
},
# ------------------------------------------------------------------
# ENDPOINT 2: ANALYZE (SCORING & MATCHING)
# ------------------------------------------------------------------
"/analyze": {
"method": "POST",
"full_url": f"{base_url}/analyze",
"summary": "Resume vs Job Description Analysis",
"description": "Calculates match score, identifies keywords, and validates critical skills.",
"content_type": "application/x-www-form-urlencoded",
"usage_example": f"curl -X POST '{base_url}/analyze' -d 'cv_text=raw_text_here' -d 'jd_text=job_desc_here' -d 'mode=strict'",
"request_body": {
"cv_text": {
"type": "String",
"required": True,
"description": "Raw text of the CV (Result from /extract endpoint). System will auto-clean newlines."
},
"jd_text": {
"type": "String",
"required": True,
"description": "Raw text of the Job Description. System will auto-clean newlines."
},
"mode": {
"type": "String (Enum)",
"options": ["strict", "flexible"],
"default": "strict",
"description": "strict = Exact Keyword Match (TF-IDF). flexible = Contextual Match (AI/SBERT)."
},
"manual_keywords": {
"type": "List[String]",
"required": False,
"description": "Optional list of MUST-HAVE skills. Accepts comma-separated values (e.g. 'Python, SQL'). If empty, defaults to Top 5 JD words."
}
},
"response_structure": {
"score": "Float (0.00 - 100.00) - Final Match Percentage.",
"mode": "String - The analysis mode used.",
"missing_keywords": "List[String] - General keywords found in Job Description but missing in CV.",
"available_keywords": "List[String] - All keywords extracted from the Job Description (Sorted by importance).",
"default_critical_keywords": "List[String] - The Top 5 most frequent words in the Job Description (used as fallback).",
"critical_check": {
"description": "Validation result of the 'Must-Have' skills (Target vs Reality).",
"fields": {
"keywords_checked": "List[String] - The TARGET list of skills the system was searching for (either User Input or Top 5 Default).",
"missing_critical": "List[String] - The CRITICAL GAP. Skills from the 'keywords_checked' list that were NOT found in the CV.",
"status": "String - 'SAFE' (User has all critical skills) or 'RISK' (User is missing one or more critical skills)."
}
}
},
"error_responses": {
"400 - Missing Input": "Returns 'Invalid Input: Both Resume text and Job Description are required' if fields are missing.",
"400 - Empty Job Description": "Returns 'Invalid Input: Job Description cannot be empty' if JD contains only whitespace.",
"500 - Server Error": "Returns 'Server Error: AI Model failed to load' if the SBERT model is not ready.",
"500 - TF-IDF Empty Vocab": "Returns 'Insufficient Content' if JD is too short or contains only stop words (e.g. 'the', 'and').",
"500 - TF-IDF Data Error": "Returns 'Data Processing Error' if there is a structure mismatch during vectorization.",
"500 - Strict Calc Error": "Returns 'Strict Mode Error' if mathematical cosine similarity calculation fails.",
"500 - AI Memory Error": "Returns 'System Limit Reached' if the server runs out of memory (RAM) during flexible analysis.",
"500 - AI Data Error": "Returns 'Data Mismatch' or 'Flexible Mode Error' if embeddings cannot be compared."
}
}
}
}
@app.post("/extract")
async def extract_text_cv(cv_file: UploadFile = File(...)):
"""
📂 **PDF Text Extraction Endpoint**
**Functionality:**
1. Accepts a binary PDF file upload.
2. Extracts raw text from every page of the document.
3. Analyzes the total page count to provide ATS/Recruiter optimization advice.
**Input:**
- `cv_file`: A valid PDF file (multipart/form-data).
**Output (JSON):**
- `total_pages`: Integer count of pages detected.
- `Info`: Strategic advice regarding resume length (Single-page vs Multi-page).
- `cv_text`: The full extracted raw text content joined by newlines.
"""
try:
info = ""
cv_text = ""
# 1. Read File Stream
# Read the binary content of the uploaded file into memory
contents = await cv_file.read()
# Convert bytes to a file-like object (BytesIO) required by PyPDF2
buffer = io.BytesIO(contents)
# 2. Parse PDF
reader = PyPDF2.PdfReader(buffer)
all_pages = reader.pages
total_pages = len(all_pages)
# 3. Analyze Page Count & Generate Feedback
if total_pages > 0:
if total_pages > 1:
# Warning for multi-page resumes: Recruiters spend ~6 seconds scanning.
info = f"ℹ️ **Note ({total_pages} Pages Detected):** Recruiters and ATS scanners heavily prioritize the first page. Ensure your most critical skills and professional experience are listed on Page 1 to avoid being overlooked."
elif total_pages == 1:
# Confirmation for single-page resumes: Industry standard.
info = f"✅ **Optimal Length:** Single-page resume detected. This concise format is highly preferred by recruiters for rapid screening and parsing."
# 4. Extract Text Loop
# Iterate through all pages and append text to the main variable
for page in all_pages:
cv_text += page.extract_text() + "\n\n"
return {
"total_pages": total_pages,
"Info": info,
"cv_text": cv_text
}
except Exception as e:
# Graceful Error Handling: Catch specific PDF errors and return clear messages
error_msg = str(e).lower()
answer = "Failed to process PDF." # Default fallback message
# 1. Handle Password Protected / Encrypted PDFs
if "password" in error_msg or "encrypted" in error_msg:
answer = "🔒 Encrypted PDF Detected. This file is password protected. Please upload an unlocked (decrypted) version of your resume so the system can read it."
# 2. Handle Corrupt Files or Invalid Formats (e.g., renaming .docx to .pdf manually)
elif "pdf marker" in error_msg or "eof" in error_msg or "startxref" in error_msg:
answer = "❌ Invalid or Corrupt File. The file appears to be corrupted or is not a valid PDF format. Tip: If you renamed a Word file (.docx) to .pdf, please open it in Word and choose 'Save as PDF' instead."
# 3. Handle Empty Files (Zero bytes)
elif "empty" in error_msg or "no data" in error_msg:
answer = "⚠️ Empty File. The uploaded file appears to contain no data. Please check the file and try again."
# 4. Handle General/Unknown Errors
else:
answer = f"⚠️ Processing Error. An unexpected error occurred while reading the PDF. Technical Details: {str(e)}"
# Raise the HTTP Exception with the specific error message to the frontend
raise HTTPException(status_code=400, detail=answer)
@app.post("/analyze")
async def detect_cv(
cv_text: str = Form(...),
jd_text: str = Form(...),
mode: Literal["strict", "flexible"] = Form("strict"),
manual_keywords: Optional[List[str]] = Form(None)
):
"""
Analyzes the match between a Resume (CV) and a Job Description (JD).
Features:
- Text Cleaning & Preprocessing.
- Keyword Extraction (TF-IDF).
- Scoring (Strict/TF-IDF or Flexible/AI).
- Critical Skills Verification.
"""
# 1. Server Safety Check
if embed_model is None:
raise HTTPException(status_code=500, detail="Server Error: AI Model failed to load. Please check server logs.")
# 2. Input Validation
if not cv_text or not jd_text:
raise HTTPException(status_code=400, detail="Invalid Input: Both Resume text and Job Description are required.")
# 3. Ensure the Job Description contains actual text and is not just empty whitespace.
# This prevents the system from analyzing blank inputs (e.g., " ").
if jd_text.strip() == "":
raise HTTPException(status_code=400, detail="Invalid Input: Job Description cannot be empty. Please paste the text to proceed.")
# 4. Preprocessing
# Clean texts to remove artifacts and ensure consistent formatting for the models.
cv_text = clean_text(cv_text)
jd_text = clean_text(jd_text)
try:
# --- PHASE 1: KEYWORD EXTRACTION (TF-IDF) ---
# We use TF-IDF to identify important keywords from the Job Description
# and check their existence in the CV. This acts as the "ATS Logic".
# Initialize Vectorizer (removes common English stop words like 'the', 'and')
vectorizer = TfidfVectorizer(stop_words='english')
jd_vectors = vectorizer.fit_transform([jd_text])
cv_vectors = vectorizer.transform([cv_text])
# Extract features (words) and their calculated importance scores
desc_keywords = vectorizer.get_feature_names_out()
jd_array = jd_vectors.toarray()[0]
cv_array = cv_vectors.toarray()[0]
# Create a DataFrame to map Job Description importance vs CV presence
df_jd = pd.DataFrame({
"Keywords": desc_keywords,
"jd_score": jd_array, # Importance score in the Job Description
"cv_score": cv_array # Presence score in the CV (0.0 = missing)
})
# Filter: Identify words present in JD (score > 0) but completely missing in CV (score == 0)
df_missing = df_jd[df_jd["cv_score"] == 0]["Keywords"]
# --- PHASE 2: PRIORITY RANKING ---
# Sort keywords by 'jd_score'. High score = Word appears frequently in Job Desc = Critical Skill.
df_jd_sorted = df_jd.sort_values(by="jd_score", ascending=False)
all_jd_keywords = df_jd_sorted["Keywords"].to_list()
# Define default top 5 critical keywords (fallback if user doesn't specify any)
default_top_5 = all_jd_keywords[:5] if len(all_jd_keywords) > 5 else all_jd_keywords
except Exception as e:
# Graceful Error Handling for TF-IDF / Sklearn errors
error_msg = str(e).lower()
detail_msg = f"Analysis Error (TF-IDF): {str(e)}" # Default fallback
# 1. Handle Empty Vocabulary (Common TF-IDF Error)
# Occurs if JD contains only filler words (e.g., "The and or") or is too short.
if "empty vocabulary" in error_msg or "stop words" in error_msg:
detail_msg = "Insufficient Content: The Job Description is too short or contains only common filler words (e.g., 'the', 'and'). The system could not extract unique keywords. Please provide a more detailed Job Description."
# 2. Handle Data Dimension/Shape Errors
# Rare, but can happen if text processing fails midway.
elif "inconsistent" in error_msg or "dimension" in error_msg or "shape" in error_msg:
detail_msg = "Data Processing Error: A mismatch occurred between the Resume and Job Description data structures during calculation. Please try re-submitting the text."
# Raise the HTTP Exception with the specific error message
raise HTTPException(status_code=500, detail=detail_msg)
# --- PHASE 3: SCORING CALCULATION ---
final_score = 0.0
# Mode A: STRICT (Mathematical Exact Match)
# Uses Cosine Similarity on the TF-IDF vectors. Best for checking keyword density.
if mode == "strict":
try:
similarity_scores = cosine_similarity(cv_vectors, jd_vectors)[0][0]
final_score = round(float(similarity_scores) * 100, 2)
except Exception as e:
# Handle Calculation Errors (e.g., Vector dimension mismatch)
raise HTTPException(status_code=500, detail=f"Strict Mode Error: Failed to calculate mathematical similarity. Details: {str(e)}")
# Mode B: FLEXIBLE (AI Semantic Match)
# Uses SBERT Embeddings to understand context (e.g., "Python" ≈ "Coding").
elif mode == "flexible":
try:
# 1. Encode text into vector embeddings
desc_embeds = embed_model.encode(jd_text)
cv_embeds = embed_model.encode(cv_text)
# 2. Calculate Contextual Similarity
# .item() converts the tensor result into a standard Python float
similarity_scores = util.cos_sim(cv_embeds, desc_embeds).item()
final_score = round(float(similarity_scores) * 100, 2)
except Exception as e:
# Graceful Error Handling for AI Model
error_msg = str(e).lower()
detail_msg = f"Flexible Mode Error: {str(e)}" # Default fallback
# 1. Handle Memory/Resource Issues (Common in Free Tier Cloud)
if "cuda" in error_msg or "memory" in error_msg or "out of memory" in error_msg:
detail_msg = "System Limit Reached: The AI model encountered a memory limit while processing the text. Please try shortening the Job Description."
# 2. Handle Data Mismatch
elif "dimension" in error_msg or "shape" in error_msg:
detail_msg = "Data Mismatch: An error occurred while comparing the AI embeddings. Please check input text."
# Raise the HTTP Exception with the specific error message
raise HTTPException(status_code=500, detail=detail_msg)
# --- PHASE 4: CRITICAL SKILLS VERIFICATION ---
# Parses manual keywords provided by the frontend/user.
target_check = []
if manual_keywords:
for item in manual_keywords:
if not item: continue
# Handle comma-separated strings (e.g., "Python, SQL, AWS") sent as a single item
if "," in item:
splitted = [x.strip() for x in item.split(",") if x.strip()]
target_check.extend(splitted)
else:
if item.strip():
target_check.append(item.strip())
# Fallback: If no manual keywords provided, check against the Top 5 JD keywords
if not target_check:
target_check = default_top_5
# Check for missing critical skills (Case Insensitive Substring Search)
critical_missing = []
for word in target_check:
if word.lower() not in cv_text.lower():
critical_missing.append(word)
# --- PHASE 5: CONSTRUCT RESPONSE ---
return {
"score": final_score,
"mode": mode,
"missing_keywords": df_missing.to_list(), # Returns a flat list for easy frontend iteration
"available_keywords": all_jd_keywords, # Provided so the frontend can populate dropdown options
"default_critical_keywords": default_top_5, # Provided so the frontend knows the default values
"critical_check": {
"keywords_checked": target_check,
"missing_critical": critical_missing,
"status": "SAFE" if len(critical_missing) == 0 else "RISK"
}
}