vbanwari commited on
Commit
3747cf3
Β·
verified Β·
1 Parent(s): 9e682f5

create app.py

Browse files
Files changed (1) hide show
  1. app.py +648 -0
app.py ADDED
@@ -0,0 +1,648 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ # Ensure writable HOME for containers
3
+ os.environ.setdefault("HOME", "/tmp")
4
+
5
+ import io
6
+ import json
7
+ import hashlib
8
+ import time
9
+ from typing import List, Tuple, Dict
10
+ from datetime import datetime
11
+ import requests
12
+ from bs4 import BeautifulSoup
13
+ import PyPDF2
14
+ from docx import Document
15
+ import gradio as gr
16
+ import difflib
17
+ import tempfile
18
+ from reportlab.lib.pagesizes import letter
19
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
20
+ from reportlab.lib.units import inch
21
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
22
+ from reportlab.lib import colors
23
+ import re
24
+
25
+ # --- Config / Helpers ---
26
+ VERSIONS_FILE = "/tmp/resume_versions.json"
27
+
28
+ def persist_versions(obj: Dict):
29
+ try:
30
+ with open(VERSIONS_FILE, "w") as f:
31
+ json.dump(obj, f)
32
+ except Exception:
33
+ pass
34
+
35
+ def load_versions() -> Dict:
36
+ try:
37
+ if os.path.exists(VERSIONS_FILE):
38
+ with open(VERSIONS_FILE, "r") as f:
39
+ return json.load(f)
40
+ except Exception:
41
+ pass
42
+ return {}
43
+
44
+ def save_debug_tmp(data: bytes, fname: str) -> str:
45
+ safe = hashlib.md5((fname + str(time.time())).encode()).hexdigest()[:8]
46
+ tmpdir = tempfile.gettempdir()
47
+ tmp_path = os.path.join(tmpdir, f"uploaded_{safe}_{fname}")
48
+ with open(tmp_path, "wb") as fw:
49
+ fw.write(data)
50
+ return tmp_path
51
+
52
+ def read_uploaded_file(f) -> Tuple[bytes, str]:
53
+ """Accepts Gradio file (path or file object). Returns bytes and filename."""
54
+ if f is None:
55
+ return None, None
56
+ try:
57
+ if isinstance(f, str):
58
+ with open(f, "rb") as fh:
59
+ data = fh.read()
60
+ name = os.path.basename(f)
61
+ return data, name
62
+ # file-like
63
+ data = f.read()
64
+ name = getattr(f, "name", "upload")
65
+ return data, name
66
+ except Exception:
67
+ return None, None
68
+
69
+ # --- Extraction ---
70
+ def extract_text_from_pdf_bytes(data: bytes) -> str:
71
+ try:
72
+ reader = PyPDF2.PdfReader(io.BytesIO(data))
73
+ parts = []
74
+ for p in reader.pages:
75
+ parts.append(p.extract_text() or "")
76
+ return "\n".join([p for p in parts if p.strip()]).strip()
77
+ except Exception as e:
78
+ return f"ERROR_PDF: {e}"
79
+
80
+ def extract_text_from_docx_bytes(data: bytes) -> str:
81
+ try:
82
+ doc = Document(io.BytesIO(data))
83
+ paras = [p.text for p in doc.paragraphs if p.text.strip()]
84
+ return "\n".join(paras).strip()
85
+ except Exception as e:
86
+ return f"ERROR_DOCX: {e}"
87
+
88
+ # --- Web scraping ---
89
+ def scrape_job_description_advanced(url: str) -> str:
90
+ try:
91
+ headers = {"User-Agent":"Mozilla/5.0"}
92
+ r = requests.get(url, headers=headers, timeout=12)
93
+ r.raise_for_status()
94
+ soup = BeautifulSoup(r.content, "html.parser")
95
+ for tag in soup(["script","style","nav","footer","header","form"]):
96
+ tag.decompose()
97
+ # Try common containers
98
+ jd = None
99
+ if 'linkedin.com' in url:
100
+ jd = soup.find('div', {'class':'description__text'}) or soup.find('div', {'class':'description'})
101
+ elif 'indeed.com' in url:
102
+ jd = soup.find('div', {'id':'jobDescriptionText'})
103
+ if not jd:
104
+ jd = soup.find('main') or soup.find('article') or soup.body
105
+ text = (jd.get_text(separator="\n", strip=True) if jd else soup.get_text(separator="\n", strip=True))
106
+ return text[:20000]
107
+ except Exception as e:
108
+ return f"ERROR_FETCH: {e}"
109
+
110
+ # --- PDF export ---
111
+ def export_to_pdf_bytes(content: str, title: str = "document") -> bytes:
112
+ buffer = io.BytesIO()
113
+ doc = SimpleDocTemplate(buffer, pagesize=letter,
114
+ rightMargin=0.6*inch, leftMargin=0.6*inch,
115
+ topMargin=0.6*inch, bottomMargin=0.6*inch)
116
+ styles = getSampleStyleSheet()
117
+ story = []
118
+ title_style = ParagraphStyle('Title', parent=styles['Heading1'], fontSize=16, textColor=colors.HexColor('#2b6cb0'), spaceAfter=10)
119
+ body = ParagraphStyle('Body', parent=styles['BodyText'], fontSize=10, spaceAfter=6)
120
+ story.append(Paragraph(title, title_style))
121
+ for line in content.splitlines():
122
+ if line.strip() == "":
123
+ story.append(Spacer(1, 0.08*inch))
124
+ else:
125
+ story.append(Paragraph(line, body))
126
+ doc.build(story)
127
+ buffer.seek(0)
128
+ return buffer.getvalue()
129
+
130
+ # --- LLM / provider wrappers ---
131
+ def _llm_endpoint_for(provider: str) -> str:
132
+ if provider == "OpenAI":
133
+ return "https://api.openai.com/v1/chat/completions"
134
+ if provider == "OpenRouter":
135
+ return "https://openrouter.ai/api/v1/chat/completions"
136
+ if provider == "Groq":
137
+ return "https://api.groq.com/openai/v1/chat/completions"
138
+ return "https://api.together.xyz/v1/chat/completions"
139
+
140
+ def llm_chat(api_endpoint: str, api_key: str, model: str, messages: List[dict], timeout=60):
141
+ api_key = api_key or os.environ.get("API_KEY", "")
142
+ if not api_key:
143
+ return {"error": "API key not provided. Set API_KEY env secret or paste in UI."}
144
+
145
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
146
+
147
+ if "groq.com" in api_endpoint:
148
+ payload = {
149
+ "model": model,
150
+ "messages": messages,
151
+ "max_tokens": 8000,
152
+ "temperature": 0.4
153
+ }
154
+ else:
155
+ payload = {
156
+ "model": model,
157
+ "messages": messages,
158
+ "max_tokens": 1500,
159
+ "temperature": 0.4
160
+ }
161
+
162
+ try:
163
+ r = requests.post(api_endpoint, headers=headers, json=payload, timeout=timeout)
164
+ if r.status_code >= 400:
165
+ return {"error": f"{r.status_code} {r.reason}", "detail": r.text}
166
+ r.raise_for_status()
167
+ return r.json()
168
+ except Exception as e:
169
+ return {"error": str(e)}
170
+
171
+ def extract_skills_from_text(job_text: str, provider: str, api_key: str, model: str) -> List[str]:
172
+ """Extract skills using LLM or heuristic fallback"""
173
+ from bs4 import BeautifulSoup
174
+
175
+ def parse_with_llm(text: str) -> List[str]:
176
+ endpoint = _llm_endpoint_for(provider)
177
+ system = (
178
+ "You are a JSON extractor. Given a job description, return ONLY a JSON array of skill/qualification strings. "
179
+ "Do NOT include any explanatory text."
180
+ )
181
+ user = f"Extract skills from the following JOB DESCRIPTION. Return only a JSON array of strings:\n\n{text}"
182
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
183
+ resp = llm_chat(endpoint, api_key, model, messages, timeout=30)
184
+ try:
185
+ if "error" in resp:
186
+ return []
187
+ content = resp["choices"][0]["message"]["content"]
188
+ skills = json.loads(content)
189
+ if isinstance(skills, list) and len(skills) >= 3:
190
+ return [s.strip() for s in skills if isinstance(s, str) and s.strip()]
191
+ except Exception:
192
+ return []
193
+ return []
194
+
195
+ def heuristic_extract(text: str) -> List[str]:
196
+ candidates = []
197
+ if "<" in text and ">" in text:
198
+ try:
199
+ soup = BeautifulSoup(text, "html.parser")
200
+ for li in soup.find_all("li"):
201
+ t = li.get_text(separator=" ", strip=True)
202
+ if t:
203
+ candidates.append(t)
204
+ for header in soup.find_all(["h2", "h3", "h4"]):
205
+ nxt = header.find_next_sibling()
206
+ if nxt and nxt.name in ("ul", "ol"):
207
+ for li in nxt.find_all("li"):
208
+ candidates.append(li.get_text(separator=" ", strip=True))
209
+ except Exception:
210
+ pass
211
+
212
+ lines = [l.strip() for l in text.splitlines() if l.strip()]
213
+ for i, line in enumerate(lines):
214
+ if re.match(r"^(\*|-|β€’|\u2022|\d+\.)\s+", line) or any(k in line.lower() for k in ["skills", "requirements", "qualifications", "experience", "responsibilities"]):
215
+ clean = re.sub(r"^(\*|-|β€’|\u2022|\d+\.)\s*", "", line)
216
+ parts = re.split(r"[;,β€’\|Β·]", clean)
217
+ for p in parts:
218
+ p = p.strip()
219
+ if len(p) > 1 and len(p.split()) <= 6:
220
+ candidates.append(p)
221
+ else:
222
+ if "," in line and len(line) < 200:
223
+ parts = [p.strip() for p in line.split(",") if p.strip()]
224
+ if len(parts) >= 2:
225
+ candidates.extend([p for p in parts if len(p.split()) <= 6])
226
+
227
+ token_pattern = re.compile(r"\b[A-Za-z0-9\+\#\.\-_/]{2,40}\b")
228
+ for match in token_pattern.findall(text):
229
+ tok = match.strip()
230
+ if len(tok) > 1 and not re.fullmatch(r"\d+", tok):
231
+ if re.search(r"[A-Z]|[\+\#\.\/-]", tok) or tok.lower() in ("sql", "aws", "docker", "kubernetes", "linux", "unix"):
232
+ candidates.append(tok)
233
+
234
+ seen = set()
235
+ out = []
236
+ for c in candidates:
237
+ s = re.sub(r"\s{2,}", " ", c).strip(" .;:-")
238
+ key = s.lower()
239
+ if key and key not in seen:
240
+ seen.add(key)
241
+ out.append(s)
242
+ if len(out) >= 200:
243
+ break
244
+
245
+ out = [o for o in out if len(o) > 1 and not re.fullmatch(r"(and|the|or|of|in|to)", o.lower())]
246
+ return out[:80]
247
+
248
+ if api_key:
249
+ llm_res = parse_with_llm(job_text)
250
+ if llm_res and len(llm_res) >= 3:
251
+ return [re.sub(r"\s{2,}", " ", s).strip() for s in llm_res]
252
+
253
+ heur = heuristic_extract(job_text or "")
254
+ return heur
255
+
256
+ def generate_tailored_resume_text(resume_text: str, job_desc: str, provider: str, api_key: str, model: str, style: str) -> str:
257
+ endpoint = _llm_endpoint_for(provider)
258
+ prompt = (
259
+ f"You are an expert resume writer. Using ONLY facts from the ORIGINAL RESUME, produce a tailored resume in plain text "
260
+ f"optimized for the following JOB DESCRIPTION. Keep truthful, do not invent. Template style: {style}.\n\n"
261
+ f"ORIGINAL RESUME:\n{resume_text}\n\nJOB DESCRIPTION:\n{job_desc}\n\nReturn the tailored resume."
262
+ )
263
+ messages = [{"role":"system","content":"You are a truthful resume-writing assistant."},{"role":"user","content":prompt}]
264
+ resp = llm_chat(endpoint, api_key, model, messages, timeout=90)
265
+ if "error" in resp:
266
+ return f"ERROR: {resp['error']}"
267
+ try:
268
+ return resp["choices"][0]["message"]["content"]
269
+ except Exception as e:
270
+ return f"ERROR_PARSE: {e}"
271
+
272
+ def generate_cover_letter_text(resume_text: str, job_desc: str, provider: str, api_key: str, model: str, company: str, position: str) -> str:
273
+ endpoint = _llm_endpoint_for(provider)
274
+ prompt = (
275
+ f"Write a 300-400 word cover letter using ONLY facts from the resume and tailored to this job.\n\n"
276
+ f"RESUME:\n{resume_text}\n\nJOB DESCRIPTION:\n{job_desc}\n\nCOMPANY: {company}\nPOSITION: {position}"
277
+ )
278
+ messages = [{"role":"system","content":"You are a cover letter writer."},{"role":"user","content":prompt}]
279
+ resp = llm_chat(endpoint, api_key, model, messages, timeout=90)
280
+ if "error" in resp:
281
+ return f"ERROR: {resp['error']}"
282
+ try:
283
+ return resp["choices"][0]["message"]["content"]
284
+ except Exception as e:
285
+ return f"ERROR_PARSE: {e}"
286
+
287
+ # --- ATS scoring ---
288
+ def calculate_ats_score(resume_text: str, job_skills: List[str]) -> Tuple[int, Dict]:
289
+ """Improved ATS scoring with fuzzy matching"""
290
+ from difflib import SequenceMatcher, get_close_matches
291
+
292
+ resume_text = (resume_text or "").lower()
293
+ resume_norm = re.sub(r"[^a-z0-9\s]", " ", resume_text)
294
+ resume_words = [w for w in resume_norm.split() if w]
295
+
296
+ details = {"matched": [], "missing": [], "scores": {}, "total": len(job_skills)}
297
+ if not job_skills:
298
+ return 0, details
299
+
300
+ total_score = 0.0
301
+ max_ngram = 4
302
+ ngrams = []
303
+ L = len(resume_words)
304
+ for size in range(1, min(max_ngram, L) + 1):
305
+ for i in range(0, L - size + 1):
306
+ ngrams.append(" ".join(resume_words[i : i + size]))
307
+
308
+ for skill in job_skills:
309
+ sk = (skill or "").lower().strip()
310
+ sk_norm = re.sub(r"[^a-z0-9\s]", " ", sk)
311
+ sk_tokens = [t for t in sk_norm.split() if t]
312
+ match_type = "no_match"
313
+ score = 0.0
314
+
315
+ pattern = r"\b" + re.escape(" ".join(sk_tokens)) + r"\b"
316
+ if re.search(pattern, resume_norm):
317
+ score = 1.0
318
+ match_type = "exact"
319
+ elif " ".join(sk_tokens) in resume_norm:
320
+ score = 0.95
321
+ match_type = "substring"
322
+ else:
323
+ if sk_tokens:
324
+ hits = sum(1 for t in sk_tokens if re.search(r"\b" + re.escape(t) + r"\b", resume_norm))
325
+ frac = hits / len(sk_tokens)
326
+ if len(sk_tokens) > 1 and frac >= 0.5:
327
+ score = 0.88
328
+ match_type = f"partial_tokens({hits}/{len(sk_tokens)})"
329
+ if score == 0.0:
330
+ best_ratio = 0.0
331
+ for cand in ngrams:
332
+ ratio = SequenceMatcher(None, " ".join(sk_tokens), cand).ratio()
333
+ if ratio > best_ratio:
334
+ best_ratio = ratio
335
+ if best_ratio >= 0.95:
336
+ break
337
+ if best_ratio >= 0.9:
338
+ score = 0.9
339
+ match_type = f"fuzzy({best_ratio:.2f})"
340
+ elif best_ratio >= 0.8:
341
+ score = 0.8
342
+ match_type = f"fuzzy({best_ratio:.2f})"
343
+
344
+ total_score += score
345
+ details["scores"][skill] = round(score, 2)
346
+ if score > 0:
347
+ details["matched"].append({"skill": skill, "score": round(score, 2), "match_type": match_type})
348
+ else:
349
+ suggestions = get_close_matches(" ".join(sk_tokens), ngrams, n=3, cutoff=0.6)
350
+ details["missing"].append({"skill": skill, "suggestions": suggestions})
351
+
352
+ overall = int((total_score / len(job_skills)) * 100)
353
+ details["overall"] = overall
354
+ return overall, details
355
+
356
+ def sanitize_skills(skills: List[str], job_text: str = "") -> List[str]:
357
+ """Clean noisy skills and merge fragments"""
358
+ if not skills:
359
+ return []
360
+
361
+ cleaned = []
362
+ for s in skills:
363
+ if not s or not isinstance(s, str):
364
+ continue
365
+ s = s.strip()
366
+ low = s.lower()
367
+ if len(s) <= 2:
368
+ continue
369
+ if re.search(r"https?://|www\.|@|\.com|\.de", low):
370
+ continue
371
+ if any(phr in low for phr in ["mode of employment", "about us", "faq", "show", "if ", "we ", "join ", "take "]):
372
+ continue
373
+ if low in ("you","your","are","we","us","our","take","join","show","about"):
374
+ continue
375
+ cleaned.append(s)
376
+
377
+ jt = (job_text or "").lower()
378
+ merged = []
379
+ i = 0
380
+ while i < len(cleaned):
381
+ curr = cleaned[i]
382
+ if len(curr.split()) == 1:
383
+ j = i + 1
384
+ candidate = curr
385
+ while j < len(cleaned) and len(candidate.split()) < 4:
386
+ next_tok = cleaned[j]
387
+ combined = candidate + " " + next_tok
388
+ if combined.lower() in jt:
389
+ candidate = combined
390
+ j += 1
391
+ else:
392
+ break
393
+ merged.append(candidate)
394
+ i = j
395
+ else:
396
+ merged.append(curr)
397
+ i += 1
398
+
399
+ seen = set()
400
+ final = []
401
+ for s in merged:
402
+ key = re.sub(r"\s+", " ", s).strip().lower()
403
+ if key and key not in seen:
404
+ seen.add(key)
405
+ final.append(s.strip())
406
+ return final
407
+
408
+ # --- Handlers ---
409
+ def handle_upload(file_obj):
410
+ data, fname = read_uploaded_file(file_obj)
411
+ if not data:
412
+ return "❌ Failed to read upload.", "", ""
413
+
414
+ if fname.lower().endswith(".pdf"):
415
+ text = extract_text_from_pdf_bytes(data)
416
+ else:
417
+ text = extract_text_from_docx_bytes(data)
418
+
419
+ return f"βœ… Uploaded {fname} ({len(data)} bytes)", fname, text
420
+
421
+ def handle_fetch_job(url: str):
422
+ if not url:
423
+ return "❌ No URL provided.", ""
424
+ text = scrape_job_description_advanced(url)
425
+ if text.startswith("ERROR_FETCH"):
426
+ return f"❌ {text}", ""
427
+ return f"βœ… Fetched job description ({len(text)} chars)", text
428
+
429
+ def handle_analyze(resume_text: str, job_text: str, provider: str, api_key: str, model_name: str):
430
+ if not job_text:
431
+ return "❌ No job description provided.", "", "N/A", "", []
432
+
433
+ skills = extract_skills_from_text(job_text, provider, api_key, model_name)
434
+ skills_clean = sanitize_skills(skills, job_text)
435
+
436
+ if resume_text and len(resume_text.strip()) > 20:
437
+ score, details = calculate_ats_score(resume_text, skills_clean)
438
+
439
+ # Format matched/missing for better display
440
+ matched_display = "\n".join([f"βœ“ {m['skill']} ({m['score']}) - {m['match_type']}" for m in details['matched'][:10]])
441
+ missing_display = "\n".join([f"βœ— {m['skill']}" for m in details['missing'][:10]])
442
+
443
+ return (
444
+ f"βœ… Extracted {len(skills_clean)} skills",
445
+ json.dumps(skills_clean, indent=2),
446
+ f"{score}%",
447
+ matched_display or "No matches",
448
+ missing_display or "All skills matched!"
449
+ )
450
+ else:
451
+ return f"βœ… Extracted {len(skills_clean)} skills", json.dumps(skills_clean, indent=2), "N/A", "", []
452
+
453
+ def handle_generate(resume_text: str, job_desc: str, provider: str, api_key: str, model_name: str, template_style: str, company: str, position: str):
454
+ if not (resume_text and job_desc):
455
+ return "❌ Missing resume or job description.", "", "", "N/A", "", []
456
+
457
+ if not api_key:
458
+ return "❌ API key required.", "", "", "N/A", "", []
459
+
460
+ tailored = generate_tailored_resume_text(resume_text, job_desc, provider, api_key, model_name, template_style)
461
+ cover = generate_cover_letter_text(resume_text, job_desc, provider, api_key, model_name, company, position)
462
+
463
+ # Post-generation ATS
464
+ skills = extract_skills_from_text(job_desc, provider, api_key, model_name)
465
+ skills_clean = sanitize_skills(skills, job_desc)
466
+ post_score, post_details = calculate_ats_score(tailored, skills_clean)
467
+
468
+ matched_display = "\n".join([f"βœ“ {m['skill']} ({m['score']}) - {m['match_type']}" for m in post_details['matched'][:10]])
469
+ missing_display = "\n".join([f"βœ— {m['skill']}" for m in post_details['missing'][:10]])
470
+
471
+ return (
472
+ "βœ… Generation complete!",
473
+ tailored,
474
+ cover,
475
+ f"{post_score}%",
476
+ matched_display or "No matches",
477
+ missing_display or "All skills matched!"
478
+ )
479
+
480
+ def make_pdf_download(text: str, title: str):
481
+ if not text:
482
+ return None
483
+ pdf = export_to_pdf_bytes(text, title or "document")
484
+ tmpdir = tempfile.gettempdir()
485
+ tmp = os.path.join(tmpdir, f"{hashlib.md5((title+str(time.time())).encode()).hexdigest()[:8]}.pdf")
486
+ with open(tmp, "wb") as fw:
487
+ fw.write(pdf)
488
+ return tmp
489
+
490
+ # --- Gradio UI ---
491
+ with gr.Blocks(title="Job Application Assistant", theme=gr.themes.Soft()) as demo:
492
+ gr.Markdown("""
493
+ # πŸ’Ό Job Application Assistant
494
+ ### AI-powered resume tailoring and ATS optimization
495
+ """)
496
+
497
+ with gr.Tabs() as tabs:
498
+ # ===== TAB 1: Setup =====
499
+ with gr.Tab("πŸ“‹ 1. Setup"):
500
+ gr.Markdown("### Configure your LLM provider and upload your resume")
501
+
502
+ with gr.Row():
503
+ with gr.Column(scale=1):
504
+ gr.Markdown("#### LLM Configuration")
505
+ provider = gr.Dropdown(
506
+ choices=["Groq", "Together AI", "OpenRouter", "OpenAI"],
507
+ value="Groq",
508
+ label="Provider"
509
+ )
510
+ model_name = gr.Textbox(
511
+ label="Model",
512
+ value="openai/gpt-oss-120b",
513
+ placeholder="e.g., openai/gpt-oss-120b"
514
+ )
515
+ api_key = gr.Textbox(
516
+ label="API Key",
517
+ type="password",
518
+ placeholder="Paste your API key or set API_KEY env variable"
519
+ )
520
+
521
+ with gr.Column(scale=1):
522
+ gr.Markdown("#### Upload Resume")
523
+ file_input = gr.File(
524
+ label="Upload your resume",
525
+ file_types=[".pdf", ".docx"]
526
+ )
527
+ upload_btn = gr.Button("πŸ“€ Process Upload", variant="primary", size="lg")
528
+ upload_status = gr.Textbox(label="Status", interactive=False)
529
+
530
+ resume_text_out = gr.Textbox(
531
+ label="πŸ“„ Extracted Resume Text",
532
+ lines=15,
533
+ placeholder="Your resume text will appear here after upload..."
534
+ )
535
+
536
+ # ===== TAB 2: Job Analysis =====
537
+ with gr.Tab("🎯 2. Job Analysis"):
538
+ gr.Markdown("### Analyze job posting and calculate initial ATS score")
539
+
540
+ with gr.Row():
541
+ with gr.Column():
542
+ job_url = gr.Textbox(
543
+ label="Job Posting URL (optional)",
544
+ placeholder="https://www.linkedin.com/jobs/view/..."
545
+ )
546
+ fetch_btn = gr.Button("πŸ”— Fetch Job Description", size="sm")
547
+
548
+ job_desc_out = gr.Textbox(
549
+ label="Job Description",
550
+ lines=12,
551
+ placeholder="Paste job description or fetch from URL..."
552
+ )
553
+
554
+ analyze_btn = gr.Button("πŸ” Analyze Job & Calculate ATS Score", variant="primary", size="lg")
555
+ analyze_status = gr.Textbox(label="Status", interactive=False)
556
+
557
+ with gr.Row():
558
+ with gr.Column():
559
+ pre_ats_score = gr.Textbox(label="πŸ“Š Initial ATS Score", interactive=False)
560
+ matched_skills = gr.Textbox(label="βœ… Matched Skills (Top 10)", lines=8, interactive=False)
561
+ missing_skills = gr.Textbox(label="❌ Missing Skills (Top 10)", lines=8, interactive=False)
562
+
563
+ with gr.Column():
564
+ skills_out = gr.Textbox(label="🎯 Extracted Skills (JSON)", lines=20, interactive=False)
565
+
566
+ # ===== TAB 3: Generate =====
567
+ with gr.Tab("✨ 3. Generate"):
568
+ gr.Markdown("### Generate tailored resume and cover letter")
569
+
570
+ with gr.Row():
571
+ with gr.Column(scale=1):
572
+ template_style = gr.Dropdown(
573
+ choices=["Professional", "Modern", "Creative", "Executive", "Technical"],
574
+ value="Professional",
575
+ label="Resume Style"
576
+ )
577
+ company_name = gr.Textbox(label="Company Name", placeholder="e.g., Google")
578
+ position_title = gr.Textbox(label="Position", placeholder="e.g., Senior Software Engineer")
579
+
580
+ gen_btn = gr.Button("✨ Generate Tailored Documents", variant="primary", size="lg")
581
+ gen_status = gr.Textbox(label="Status", interactive=False)
582
+
583
+ with gr.Column(scale=2):
584
+ tailored_out = gr.Textbox(
585
+ label="πŸ“ Tailored Resume",
586
+ lines=20,
587
+ placeholder="Your tailored resume will appear here..."
588
+ )
589
+
590
+ with gr.Row():
591
+ download_resume_btn = gr.Button("⬇️ Download Resume as PDF")
592
+ resume_pdf = gr.File(label="Resume PDF")
593
+
594
+ cover_out = gr.Textbox(
595
+ label="βœ‰οΈ Cover Letter",
596
+ lines=15,
597
+ placeholder="Your cover letter will appear here..."
598
+ )
599
+
600
+ with gr.Row():
601
+ download_cover_btn = gr.Button("⬇️ Download Cover Letter as PDF")
602
+ cover_pdf = gr.File(label="Cover Letter PDF")
603
+
604
+ gr.Markdown("### πŸ“Š Post-Generation ATS Score")
605
+ with gr.Row():
606
+ post_ats_score = gr.Textbox(label="Final ATS Score", interactive=False)
607
+ post_matched = gr.Textbox(label="βœ… Matched Skills", lines=6, interactive=False)
608
+ post_missing = gr.Textbox(label="❌ Missing Skills", lines=6, interactive=False)
609
+
610
+ # Wire up events
611
+ upload_btn.click(
612
+ fn=handle_upload,
613
+ inputs=[file_input],
614
+ outputs=[upload_status, gr.Textbox(visible=False), resume_text_out]
615
+ )
616
+
617
+ fetch_btn.click(
618
+ fn=handle_fetch_job,
619
+ inputs=[job_url],
620
+ outputs=[analyze_status, job_desc_out]
621
+ )
622
+
623
+ analyze_btn.click(
624
+ fn=handle_analyze,
625
+ inputs=[resume_text_out, job_desc_out, provider, api_key, model_name],
626
+ outputs=[analyze_status, skills_out, pre_ats_score, matched_skills, missing_skills]
627
+ )
628
+
629
+ gen_btn.click(
630
+ fn=handle_generate,
631
+ inputs=[resume_text_out, job_desc_out, provider, api_key, model_name, template_style, company_name, position_title],
632
+ outputs=[gen_status, tailored_out, cover_out, post_ats_score, post_matched, post_missing]
633
+ )
634
+
635
+ download_resume_btn.click(
636
+ fn=lambda t: make_pdf_download(t, "tailored_resume"),
637
+ inputs=[tailored_out],
638
+ outputs=[resume_pdf]
639
+ )
640
+
641
+ download_cover_btn.click(
642
+ fn=lambda t: make_pdf_download(t, "cover_letter"),
643
+ inputs=[cover_out],
644
+ outputs=[cover_pdf]
645
+ )
646
+
647
+ if __name__ == "__main__":
648
+ demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))