Alpha108 commited on
Commit
67d61f0
·
verified ·
1 Parent(s): d154480

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +505 -439
app.py CHANGED
@@ -1,462 +1,528 @@
1
  import streamlit as st
2
- import io
3
- from reportlab.lib.pagesizes import letter
4
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
5
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
6
- from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
 
 
7
  from reportlab.lib import colors
8
- from reportlab.lib.units import inch
9
- import time
10
- import pandas as pd
11
-
12
- # --- Configuration & Theming ---
13
- st.set_page_config(
14
- page_title="AI Resume Tailor",
15
- page_icon="📄",
16
- layout="wide",
17
- initial_sidebar_state="expanded",
18
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- # Custom CSS for modern styling and theme support
21
- st.markdown("""
22
- <style>
23
- /* General App Styling */
24
- .stApp {
25
- background-color: var(--background-color);
26
- color: var(--text-color);
27
- }
28
- .st-emotion-cache-16txtl3 {
29
- padding: 2rem 2rem;
30
- }
31
-
32
- /* Custom button styling */
33
- .stDownloadButton > button, .stButton > button {
34
- border-radius: 12px;
35
- padding: 10px 20px;
36
- font-weight: bold;
37
- transition: all 0.2s ease-in-out;
38
- border: 2px solid #2ECC71; /* Accent color border */
39
- background-color: transparent;
40
- color: #2ECC71;
41
- }
42
- .stDownloadButton > button:hover, .stButton > button:hover {
43
- background-color: #2ECC71;
44
- color: white;
45
- transform: translateY(-2px);
46
- box-shadow: 0 4px 8px rgba(0,0,0,0.1);
47
- }
48
-
49
- /* Sidebar styling */
50
- .st-emotion-cache-1jicfl2 {
51
- background-color: var(--secondary-background-color);
52
- }
53
-
54
- /* Expander styling */
55
- .st-emotion-cache-p5msec {
56
- border-radius: 10px;
57
- border: 1px solid var(--separator-color);
58
- }
59
-
60
- /* Match Score Badge */
61
- .match-score {
62
- background-color: #2ECC71;
63
- color: white;
64
- padding: 5px 12px;
65
- border-radius: 15px;
66
- font-size: 0.9em;
67
- font-weight: bold;
68
- }
69
-
70
- /* Tabs styling */
71
- .st-emotion-cache-1vbkxwb button {
72
- border-radius: 8px 8px 0 0;
73
- }
74
- </style>
75
- """, unsafe_allow_html=True)
76
-
77
- # --- PDF Generation (ReportLab) ---
78
-
79
- def build_pdf(user_data, resume_text, cover_letter_text=None) -> io.BytesIO:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  """
81
- Generates a modern, professional PDF resume using ReportLab.
 
82
  """
83
  buffer = io.BytesIO()
84
- doc = SimpleDocTemplate(buffer, pagesize=letter,
85
- rightMargin=0.75*inch, leftMargin=0.75*inch,
86
- topMargin=0.75*inch, bottomMargin=0.75*inch)
87
-
88
- story = []
 
 
 
89
  styles = getSampleStyleSheet()
90
 
91
- # --- Custom Styles ---
92
- accent_color = colors.HexColor("#2C3E50") # Dark Slate Blue
93
- secondary_accent_color = colors.HexColor("#2ECC71") # Green accent for score
94
-
95
- styles.add(ParagraphStyle(name='NameStyle',
96
- fontName='Helvetica-Bold',
97
- fontSize=28,
98
- leading=34,
99
- alignment=TA_CENTER,
100
- textColor=accent_color))
101
-
102
- styles.add(ParagraphStyle(name='ContactStyle',
103
- fontName='Helvetica',
104
- fontSize=10,
105
- alignment=TA_CENTER,
106
- leading=14))
107
-
108
- styles.add(ParagraphStyle(name='HeadingStyle',
109
- fontName='Helvetica-Bold',
110
- fontSize=14,
111
- leading=18,
112
- textColor=accent_color,
113
- spaceAfter=6))
114
-
115
- styles.add(ParagraphStyle(name='BodyStyle',
116
- fontName='Helvetica',
117
- fontSize=10,
118
- alignment=TA_LEFT,
119
- leading=14))
120
-
121
- styles.add(ParagraphStyle(name='JobTitleStyle',
122
- fontName='Helvetica-Bold',
123
- fontSize=11))
124
-
125
- # --- 1. Header Section ---
126
- name = Paragraph(user_data.get('name', 'Your Name'), styles['NameStyle'])
127
- contact_info = user_data.get('email', '') + " | " + user_data.get('phone', '')
128
- contact = Paragraph(contact_info, styles['ContactStyle'])
129
- story.append(name)
130
- story.append(contact)
131
- story.append(Spacer(1, 0.25*inch))
132
- story.append(HRFlowable(width="100%", thickness=1, color=colors.lightgrey))
133
- story.append(Spacer(1, 0.2*inch))
134
-
135
- # --- 2. Resume Content ---
136
- # The AI-generated text is assumed to have sections marked with headers.
137
- # We will parse this text and format it.
138
-
139
- # Simple parser: splits text into sections by looking for "Header:" lines.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  sections = {}
141
- current_section = "Summary" # Default section
142
- sections[current_section] = []
143
 
144
- for line in resume_text.split('\n'):
145
- line = line.strip()
146
- if line.endswith(':') and len(line.split()) < 4: # Likely a header
147
- current_section = line[:-1].strip()
 
 
 
 
148
  sections[current_section] = []
149
- elif line:
150
- sections[current_section].append(line)
151
-
152
- # Order of sections in the resume
153
- section_order = ["Summary", "Experience", "Skills", "Education", "Projects"]
154
-
155
- for section_title in section_order:
156
- if section_title in sections and sections[section_title]:
157
- story.append(Paragraph(section_title.upper(), styles['HeadingStyle']))
158
- content = sections[section_title]
159
-
160
- if section_title == "Skills":
161
- # Format skills into a two-column grid
162
- skills_list = [s.strip() for s in " ".join(content).split(',') if s.strip()]
163
- num_skills = len(skills_list)
164
- if num_skills > 0:
165
- num_cols = 2
166
- data = []
167
- row = []
168
- for i, skill in enumerate(skills_list):
169
- row.append(f"• {skill}")
170
- if len(row) == num_cols or i == num_skills - 1:
171
- data.append(row)
172
- row = []
173
-
174
- # Ensure all rows have num_cols items
175
- for r in data:
176
- while len(r) < num_cols:
177
- r.append('')
178
-
179
- table = Table(data, colWidths=[2.7*inch] * num_cols)
180
- table.setStyle(TableStyle([
181
- ('VALIGN', (0,0), (-1,-1), 'TOP'),
182
- ('LEFTPADDING', (0,0), (-1,-1), 0),
183
- ('RIGHTPADDING', (0,0), (-1,-1), 0),
184
- ('BOTTOMPADDING', (0,0), (-1,-1), 3),
185
- ('TOPPADDING', (0,0), (-1,-1), 0),
186
- ]))
187
- story.append(table)
188
-
189
- elif section_title == "Experience":
190
- # Format experience with left/right alignment using a Table
191
- # Assuming format: "Job Title at Company Name | Location | MM/YYYY - MM/YYYY"
192
- # And bullet points follow
193
- current_entry = []
194
- for line in content:
195
- if '|' in line and ('/' in line or 'Present' in line):
196
- if current_entry:
197
- story.extend(current_entry)
198
- current_entry = []
199
- parts = [p.strip() for p in line.split('|')]
200
-
201
- job_company = Paragraph(parts[0], styles['JobTitleStyle'])
202
- dates = Paragraph(parts[-1], styles['BodyStyle'])
203
-
204
- header_table = Table([[job_company, dates]], colWidths=['75%', '25%'])
205
- header_table.setStyle(TableStyle([
206
- ('ALIGN', (1,0), (1,0), 'RIGHT'),
207
- ('VALIGN', (0,0), (-1,-1), 'TOP'),
208
- ('LEFTPADDING', (0,0), (-1,-1), 0),
209
- ]))
210
- current_entry.append(header_table)
211
- elif line.startswith(('•', '*', '-')):
212
- p = Paragraph(line, styles['BodyStyle'], bulletText='•')
213
- p.leftIndent = 18
214
- current_entry.append(p)
215
- if current_entry:
216
- story.extend(current_entry)
217
-
218
-
219
- else: # For Summary, Education, Projects etc.
220
- for line in content:
221
- story.append(Paragraph(line, styles['BodyStyle']))
222
-
223
- story.append(Spacer(1, 0.2*inch))
224
-
225
-
226
- # --- 3. Optional Cover Letter ---
227
- if cover_letter_text:
228
- story.append(Spacer(1, 0.3*inch))
229
- story.append(HRFlowable(width="100%", thickness=2, color=accent_color))
230
- story.append(Spacer(1, 0.3*inch))
231
- story.append(Paragraph("COVER LETTER", styles['NameStyle']))
232
- story.append(Spacer(1, 0.2*inch))
233
-
234
- for para in cover_letter_text.split('\n\n'):
235
- story.append(Paragraph(para, styles['BodyStyle']))
236
- story.append(Spacer(1, 12))
237
-
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  doc.build(story)
240
  buffer.seek(0)
241
  return buffer
242
 
243
- # --- MOCK/PLACEHOLDER FUNCTIONS ---
244
-
245
- def parse_resume(file):
246
- # Placeholder: In a real app, use libraries like python-docx, pypdf
247
- time.sleep(1) # Simulate processing
248
- return "This is the parsed text content of the uploaded resume. It includes sections for skills, experience, and education."
249
-
250
- def get_job_postings():
251
- # Placeholder: In a real app, this would fetch from a database or API
252
- data = {
253
- 'id': [1, 2, 3, 4, 5],
254
- 'title': ['Senior Python Developer', 'Data Scientist', 'Frontend Engineer (React)', 'UX/UI Designer', 'Product Manager'],
255
- 'company': ['TechCorp', 'Data Inc.', 'Innovate LLC', 'Creative Designs', 'Productify'],
256
- 'location': ['Remote', 'New York, NY', 'San Francisco, CA', 'Remote', 'Austin, TX'],
257
- 'job_type': ['Full-time', 'Full-time', 'Contract', 'Full-time', 'Full-time'],
258
- 'salary_min': [120000, 110000, 130000, 90000, 140000],
259
- 'salary_max': [160000, 145000, 170000, 120000, 180000],
260
- 'description': [
261
- "Seeking a Senior Python Developer with expertise in Django and AWS...",
262
- "Join our data science team to build machine learning models for customer analytics...",
263
- "We need a React developer to build beautiful and responsive user interfaces...",
264
- "Design intuitive and engaging user experiences for our web and mobile applications...",
265
- "Lead the development and launch of new products from conception to launch..."
266
- ]
267
- }
268
- return pd.DataFrame(data)
269
-
270
- def match_jobs_with_resume(resume_text, jobs_df):
271
- # Placeholder: In a real app, use embeddings (e.g., SentenceTransformers) + FAISS
272
- time.sleep(2) # Simulate matching
273
- jobs_df['match_score'] = [95, 88, 76, 65, 82]
274
- return jobs_df.sort_values(by='match_score', ascending=False)
275
-
276
- def generate_ai_content(resume_text, job_description, content_type="resume"):
277
- # Placeholder: In a real app, this would call a generative AI model (e.g., Gemini API)
278
- time.sleep(3) # Simulate AI generation
279
- if content_type == "resume":
280
- return """
281
- Summary:
282
- A highly skilled and motivated professional with over 5 years of experience in software development, specializing in Python and cloud technologies. Proven ability to lead projects and deliver high-quality solutions. Tailored this summary to highlight alignment with the Senior Python Developer role at TechCorp.
283
-
284
- Experience:
285
- Senior Software Engineer at PreviousCompany | San Francisco, CA | 01/2020 - Present
286
- • Led the development of a key microservice using Python and Django, resulting in a 20% performance improvement.
287
- • Mentored junior developers and conducted code reviews to ensure code quality and standards.
288
- • Deployed applications to AWS using Docker and Kubernetes.
289
-
290
- Software Engineer at AnotherCompany | Boston, MA | 06/2017 - 12/2019
291
- • Developed and maintained REST APIs for the main product.
292
- • Worked in an Agile team to deliver features on a bi-weekly sprint schedule.
293
-
294
- Skills:
295
- Python, Django, Flask, FastAPI, JavaScript, React, AWS, GCP, Docker, Kubernetes, Terraform, SQL, PostgreSQL, MongoDB, Git
296
-
297
- Education:
298
- Master of Science in Computer Science | University of Technology | 2017
299
- Bachelor of Science in Software Engineering | State University | 2015
300
- """
301
- else: # Cover Letter
302
- return """
303
- Dear Hiring Manager,
304
-
305
- I am writing to express my enthusiastic interest in the Senior Python Developer position at TechCorp, which I found advertised on [Platform]. With my extensive experience in Python development, particularly with Django and AWS, and a proven track record of delivering scalable and efficient solutions, I am confident that I possess the skills and qualifications necessary to excel in this role and contribute significantly to your team.
306
-
307
- In my previous role at PreviousCompany, I led the development of a critical microservice that enhanced system performance by 20%. This project required deep expertise in Python, architectural design, and cloud deployment, all of which are key requirements for the position at TechCorp. I am particularly drawn to your company's innovative work in [mention a specific company project or value], and I am eager to bring my passion for building high-quality software to your organization.
308
-
309
- Thank you for considering my application. I have attached my resume for your review and welcome the opportunity to discuss how my background, skills, and enthusiasm can be a valuable asset to TechCorp.
310
-
311
- Sincerely,
312
- [Your Name]
313
- """
314
-
315
-
316
- # --- STREAMLIT UI ---
317
-
318
- # Initialize session state
319
- if 'resume_text' not in st.session_state:
320
- st.session_state.resume_text = ""
321
- if 'matched_jobs' not in st.session_state:
322
- st.session_state.matched_jobs = None
323
- if 'tailored_resume' not in st.session_state:
324
- st.session_state.tailored_resume = ""
325
- if 'cover_letter' not in st.session_state:
326
- st.session_state.cover_letter = ""
327
 
328
- # --- Sidebar ---
 
 
 
 
 
 
 
 
 
 
 
329
  with st.sidebar:
330
- st.markdown("## 📄 AI Resume Tailor")
 
 
 
 
331
  st.markdown("---")
332
-
333
- st.markdown("### 1. Your Information")
334
- user_name = st.text_input("Full Name", placeholder="e.g., Jane Doe")
335
- user_email = st.text_input("Email", placeholder="e.g., jane.doe@email.com")
336
- user_phone = st.text_input("Phone Number", placeholder="e.g., (123) 456-7890")
337
-
338
- user_data = {"name": user_name, "email": user_email, "phone": user_phone}
339
-
340
- st.markdown("### 2. Upload Your Resume")
341
- uploaded_file = st.file_uploader("Upload your resume (PDF, DOCX)", type=['pdf', 'docx'])
342
-
343
- if uploaded_file:
344
- with st.spinner('Analyzing your resume...'):
345
- st.session_state.resume_text = parse_resume(uploaded_file)
346
- all_jobs = get_job_postings()
347
- st.session_state.matched_jobs = match_jobs_with_resume(st.session_state.resume_text, all_jobs)
348
- st.success("Resume analyzed successfully!")
349
-
350
- # --- Main Page ---
351
- st.title("Find and Apply for Your Next Job")
352
- st.markdown("Upload your resume on the left to get started. We'll match you with relevant job postings and help you tailor your application materials instantly.")
353
-
354
- if st.session_state.matched_jobs is not None:
355
  st.markdown("---")
356
- st.header(" Top Job Matches")
357
-
358
- # --- Filtering UI ---
359
- jobs_df = st.session_state.matched_jobs
360
-
361
- col1, col2, col3 = st.columns(3)
362
- with col1:
363
- locations = ['All'] + sorted(jobs_df['location'].unique().tolist())
364
- location_filter = st.selectbox("Location", options=locations)
365
- with col2:
366
- job_types = ['All'] + sorted(jobs_df['job_type'].unique().tolist())
367
- type_filter = st.selectbox("Job Type", options=job_types)
368
- with col3:
369
- min_sal, max_sal = int(jobs_df['salary_min'].min()), int(jobs_df['salary_max'].max())
370
- salary_filter = st.slider("Salary Range ($)", min_sal, max_sal, (min_sal, max_sal), 1000)
371
-
372
- keyword_filter = st.text_input("Search by keyword in title or description", "")
373
-
374
- # Apply filters
375
- filtered_jobs = jobs_df.copy()
376
- if location_filter != 'All':
377
- filtered_jobs = filtered_jobs[filtered_jobs['location'] == location_filter]
378
- if type_filter != 'All':
379
- filtered_jobs = filtered_jobs[filtered_jobs['job_type'] == type_filter]
380
- filtered_jobs = filtered_jobs[
381
- (filtered_jobs['salary_min'] >= salary_filter[0]) &
382
- (filtered_jobs['salary_max'] <= salary_filter[1])
383
- ]
384
- if keyword_filter:
385
- filtered_jobs = filtered_jobs[
386
- filtered_jobs['title'].str.contains(keyword_filter, case=False) |
387
- filtered_jobs['description'].str.contains(keyword_filter, case=False)
388
- ]
389
-
390
- if filtered_jobs.empty:
391
- st.warning("No jobs match your current filter criteria.")
392
- else:
393
- # --- Display Matched Jobs ---
394
- for index, job in filtered_jobs.iterrows():
395
- with st.expander(f"**{job['title']}** at {job['company']}"):
396
- col1, col2 = st.columns([4, 1])
397
- with col1:
398
- st.markdown(f"**Location:** {job['location']} | **Type:** {job['job_type']}")
399
- st.markdown(f"**Salary:** ${job['salary_min']:,} - ${job['salary_max']:,}")
400
- st.write(job['description'])
401
- with col2:
402
- st.markdown(f"<div style='text-align: right;'><span class='match-score'>🔥 {job['match_score']}% Match</span></div>", unsafe_allow_html=True)
403
-
404
- # Action buttons
405
- action_col1, action_col2, _ = st.columns([1, 1, 3])
406
- if action_col1.button("Tailor Resume", key=f"resume_{job['id']}"):
407
- with st.spinner(f"Generating tailored resume for {job['title']}..."):
408
- st.session_state.tailored_resume = generate_ai_content(
409
- st.session_state.resume_text, job['description'], "resume"
410
- )
411
- st.success("Resume tailored!")
412
-
413
- if action_col2.button("Generate Cover Letter", key=f"cover_{job['id']}"):
414
- with st.spinner(f"Generating cover letter for {job['title']}..."):
415
- st.session_state.cover_letter = generate_ai_content(
416
- st.session_state.resume_text, job['description'], "cover_letter"
417
- )
418
- st.success("Cover letter generated!")
419
-
420
-
421
- # --- Output Section ---
422
- if st.session_state.tailored_resume or st.session_state.cover_letter:
423
- st.markdown("---")
424
- st.header("📝 Your Generated Documents")
425
- st.info("You can edit the text below before exporting to PDF.")
426
-
427
- tab1, tab2 = st.tabs(["Tailored Resume", "Cover Letter"])
428
-
429
- with tab1:
430
- if st.session_state.tailored_resume:
431
- st.session_state.tailored_resume = st.text_area(
432
- "Resume Content", value=st.session_state.tailored_resume, height=400
433
- )
434
-
435
- pdf_resume = build_pdf(user_data, st.session_state.tailored_resume)
436
- st.download_button(
437
- label="📥 Download Resume PDF",
438
- data=pdf_resume,
439
- file_name=f"{user_name.replace(' ', '_')}_Resume.pdf",
440
- mime="application/pdf"
441
- )
442
- else:
443
- st.write("Generate a tailored resume from a job match above.")
444
-
445
- with tab2:
446
- if st.session_state.cover_letter:
447
- st.session_state.cover_letter = st.text_area(
448
- "Cover Letter Content", value=st.session_state.cover_letter, height=400
449
- )
450
-
451
- pdf_cl = build_pdf(user_data, "", st.session_state.cover_letter)
452
- st.download_button(
453
- label="📥 Download Cover Letter PDF",
454
- data=pdf_cl,
455
- file_name=f"{user_name.replace(' ', '_')}_Cover_Letter.pdf",
456
- mime="application/pdf"
457
- )
458
- else:
459
- st.write("Generate a cover letter from a job match above.")
460
 
 
 
 
 
 
461
  else:
462
- st.info("Upload your resume in the sidebar to find job matches.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
+ import requests
3
+ import pdfplumber
4
+ import docx
5
+ from sentence_transformers import SentenceTransformer
6
+ import faiss
7
+ from groq import Groq
8
+ from reportlab.lib.pagesizes import A4
9
  from reportlab.lib import colors
10
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
11
+ from reportlab.platypus import (
12
+ SimpleDocTemplate,
13
+ Paragraph,
14
+ Spacer,
15
+ ListFlowable,
16
+ ListItem,
17
+ Table,
18
+ TableStyle,
19
+ Image as RLImage,
20
  )
21
+ from reportlab.lib.units import mm
22
+ from reportlab.pdfbase import pdfmetrics
23
+ from reportlab.pdfbase.ttfonts import TTFont
24
+ import io
25
+ from PIL import Image
26
+ import tempfile
27
+ import os
28
+ from typing import List
29
+
30
+ # -----------------------------
31
+ # CONFIG
32
+ # -----------------------------
33
+ REMOTEOK_URL = "https://remoteok.com/api"
34
+ EMBED_MODEL = "BAAI/bge-small-en-v1.5"
35
+ AI_MODEL = "openai/gpt-oss-120b" # Groq model
36
+
37
+ # Register font fallback (optional - requires the .ttf to exist if you want specific fonts)
38
+ # If you have fonts, register them; otherwise default fonts will be used.
39
+ # Example: pdfmetrics.registerFont(TTFont('HelveticaNeue', '/path/to/HelveticaNeue.ttf'))
40
+
41
+ # -----------------------------
42
+ # CACHED MODELS
43
+ # -----------------------------
44
+ @st.cache_resource
45
+ def load_embedding_model():
46
+ return SentenceTransformer(EMBED_MODEL)
47
+
48
+ model = load_embedding_model()
49
+
50
+ @st.cache_resource
51
+ def init_groq():
52
+ return Groq(api_key=st.secrets.get("GROQ_API_KEY", None))
53
+
54
+ groq_client = init_groq()
55
+
56
+ # -----------------------------
57
+ # UTIL / PARSING FUNCTIONS
58
+ # -----------------------------
59
+ def extract_text_from_resume(file) -> str:
60
+ """Extract text from PDF or DOCX file"""
61
+ name = getattr(file, "name", "")
62
+ if name.lower().endswith(".pdf"):
63
+ text = ""
64
+ with pdfplumber.open(file) as pdf:
65
+ for page in pdf.pages:
66
+ text += page.extract_text() or ""
67
+ return text
68
+
69
+ elif name.lower().endswith(".docx"):
70
+ doc = docx.Document(file)
71
+ text = "\n".join([p.text for p in doc.paragraphs])
72
+ return text
73
 
74
+ else:
75
+ st.error("Unsupported file type. Please upload PDF or DOCX.")
76
+ return ""
77
+
78
+ def fetch_jobs() -> List[dict]:
79
+ try:
80
+ resp = requests.get(REMOTEOK_URL, timeout=10)
81
+ if resp.status_code == 200:
82
+ jobs = resp.json()[1:] # skip metadata
83
+ return jobs
84
+ except Exception as e:
85
+ st.warning(f"Failed to fetch jobs: {e}")
86
+ return []
87
+
88
+ def embed_texts(texts):
89
+ # returns numpy array
90
+ return model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
91
+
92
+ def match_jobs(resume_text, jobs, top_k=5):
93
+ if not jobs:
94
+ return []
95
+
96
+ job_texts = [f"{job.get('position','')} {job.get('company','')} {job.get('description','')}" for job in jobs]
97
+ resume_vec = embed_texts([resume_text])
98
+ job_vecs = embed_texts(job_texts)
99
+
100
+ dim = job_vecs.shape[1]
101
+ index = faiss.IndexFlatIP(dim)
102
+ index.add(job_vecs)
103
+
104
+ scores, idx = index.search(resume_vec, top_k)
105
+ results = []
106
+ for i, score in zip(idx[0], scores[0]):
107
+ results.append((jobs[i], float(score)))
108
+ return results
109
+
110
+ # -----------------------------
111
+ # AI GENERATION
112
+ # -----------------------------
113
+ def generate_resume(resume_text, job):
114
+ prompt = f"""
115
+ You are an AI career assistant.
116
+ Given this resume:\n{resume_text}\n
117
+ and this job description:\n{job.get('description','')}\n
118
+ Generate a structured resume in this format:
119
+
120
+ Summary
121
+ -----------------
122
+ [2-3 line summary tailored for the job]
123
+
124
+ Skills
125
+ -----------------
126
+ - Skill 1
127
+ - Skill 2
128
+ - Skill 3
129
+
130
+ Experience
131
+ -----------------
132
+ Job Title | Company | Dates
133
+ Achievement 1
134
+ • Achievement 2
135
+
136
+ Education
137
+ -----------------
138
+ Degree | Institution | Year
139
+ """
140
+ chat_completion = groq_client.chat.completions.create(
141
+ model=AI_MODEL,
142
+ messages=[{"role": "user", "content": prompt}],
143
+ temperature=0.7,
144
+ )
145
+ return chat_completion.choices[0].message.content
146
+
147
+ def generate_cover_letter(resume_text, job, name, email, phone):
148
+ prompt = f"""
149
+ You are an AI career assistant.
150
+ Given this resume:\n{resume_text}\n
151
+ and this job description:\n{job.get('description','')}\n
152
+ Generate a professional, one-page cover letter tailored to this role.
153
+ Format it like this:
154
+
155
+ Dear Hiring Manager,
156
+
157
+ [Intro paragraph: Show enthusiasm and alignment with company/role]
158
+ [Body paragraph: Highlight 2-3 most relevant skills/experiences from resume]
159
+ [Closing paragraph: Express eagerness and thank them]
160
+
161
+ Sincerely,
162
+ {name}
163
+ {email} | {phone}
164
+ """
165
+ chat_completion = groq_client.chat.completions.create(
166
+ model=AI_MODEL,
167
+ messages=[{"role": "user", "content": prompt}],
168
+ temperature=0.7,
169
+ )
170
+ return chat_completion.choices[0].message.content
171
+
172
+ # -----------------------------
173
+ # PDF BUILDING - Improved professional template
174
+ # -----------------------------
175
+ def build_pdf(content: str,
176
+ title: str = "Resume",
177
+ name: str = "John Doe",
178
+ email: str = "john.doe@email.com",
179
+ phone: str = "+1 234 567 890",
180
+ profile_image_bytes: bytes = None) -> io.BytesIO:
181
  """
182
+ Build a polished PDF resume.
183
+ content: assumed to be a structured text (the output from the AI generation).
184
  """
185
  buffer = io.BytesIO()
186
+ doc = SimpleDocTemplate(
187
+ buffer,
188
+ pagesize=A4,
189
+ leftMargin=30,
190
+ rightMargin=30,
191
+ topMargin=30,
192
+ bottomMargin=30,
193
+ )
194
  styles = getSampleStyleSheet()
195
 
196
+ # Custom styles
197
+ header_style = ParagraphStyle(
198
+ "Header",
199
+ parent=styles["Heading1"],
200
+ fontSize=20,
201
+ spaceAfter=6,
202
+ textColor=colors.HexColor("#2C3E50"),
203
+ alignment=1,
204
+ leading=22,
205
+ )
206
+ contact_style = ParagraphStyle(
207
+ "Contact",
208
+ parent=styles["Normal"],
209
+ fontSize=10,
210
+ textColor=colors.HexColor("#566573"),
211
+ alignment=1,
212
+ )
213
+ section_style = ParagraphStyle(
214
+ "Section",
215
+ parent=styles["Heading2"],
216
+ fontSize=12,
217
+ spaceBefore=12,
218
+ spaceAfter=6,
219
+ textColor=colors.HexColor("#1B2631"),
220
+ )
221
+ normal_style = ParagraphStyle("Normal", parent=styles["Normal"], fontSize=11, leading=15)
222
+ bullet_style = ParagraphStyle("Bullet", parent=styles["Normal"], fontSize=11, leading=15, leftIndent=6)
223
+
224
+ story = []
225
+
226
+ # Header with optional profile image: split header into a two-column table
227
+ header_data = []
228
+ header_cells = []
229
+
230
+ # Name & contact block
231
+ header_text = f"<b>{name}</b>"
232
+ header_text += f"<br/>{email} | {phone}"
233
+ header_para = Paragraph(header_text, ParagraphStyle("HeaderLeft", parent=styles["Normal"], alignment=0, fontSize=10, leading=12))
234
+
235
+ # If profile image is provided, create a small reportlab Image
236
+ if profile_image_bytes:
237
+ try:
238
+ tmp = io.BytesIO(profile_image_bytes)
239
+ pil = Image.open(tmp)
240
+ pil.thumbnail((150, 150))
241
+ img_temp = io.BytesIO()
242
+ pil.save(img_temp, format="PNG")
243
+ img_temp.seek(0)
244
+ rl_img = RLImage(img_temp, width=40 * mm, height=40 * mm)
245
+ header_cells = [[rl_img, header_para]]
246
+ header_table = Table(header_cells, colWidths=[45 * mm, 120 * mm])
247
+ except Exception:
248
+ # fallback to no image
249
+ header_table = Table([[header_para]], colWidths=[165 * mm])
250
+ else:
251
+ header_table = Table([[header_para]], colWidths=[165 * mm])
252
+
253
+ header_table.setStyle(
254
+ TableStyle(
255
+ [
256
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
257
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
258
+ ("RIGHTPADDING", (0, 0), (-1, -1), 0),
259
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
260
+ ]
261
+ )
262
+ )
263
+
264
+ story.append(header_table)
265
+ story.append(Spacer(1, 8))
266
+ # Thin accent line
267
+ story.append(Table([[""]], colWidths=[165 * mm], style=[("LINEBELOW", (0, 0), (-1, -1), 1, colors.HexColor("#2C3E50"))]))
268
+ story.append(Spacer(1, 6))
269
+
270
+ # Parse content into sections. We expect structured AI output with headings e.g. "Summary", "Skills", etc.
271
+ # We'll split by lines and detect sections by headings
272
+ lines = [l for l in content.splitlines()]
273
+ current_section = None
274
  sections = {}
 
 
275
 
276
+ for ln in lines:
277
+ ln_stripped = ln.strip()
278
+ if not ln_stripped:
279
+ continue
280
+ # heuristics for section headings
281
+ llow = ln_stripped.lower()
282
+ if llow.startswith("summary") or llow.startswith("skills") or llow.startswith("experience") or llow.startswith("education") or llow.startswith("projects"):
283
+ current_section = ln_stripped
284
  sections[current_section] = []
285
+ else:
286
+ if current_section is None:
287
+ # put in summary fallback
288
+ sections.setdefault("Summary", []).append(ln_stripped)
289
+ else:
290
+ sections[current_section].append(ln_stripped)
291
+
292
+ # If no detected sections, treat whole content as a summary paragraph
293
+ if not sections:
294
+ sections["Summary"] = lines
295
+
296
+ # Build PDF content by section
297
+ accent = colors.HexColor("#2C3E50")
298
+
299
+ for sec_title, sec_lines in sections.items():
300
+ # Standardize title text (use 'Skills' instead of 'Skills:')
301
+ title_clean = sec_title.strip().rstrip(":").title()
302
+ story.append(Paragraph(title_clean, section_style))
303
+
304
+ # Skills: render as two-column table with small cells
305
+ if title_clean.lower().startswith("skills"):
306
+ # flatten bullets and commas
307
+ skills = []
308
+ for l in sec_lines:
309
+ # remove leading bullets if present
310
+ l2 = l.lstrip("-• ")
311
+ parts = [p.strip() for p in l2.replace(",", "\n").splitlines() if p.strip()]
312
+ skills.extend(parts)
313
+ if not skills:
314
+ story.append(Paragraph("No skills detected.", normal_style))
315
+ else:
316
+ # create two-column table
317
+ left_col = skills[0::2]
318
+ right_col = skills[1::2] + [""] * max(0, len(left_col) - len(skills[1::2]))
319
+ table_data = list(zip(left_col, right_col))
320
+ skills_table = Table(table_data, colWidths=[75 * mm, 75 * mm])
321
+ skills_table.setStyle(
322
+ TableStyle(
323
+ [
324
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
325
+ ("INNERGRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#E5E7EB")),
326
+ ("BOX", (0, 0), (-1, -1), 0, colors.white),
327
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
328
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
329
+ ]
330
+ )
331
+ )
332
+ story.append(skills_table)
333
+ # Experience: detect lines and format with title/company left and dates right
334
+ elif title_clean.lower().startswith("experience"):
335
+ # We will try to parse blocks starting with something that looks like "Job Title | Company | Dates"
336
+ # We will treat each blank-line separated block as an entry
337
+ entries = []
338
+ current = []
339
+ for l in sec_lines:
340
+ if l.strip() == "":
341
+ if current:
342
+ entries.append(current)
343
+ current = []
344
+ else:
345
+ current.append(l)
346
+ if current:
347
+ entries.append(current)
348
+
349
+ # Fallback: if entries is empty, treat all lines as one block
350
+ if not entries and sec_lines:
351
+ entries = [sec_lines]
352
+
353
+ for entry in entries:
354
+ # first non-empty line often has job title | company | date or similar
355
+ header_line = entry[0]
356
+ parts = [p.strip() for p in header_line.split("|")]
357
+ if len(parts) >= 3:
358
+ title_company = f"<b>{parts[0]}</b> | {parts[1]}"
359
+ dates = parts[2]
360
+ elif len(parts) == 2:
361
+ title_company = f"<b>{parts[0]}</b> | {parts[1]}"
362
+ dates = ""
363
+ else:
364
+ title_company = header_line
365
+ dates = ""
366
+
367
+ table = Table([[Paragraph(title_company, normal_style), Paragraph(dates, ParagraphStyle("Right", parent=normal_style, alignment=2))]],
368
+ colWidths=[115 * mm, 40 * mm])
369
+ table.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 0)]))
370
+ story.append(table)
371
+ # rest of lines are bullets or descriptions
372
+ for desc in entry[1:]:
373
+ # convert leading dashes to bullets
374
+ desc_clean = desc.lstrip("-• ").strip()
375
+ story.append(Paragraph("• " + desc_clean, bullet_style))
376
+ story.append(Spacer(1, 6))
377
+ else:
378
+ # Generic paragraph or list
379
+ for l in sec_lines:
380
+ # bullet detection
381
+ if l.startswith("- ") or l.startswith("• "):
382
+ text = l.lstrip("-• ").strip()
383
+ story.append(Paragraph("• " + text, bullet_style))
384
+ else:
385
+ story.append(Paragraph(l, normal_style))
386
+ story.append(Spacer(1, 8))
387
 
