-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
310 lines (261 loc) · 12.7 KB
/
Copy pathapp.py
File metadata and controls
310 lines (261 loc) · 12.7 KB
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
import sys
import os
import io
# Force UTF-8 stdout/stderr so emoji in print() statements can't crash the
# app on Windows consoles that default to cp1252.
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
import streamlit as st
import pandas as pd
import time
import re
import html
from dotenv import load_dotenv
load_dotenv()
# Ensure the 'src' directory is in the Python path for Hugging Face
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from src.parser_v2 import EnhancedCVParser
from src.scraper import JobScraper
from src.matcher_v2 import JobMatcher
from src.ui import load_css, show_loading_screen, display_job_card
from src.model_manager import warmup_on_startup
# ============================================================
# PAGE CONFIG
# ============================================================
st.set_page_config(
page_title="Job Hunter Pro",
page_icon="🎯",
layout="wide"
)
# Load custom styling
load_css()
def main():
# Cold start tracking (for analytics)
if "first_load" not in st.session_state:
st.session_state.first_load = True
# Session state guard for model warmup (prevents rerun crashes)
if "models_loaded" not in st.session_state:
with st.spinner("🚀 Loading AI models (one-time, ~20s on cold start)..."):
try:
warmup_on_startup(['bert'])
st.session_state.models_loaded = True
st.sidebar.success("✅ AI models ready")
except Exception:
st.session_state.models_loaded = False
st.sidebar.info("ℹ️ Running in fast mode (TF-IDF only)")
# Sidebar Navigation
st.sidebar.image("https://img.icons8.com/clouds/100/000000/job.png", width=100)
st.sidebar.title("Configuration")
# Matching Mode Selection - Default to TF-IDF for HF Free Tier performance
match_mode_display = st.sidebar.selectbox(
"Match Engine",
["Standard (TF-IDF)", "BERT Semantic (Local AI)", "Azure Semantic (Cloud)", "Hybrid (All Combined)"],
index=0, # Default to TF-IDF for fastest response on HF Free Tier
help="TF-IDF = fastest (recommended for HF Free Tier). BERT = better accuracy but slower. Azure = requires API key."
)
# Map display to internal mode
match_mode_map = {
"Standard (TF-IDF)": "tfidf",
"BERT Semantic (Local AI)": "bert",
"Azure Semantic (Cloud)": "azure",
"Hybrid (All Combined)": "hybrid"
}
match_mode = match_mode_map[match_mode_display]
# API Key validation at selection time (prevents mid-match crashes)
if match_mode in ["azure", "hybrid"]:
azure_key = os.getenv("AZURE_OPENAI_API_KEY")
azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
if not azure_key or not azure_endpoint:
st.sidebar.error("⚠️ Azure mode selected but API key/endpoint missing. Switching to BERT fallback.")
match_mode = "bert" if match_mode == "azure" else "bert" # Fallback
else:
st.sidebar.success("✅ Azure OpenAI configured")
# Show appropriate warnings
if match_mode == "bert":
st.sidebar.info("BERT: Uses local transformer model (~80MB).")
elif match_mode == "hybrid":
st.sidebar.info("Hybrid: TF-IDF + BERT (+ Azure if configured).")
elif match_mode == "tfidf":
st.sidebar.info("TF-IDF: Fast keyword-based job matching. (CV parsing still uses AI.)")
# Governance Section (Sidebar)
st.sidebar.markdown("---")
st.sidebar.subheader("⚖️ AI Governance")
st.sidebar.info("""
**Privacy & Compliance:**
- **GDPR Compliant**: PII is sanitized before any AI provider sees your CV.
- **In-Memory**: No CV data is stored on disk.
- **Stateless**: Data is purged upon browser exit.
""")
# Header
st.markdown("""
<div class="main-header">
<h1>Job Hunter</h1>
<p>AI-powered job discovery and intelligent career matching.</p>
</div>
""", unsafe_allow_html=True)
# Main UI - Vertical Layout (CV Upload on top, Job Matching below)
st.markdown("---")
st.subheader("📁 Step 1: Upload Your CV")
uploaded_file = st.file_uploader("Upload your CV (PDF, DOCX, TXT)", type=['pdf', 'docx', 'txt'])
if uploaded_file:
# Store file bytes once to avoid double read() and EOF issues
file_bytes = uploaded_file.read()
file_hash = hash(file_bytes)
# Check if this is a new file (prevent stale session state)
if file_hash != st.session_state.get("cv_file_hash"):
parser = EnhancedCVParser()
with st.spinner("🔍 Parsing CV with AI..."):
st.session_state.cv_data = parser.parse(uploaded_file.name, file_bytes)
st.session_state.cv_file_hash = file_hash
# Show extraction results
cv_data = st.session_state.cv_data
skills = cv_data.get('skills', [])
years_exp = cv_data.get('years_experience', 0)
professional_title = cv_data.get('professional_title', '')
# Notify user if using fallback extraction
extraction_method = cv_data.get('extraction_method', '')
if extraction_method in ['llm_error', 'llm_failed', 'local_fallback']:
st.info("ℹ️ Using local extraction mode. Results may be less precise.")
if skills:
if years_exp:
st.success(f"✅ CV Parsed! Found **{len(skills)} skills** and **{years_exp} years** experience")
else:
st.success(f"✅ CV Parsed! Found **{len(skills)} skills**")
with st.expander("📋 View Extracted Profile", expanded=True):
# Show professional title if available
if professional_title:
st.markdown(f"**👤 Professional Title:** {professional_title}")
# Show skills grouped by category for easier scanning
skill_categories = cv_data.get('skill_categories', {})
grouped = {}
for skill in skills[:30]:
category = skill_categories.get(skill, "Other")
grouped.setdefault(category, []).append(skill)
category_order = ["Technical", "Tools", "Domain", "Soft Skills", "Other"]
for category in category_order:
if category in grouped:
st.markdown(f"**🛠️ {category}:** {', '.join(grouped[category])}")
# Show extraction confidence
confidence = cv_data.get('extraction_confidence', 0)
if confidence:
st.progress(confidence, text=f"Extraction confidence: {confidence:.0%}")
else:
st.warning("⚠️ No skills were extracted. Try uploading a different CV format or check the file content.")
st.markdown("---")
st.subheader("🎯 Step 2: Find Matching Jobs")
# Filter Section
with st.expander("🔍 Search Filters & Options", expanded=False):
f_col1, f_col2 = st.columns(2)
with f_col1:
search_keywords = st.text_input("Job Title / Skills",
placeholder="e.g. 'Physiotherapist', 'Physical Therapist', 'Remote Healthcare'",
help="Leave empty to use skills extracted from your CV")
limit = st.slider("Max results per source", 10, 100, 50)
with f_col2:
location_filter = st.selectbox("Preferred Location", ["All Remote", "USA", "Europe", "UK", "Worldwide"])
match_quality = st.selectbox(
"Match Quality",
["All Results", "Good matches and above", "Strong matches only"],
help="Filter out lower-relevance results"
)
min_score = {"All Results": 0, "Good matches and above": 40, "Strong matches only": 70}[match_quality]
if st.button("Find Matching Jobs", type="primary", use_container_width=True):
if not uploaded_file:
st.error("Please upload your CV first!")
return
# Initialize components
cv_data = st.session_state.get('cv_data')
if not cv_data:
st.error("CV data not found. Please re-upload your CV.")
return
scraper = JobScraper()
matcher = JobMatcher(cv_data, match_mode=match_mode)
# 1. Scrape
progress_bar = st.progress(0)
status_text = st.empty()
def update_progress(pct, text):
progress_bar.progress(pct)
status_text.text(text)
# Use search keywords or CV skills (strip whitespace)
search_keywords = search_keywords.strip() if search_keywords else ""
keywords = [search_keywords] if search_keywords else cv_data.get('skills', [])
if not keywords:
keywords = ['remote'] # fallback
# Cache key for scraper results (avoid rescraping same query)
cache_key = f"{str(keywords)}_{limit}_{location_filter}"
if cache_key != st.session_state.get("last_search_key"):
# New search - scrape fresh
with st.spinner("⏱️ Searching job boards (20-40s on first search)..."):
jobs = scraper.scrape_all(
keywords=keywords,
limit=limit,
progress_callback=update_progress,
timeout=10
)
st.session_state.cached_jobs = jobs
st.session_state.last_search_key = cache_key
else:
# Use cached results
jobs = st.session_state.get("cached_jobs", [])
st.info("📋 Using cached results. Refresh page to search again.")
if not jobs:
st.error("⚠️ No jobs could be retrieved. This may be due to:")
st.markdown("""
- Network timeout (common on HuggingFace Free Tier)
- All job boards temporarily unavailable
**Try:**
1. Wait a moment and click "Find Matching Jobs" again
2. Use cached results if available (refresh page)
3. Run locally for more reliable scraping
""")
return
# 2. Match & Score
scored_jobs = matcher.score_jobs(jobs, progress_callback=update_progress)
# Apply Filters
if location_filter != "All Remote":
scored_jobs = [j for j in scored_jobs if location_filter.lower() in j.get('location', '').lower()]
filtered_by_score = [j for j in scored_jobs if j.get('Match Score', 0) >= min_score]
# Suggest lowering threshold if everything filtered out
if not filtered_by_score and scored_jobs:
st.warning(f"⚠️ No jobs matched \"{match_quality}\". Showing the closest results instead.")
# Show unfiltered results instead
filtered_by_score = scored_jobs[:10] # Show top 10 anyway
scored_jobs = filtered_by_score
# Final Results
update_progress(1.0, "✅ Done! Top matches found.")
time.sleep(0.5)
progress_bar.empty()
status_text.empty()
st.write(f"### Found {len(scored_jobs)} matches for you (showing top 20):")
st.caption("💡 All matches are included in the CSV download below")
# Download as CSV Utility (includes ALL matches, not just displayed)
import html
df = pd.DataFrame(scored_jobs)
if not df.empty:
# Clean up description for CSV readability - strip HTML tags
if 'description' in df.columns:
# Remove HTML tags, decode entities, truncate
df['description'] = df['description'].astype(str).apply(
lambda x: html.unescape(re.sub(r'<[^>]+>', '', x))[:200] + "..."
)
csv = df.to_csv(index=False).encode('utf-8')
st.download_button(
label="📥 Download Matches as CSV",
data=csv,
file_name=f"job_matches_{time.strftime('%Y%m%d_%H%M%S')}.csv",
mime="text/csv",
use_container_width=True,
help="Export your matches for application tracking"
)
for job in scored_jobs[:20]: # Show top 20
display_job_card(job)
# Footer for Portfolio
st.markdown("---")
st.markdown(f"""
<div style="text-align: center; color: #666; font-size: 0.8em;">
Built by <b>Chukwuma Clifford Nwanna</b>
</div>
""", unsafe_allow_html=True)
if __name__ == "__main__":
main()