resume-forge / server.py
aka38's picture
Upload 5 files
f226eb6 verified
"""
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)