Spaces:
Sleeping
Sleeping
File size: 14,472 Bytes
960eb7f f226eb6 960eb7f f226eb6 960eb7f f226eb6 960eb7f f226eb6 960eb7f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 | """
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)
|