388
  doc.build(story)
389
  buffer.seek(0)
390
  return buffer
391
 
392
+ # -----------------------------
393
+ # STREAMLIT UI
394
+ # -----------------------------
395
+ st.set_page_config(page_title="MATCHHIVE - AI Job Matcher", layout="wide", initial_sidebar_state="expanded")
396
+ # Custom CSS for nicer buttons and spacing
397
+ st.markdown(
398
+ """
399
+ <style>
400
+ .stButton>button { border-radius: 8px; padding:8px 12px; }
401
+ .download-btn { background-color:#2ECC71 !important; color:white !important; }
402
+ .job-card { padding:10px; border:1px solid #E5E7EB; border-radius:8px; margin-bottom:8px; }
403
+ </style>
404
+ """,
405
+ unsafe_allow_html=True,
406
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
+ # Header area with optional logo upload
409
+ col1, col2 = st.columns([1, 6])
410
+ with col1:
411
+ logo_file = st.file_uploader("Upload logo (optional)", type=["png", "jpg", "jpeg"], help="Optional: upload your company/app logo")
412
+ if logo_file:
413
+ img = Image.open(logo_file)
414
+ st.image(img, width=100)
415
+ with col2:
416
+ st.title("MATCHHIVE - AI Job Matcher")
417
+ st.caption("Upload a resume, match to jobs, generate tailored resumes & cover letters (PDF).")
418
+
419
+ # Sidebar: user contact info + options
420
  with st.sidebar:
421
+ st.header("Candidate Info")
422
+ name = st.text_input("Full Name", "John Doe")
423
+ email = st.text_input("Email", "john.doe@email.com")
424
+ phone = st.text_input("Phone", "+1 234 567 890")
425
+ profile_pic = st.file_uploader("Profile photo (optional)", type=["png", "jpg", "jpeg"], help="Small circular/headshot for resume header")
426
  st.markdown("---")
427
+ st.header("Job Filters (optional)")
428
+ location_filter = st.text_input("Location keyword (e.g. Remote, USA, Canada)", "")
429
+ keyword_filter = st.text_input("Job keyword (e.g. Python, ML, DevOps)", "")
430
+ min_score = st.slider("Minimum match score", min_value=0.0, max_value=1.0, value=0.0, step=0.01)
431
+ top_k = st.number_input("Number of matches to show", min_value=1, max_value=20, value=5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  st.markdown("---")
433
+ st.caption("Note: Job data comes from remoteok.com API and match scores are semantic similarity approximations.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
 
435
+ # Main upload & processing area
436
+ st.header("Upload Resume (PDF or DOCX)")
437
+ resume_file = st.file_uploader("Upload your resume", type=["pdf", "docx"])
438
+ if not resume_file:
439
+ st.info("Please upload a resume (PDF or DOCX) to start matching.")
440
  else:
441
+ with st.spinner("Extracting resume text..."):
442
+ resume_text = extract_text_from_resume(resume_file)
443
+
444
+ if not resume_text.strip():
445
+ st.error("Could not extract text from the resume. Try a different file or ensure the PDF is text-based (not scanned).")
446
+ else:
447
+ # Fetch jobs and filter
448
+ with st.spinner("Fetching remote jobs..."):
449
+ jobs = fetch_jobs()
450
+
451
+ # Apply simple filters
452
+ def job_matches_filters(job):
453
+ if location_filter:
454
+ loc = job.get("location") or job.get("company_location") or ""
455
+ if location_filter.lower() not in str(loc).lower():
456
+ return False
457
+ if keyword_filter:
458
+ combined = f"{job.get('position','')} {job.get('company','')} {job.get('description','')}"
459
+ if keyword_filter.lower() not in combined.lower():
460
+ return False
461
+ return True
462
+
463
+ filtered_jobs = [j for j in jobs if job_matches_filters(j)]
464
+
465
+ # Do matching & display results
466
+ with st.spinner("Computing semantic match scores..."):
467
+ matches = match_jobs(resume_text, filtered_jobs, top_k=top_k)
468
+
469
+ # apply min_score filter
470
+ matches = [(job, score) for job, score in matches if score >= min_score]
471
+
472
+ if not matches:
473
+ st.warning("No matches found with given filters/score. Try lowering minimum score or removing filters.")
474
+ else:
475
+ st.subheader(f"Top {len(matches)} Matches")
476
+ for job, score in matches:
477
+ # Use an expander for each job
478
+ title = job.get("position", "Unknown Position")
479
+ company = job.get("company", "Unknown Company")
480
+ url = job.get("url", "#")
481
+ posted = job.get("date", "")
482
+ exp_label = f"{title} at {company} — Score: {score:.2f}"
483
+ with st.expander(exp_label, expanded=False):
484
+ st.markdown(f"**Location:** {job.get('location','N/A')} \n**Posted:** {posted} \n[View Job Posting]({url})")
485
+ st.markdown("---")
486
+ cols = st.columns([1, 1, 1])
487
+ # Buttons for generation in-line
488
+ if cols[0].button("Generate Resume (AI)", key=f"resume_{job.get('id', title)}"):
489
+ with st.spinner("Generating tailored resume..."):
490
+ tailored_resume = generate_resume(resume_text, job)
491
+ # show in a tabbed output
492
+ tab1, tab2 = st.tabs(["Tailored Resume", "Cover Letter"])
493
+ with tab1:
494
+ edited_resume = st.text_area("Tailored Resume (editable)", tailored_resume, height=300)
495
+ if st.button("Export Tailored Resume as PDF", key=f"export_resume_{job.get('id', title)}"):
496
+ prof_bytes = None
497
+ if profile_pic:
498
+ prof_bytes = profile_pic.getvalue()
499
+ pdf_buffer = build_pdf(edited_resume, title="Resume", name=name, email=email, phone=phone, profile_image_bytes=prof_bytes)
500
+ st.download_button(
501
+ label="📥 Download Resume (PDF)",
502
+ data=pdf_buffer,
503
+ file_name=f"{name.replace(' ', '_')}_resume.pdf",
504
+ mime="application/pdf",
505
+ )
506
+ with tab2:
507
+ # generate cover letter on demand
508
+ if cols[1].button("Generate Cover Letter (AI)", key=f"clgen_{job.get('id', title)}"):
509
+ with st.spinner("Generating cover letter..."):
510
+ tailored_cl = generate_cover_letter(resume_text, job, name, email, phone)
511
+ edited_cl = st.text_area("Cover Letter (editable)", tailored_cl, height=300, key=f"cltext_{job.get('id', title)}")
512
+ if st.button("Export Cover Letter as PDF", key=f"export_cl_{job.get('id', title)}"):
513
+ prof_bytes = None
514
+ if profile_pic:
515
+ prof_bytes = profile_pic.getvalue()
516
+ pdf_buffer = build_pdf(edited_cl, title="Cover Letter", name=name, email=email, phone=phone, profile_image_bytes=prof_bytes)
517
+ st.download_button(
518
+ label="📥 Download Cover Letter (PDF)",
519
+ data=pdf_buffer,
520
+ file_name=f"{name.replace(' ', '_')}_cover_letter.pdf",
521
+ mime="application/pdf",
522
+ )
523
+
524
+ # Quick preview of job description (collapsible)
525
+ if cols[2].button("Show Job Description", key=f"desc_{job.get('id', title)}"):
526
+ st.info(job.get("description", "No description available"))
527
+
528
+ st.success("Done — select a match and generate your tailored resume or cover letter.")