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)