""" 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 # --------------------------------------------------------------------------- @app.get("/") async def serve_index(): """Serve the main UI.""" return FileResponse(str(STATIC_DIR / "index.html")) @app.get("/api/key/status") 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)} @app.post("/api/key/set") 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} @app.post("/api/compile") 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} @app.get("/api/pdf/{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") @app.post("/api/analyze") 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}") @app.post("/api/apply") 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} @app.post("/api/upload") 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)