Spaces:
Sleeping
Sleeping
| """ | |
| ResumeForge V2 — AI-Powered Resume Tailoring Server | |
| FastAPI backend that uses Google Gemini to analyze LaTeX resumes against | |
| job descriptions and suggest targeted edits, with PDF compilation support. | |
| """ | |
| import os | |
| import uuid | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Optional | |
| from fastapi import FastAPI, HTTPException, UploadFile, File | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from pydantic import BaseModel | |
| from google import genai | |
| from google.genai import types | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| from starlette.responses import Response | |
| import base64 | |
| import secrets | |
| import binascii | |
| # --------------------------------------------------------------------------- | |
| # App setup | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI(title="ResumeForge", version="2.0.0") | |
| # Access Control | |
| APP_USERNAME = os.getenv("APP_USERNAME", "admin") | |
| APP_PASSWORD = os.getenv("APP_PASSWORD", "resumeforge") | |
| class BasicAuthMiddleware(BaseHTTPMiddleware): | |
| """Protects all routes (including static files) with HTTP Basic Auth.""" | |
| async def dispatch(self, request, call_next): | |
| auth_header = request.headers.get("Authorization") | |
| if not auth_header or not auth_header.startswith("Basic "): | |
| return Response("Unauthorized", status_code=401, headers={"WWW-Authenticate": "Basic"}) | |
| try: | |
| decoded = base64.b64decode(auth_header[6:]).decode("utf-8") | |
| username, _, password = decoded.partition(":") | |
| correct_user = secrets.compare_digest(username, APP_USERNAME) | |
| correct_pass = secrets.compare_digest(password, APP_PASSWORD) | |
| if not (correct_user and correct_pass): | |
| return Response("Unauthorized", status_code=401, headers={"WWW-Authenticate": "Basic"}) | |
| except (binascii.Error, UnicodeDecodeError): | |
| return Response("Invalid Auth", status_code=401, headers={"WWW-Authenticate": "Basic"}) | |
| return await call_next(request) | |
| app.add_middleware(BasicAuthMiddleware) | |
| STATIC_DIR = Path(__file__).parent / "static" | |
| SAMPLE_DIR = Path(__file__).parent / "sample" | |
| BUILD_DIR = Path(__file__).parent / "builds" | |
| BUILD_DIR.mkdir(exist_ok=True) | |
| # Serve static files | |
| app.mount("/sample", StaticFiles(directory=str(SAMPLE_DIR)), name="sample") | |
| app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") | |
| # Find pdflatex | |
| PDFLATEX = shutil.which("pdflatex") or "/Library/TeX/texbin/pdflatex" | |
| # --------------------------------------------------------------------------- | |
| # Pydantic models for structured Gemini output | |
| # --------------------------------------------------------------------------- | |
| class ResumeEdit(BaseModel): | |
| section: str # e.g., "Professional Summary", "Experience > Company X" | |
| original_text: str # Exact text from the LaTeX source | |
| modified_text: str # Suggested replacement (valid LaTeX) | |
| display_original: str # Human-readable version of original (LaTeX stripped) | |
| display_modified: str # Human-readable version of modified (LaTeX stripped) | |
| reasoning: str # Why this change helps match the JD | |
| priority: str # "high", "medium", "low" | |
| class ResumeAnalysis(BaseModel): | |
| summary: str | |
| keyword_gaps: list[str] | |
| edits: list[ResumeEdit] | |
| match_score_before: int | |
| match_score_after: int | |
| # --------------------------------------------------------------------------- | |
| # Request / Response models | |
| # --------------------------------------------------------------------------- | |
| class AnalyzeRequest(BaseModel): | |
| resume_content: str | |
| job_description: str | |
| additional_instructions: str = "" | |
| class ApplyRequest(BaseModel): | |
| resume_content: str | |
| edits: list[dict] | |
| class CompileRequest(BaseModel): | |
| latex_content: str | |
| class SetKeyRequest(BaseModel): | |
| api_key: str | |
| # --------------------------------------------------------------------------- | |
| # Gemini client (lazy init) | |
| # --------------------------------------------------------------------------- | |
| _gemini_client: Optional[genai.Client] = None | |
| def get_gemini_client() -> genai.Client: | |
| """Get or create the Gemini client.""" | |
| global _gemini_client | |
| if _gemini_client is None: | |
| api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") | |
| if not api_key: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="GEMINI_API_KEY environment variable is not set. " | |
| "Get a free key at https://ai.google.dev" | |
| ) | |
| _gemini_client = genai.Client(api_key=api_key) | |
| return _gemini_client | |
| # --------------------------------------------------------------------------- | |
| # PDF Compilation | |
| # --------------------------------------------------------------------------- | |
| def compile_latex(latex_content: str) -> str: | |
| """ | |
| Compile LaTeX content to PDF using pdflatex. | |
| Returns the session_id to retrieve the PDF. | |
| """ | |
| session_id = str(uuid.uuid4())[:8] | |
| session_dir = BUILD_DIR / session_id | |
| session_dir.mkdir(exist_ok=True) | |
| tex_file = session_dir / "resume.tex" | |
| tex_file.write_text(latex_content, encoding="utf-8") | |
| try: | |
| # Run pdflatex twice for references/TOC | |
| for _ in range(2): | |
| result = subprocess.run( | |
| [PDFLATEX, "-interaction=nonstopmode", "-output-directory", str(session_dir), str(tex_file)], | |
| capture_output=True, text=True, timeout=30, | |
| cwd=str(session_dir), | |
| ) | |
| pdf_file = session_dir / "resume.pdf" | |
| if not pdf_file.exists(): | |
| # Extract useful error from log | |
| log_file = session_dir / "resume.log" | |
| error_lines = [] | |
| if log_file.exists(): | |
| for line in log_file.read_text().splitlines(): | |
| if line.startswith("!") or "Error" in line: | |
| error_lines.append(line) | |
| error_detail = "\n".join(error_lines[:5]) if error_lines else result.stderr[-500:] | |
| raise HTTPException(status_code=400, detail=f"LaTeX compilation failed:\n{error_detail}") | |
| return session_id | |
| except subprocess.TimeoutExpired: | |
| raise HTTPException(status_code=400, detail="LaTeX compilation timed out (30s limit)") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Compilation error: {str(e)}") | |
| # --------------------------------------------------------------------------- | |
| # System prompt for Gemini | |
| # --------------------------------------------------------------------------- | |
| SYSTEM_PROMPT = """You are an expert career coach, ATS optimization specialist, and senior recruiter for the job description provided. Your job is to analyze a LaTeX resume against the specific job description and suggest targeted edits from the eyes of a senior recruiter that will improve the resume's relevance and match score. | |
| IMPORTANT RULES: | |
| 1. Only suggest edits that are TRUTHFUL — rephrase and reframe existing experience, but never fabricate new experience or skills the candidate doesn't have. | |
| 2. Focus on: incorporating relevant keywords from the JD, strengthening action verbs, quantifying achievements where possible, and reordering bullet points to prioritize relevant experience. | |
| 3. The `original_text` field MUST contain the EXACT text from the LaTeX source (including LaTeX commands like \\textbf{}, \\item, etc.). Do not paraphrase or summarize the original. | |
| 4. The `modified_text` field should be valid LaTeX that can directly replace the original. | |
| 5. The `display_original` field should be the PLAIN TEXT version of original_text — strip all LaTeX commands (\\textbf, \\item, \\textit, etc.) so it reads like normal text that would appear on the PDF. | |
| 6. The `display_modified` field should be the PLAIN TEXT version of modified_text — same stripping of LaTeX commands. | |
| 7. Keep edits surgical — change only what's needed, preserve the resume's overall structure and LaTeX formatting. | |
| 8. Prioritize edits by impact: "high" = critical keyword/skill match, "medium" = improved phrasing, "low" = minor optimization. | |
| 9. Suggest 5-15 edits depending on how much the resume needs to change. | |
| 10. For the summary, briefly explain the overall strategy for tailoring this resume. | |
| 11. For keyword_gaps, list specific skills, technologies, or qualifications mentioned in the JD but missing from the resume.""" | |
| # --------------------------------------------------------------------------- | |
| # API Routes | |
| # --------------------------------------------------------------------------- | |
| async def serve_index(): | |
| """Serve the main UI.""" | |
| return FileResponse(str(STATIC_DIR / "index.html")) | |
| async def key_status(): | |
| """Check if a Gemini API key is configured.""" | |
| api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") | |
| return {"has_key": bool(api_key)} | |
| async def set_key(request: SetKeyRequest): | |
| """Set the Gemini API key at runtime.""" | |
| global _gemini_client | |
| os.environ["GEMINI_API_KEY"] = request.api_key | |
| _gemini_client = None | |
| return {"success": True} | |
| async def compile_resume(request: CompileRequest): | |
| """Compile LaTeX content to PDF and return session ID.""" | |
| session_id = compile_latex(request.latex_content) | |
| return {"session_id": session_id} | |
| async def get_pdf(session_id: str, download: bool = False, filename: str = "resume.pdf"): | |
| """Serve a compiled PDF by session ID.""" | |
| pdf_path = BUILD_DIR / session_id / "resume.pdf" | |
| if not pdf_path.exists(): | |
| raise HTTPException(status_code=404, detail="PDF not found") | |
| if download: | |
| return FileResponse(str(pdf_path), media_type="application/pdf", filename=filename) | |
| return FileResponse(str(pdf_path), media_type="application/pdf") | |
| async def analyze_resume(request: AnalyzeRequest): | |
| """ | |
| Analyze a LaTeX resume against a job description using Gemini. | |
| Returns structured edit suggestions with display-friendly text. | |
| """ | |
| client = get_gemini_client() | |
| user_prompt = f"""Here is the candidate's LaTeX resume: | |
| ```latex | |
| {request.resume_content} | |
| ``` | |
| Here is the job description they are applying to: | |
| ``` | |
| {request.job_description} | |
| ``` | |
| """ | |
| if request.additional_instructions: | |
| user_prompt += f""" | |
| Here are some additional instructions for customizing the resume: | |
| ``` | |
| {request.additional_instructions} | |
| ``` | |
| """ | |
| user_prompt += """ | |
| Analyze the resume against this job description and provide specific, actionable edits to tailor the resume for this position. Return your analysis as structured JSON. | |
| IMPORTANT: For each edit, provide both the raw LaTeX versions (original_text, modified_text) AND the plain-text display versions (display_original, display_modified) with all LaTeX commands stripped.""" | |
| try: | |
| response = client.models.generate_content( | |
| model="gemini-2.5-flash", | |
| contents=user_prompt, | |
| config=types.GenerateContentConfig( | |
| system_instruction=SYSTEM_PROMPT, | |
| temperature=0.3, | |
| response_mime_type="application/json", | |
| response_json_schema=ResumeAnalysis.model_json_schema(), | |
| thinking_config=types.ThinkingConfig( | |
| thinking_budget=2048 | |
| ), | |
| ), | |
| ) | |
| analysis = ResumeAnalysis.model_validate_json(response.text) | |
| return analysis.model_dump() | |
| except Exception as e: | |
| error_msg = str(e) | |
| print(f"[ERROR] Gemini API call failed: {type(e).__name__}: {error_msg}") | |
| from google.genai.errors import APIError | |
| if isinstance(e, APIError) and (e.code == 401 or e.code == 403): | |
| raise HTTPException( | |
| status_code=401, | |
| detail="Invalid Gemini API key. Please check your key and try again." | |
| ) | |
| raise HTTPException(status_code=500, detail=f"Gemini API error: {error_msg}") | |
| async def apply_edits(request: ApplyRequest): | |
| """ | |
| Apply accepted edits to the original LaTeX resume. | |
| Also compiles the result to PDF. | |
| """ | |
| modified = request.resume_content | |
| for edit in request.edits: | |
| original = edit.get("original_text", "") | |
| replacement = edit.get("modified_text", "") | |
| if original and replacement: | |
| modified = modified.replace(original, replacement, 1) | |
| # Compile the modified resume | |
| try: | |
| session_id = compile_latex(modified) | |
| except HTTPException: | |
| # If compilation fails, still return the tex but no PDF | |
| return {"modified_content": modified, "session_id": None} | |
| return {"modified_content": modified, "session_id": session_id} | |
| async def upload_resume(file: UploadFile = File(...)): | |
| """Handle LaTeX file upload and return its content.""" | |
| if not file.filename.endswith((".tex", ".latex", ".txt")): | |
| raise HTTPException(status_code=400, detail="Please upload a .tex, .latex, or .txt file.") | |
| content = await file.read() | |
| try: | |
| text = content.decode("utf-8") | |
| except UnicodeDecodeError: | |
| text = content.decode("latin-1") | |
| return {"content": text, "filename": file.filename} | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| import uvicorn | |
| print("\n🔨 ResumeForge V2 — AI Resume Tailoring Tool") | |
| print("=" * 48) | |
| api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") | |
| if api_key: | |
| print(f"✅ Gemini API key detected (***{api_key[-4:]})") | |
| else: | |
| print("⚠️ No GEMINI_API_KEY set. Get one free at https://ai.google.dev") | |
| if shutil.which("pdflatex") or Path(PDFLATEX).exists(): | |
| print(f"✅ pdflatex found at {PDFLATEX}") | |
| else: | |
| print("⚠️ pdflatex not found! PDF compilation will fail.") | |
| print(f"\n🌐 Open http://localhost:8000 in your browser\n") | |
| uvicorn.run(app, host="0.0.0.0", port=8000) | |