Spaces:
Running
Running
| 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 | |
| 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. | |
| ) | |
| 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." | |
| } | |
| } | |
| } | |
| } | |
| 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) | |
| 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" | |
| } | |
| } |