ByteRiot/CandidateExplorer
1
1import os2import dotenv3dotenv.load_dotenv()4import json5import requests6import streamlit as st7 8# ─────────────────────────────────────────────9# CONFIG10# ─────────────────────────────────────────────11BASE_URL = os.environ.get("BACKEND_BASE_URL", "http://localhost:8000")12 13st.set_page_config(14 page_title="Candidate Explorer",15 page_icon="🔍",16 layout="wide",17 initial_sidebar_state="expanded",18)19 20# ─────────────────────────────────────────────21# GLOBAL CSS22# ─────────────────────────────────────────────23st.markdown(24 """25 <style>26 /* ── Global ─────────────────────────────── */27 html, body, [class*="css"] {28 font-family: 'Inter', 'Segoe UI', sans-serif;29 color: #333e4a;30 background-color: #ffffff;31 }32 33 /* ── Sidebar ─────────────────────────────── */34 [data-testid="stSidebar"] {35 background-color: #f4f6ff;36 border-right: 1px solid #c7cef5;37 }38 [data-testid="stSidebar"] h1,39 [data-testid="stSidebar"] h2,40 [data-testid="stSidebar"] h3,41 [data-testid="stSidebar"] label {42 color: #435cdc !important;43 }44 45 /* ── Buttons ─────────────────────────────── */46 .stButton > button {47 background-color: #435cdc;48 color: #ffffff;49 border: none;50 border-radius: 8px;51 padding: 0.5rem 1.25rem;52 font-weight: 600;53 transition: background-color 0.2s ease;54 }55 .stButton > button:hover {56 background-color: #7b8de7;57 color: #ffffff;58 }59 .stButton > button:focus {60 outline: 2px solid #c7cef5;61 }62 63 /* ── Tabs ─────────────────────────────────── */64 [data-baseweb="tab-list"] {65 gap: 8px;66 border-bottom: 2px solid #c7cef5;67 }68 [data-baseweb="tab"] {69 border-radius: 8px 8px 0 0;70 padding: 0.5rem 1.25rem;71 font-weight: 600;72 color: #7b8de7;73 background: transparent;74 }75 [aria-selected="true"][data-baseweb="tab"] {76 color: #435cdc !important;77 border-bottom: 3px solid #435cdc !important;78 background: #f4f6ff;79 }80 81 /* ── Inputs ──────────────────────────────── */82 [data-testid="stTextInput"] input,83 [data-testid="stSelectbox"] select,84 textarea {85 border-radius: 8px !important;86 border: 1.5px solid #c7cef5 !important;87 color: #333e4a !important;88 }89 [data-testid="stTextInput"] input:focus,90 textarea:focus {91 border-color: #435cdc !important;92 box-shadow: 0 0 0 2px #c7cef5;93 }94 95 /* ── File uploader ───────────────────────── */96 [data-testid="stFileUploader"] {97 border: 2px dashed #7b8de7;98 border-radius: 10px;99 background: #f4f6ff;100 padding: 1rem;101 }102 103 /* ── Metric cards ────────────────────────── */104 .scorecard-wrap {105 display: flex;106 gap: 1rem;107 margin-bottom: 1.5rem;108 }109 .scorecard-card {110 flex: 1;111 background: #f4f6ff;112 border: 1.5px solid #c7cef5;113 border-radius: 12px;114 padding: 1.25rem 1.5rem;115 box-shadow: 0 2px 8px rgba(67,92,220,0.07);116 text-align: center;117 }118 .scorecard-card .sc-label {119 font-size: 0.82rem;120 font-weight: 600;121 color: #7b8de7;122 text-transform: uppercase;123 letter-spacing: 0.04em;124 margin-bottom: 0.4rem;125 }126 .scorecard-card .sc-value {127 font-size: 2rem;128 font-weight: 800;129 color: #435cdc;130 line-height: 1.1;131 }132 .scorecard-card .sc-sub {133 font-size: 0.78rem;134 color: #7b8de7;135 margin-top: 0.2rem;136 }137 138 /* ── Badge ───────────────────────────────── */139 .badge-success {140 background: #c7cef5; color: #435cdc;141 border-radius: 99px; padding: 2px 10px;142 font-size: 0.78rem; font-weight: 700;143 }144 .badge-warn {145 background: #fff3c4; color: #a07c00;146 border-radius: 99px; padding: 2px 10px;147 font-size: 0.78rem; font-weight: 700;148 }149 .badge-error {150 background: #fde8e8; color: #c0392b;151 border-radius: 99px; padding: 2px 10px;152 font-size: 0.78rem; font-weight: 700;153 }154 155 /* ── Section header ──────────────────────── */156 .section-title {157 font-size: 1.15rem;158 font-weight: 700;159 color: #435cdc;160 margin-bottom: 0.75rem;161 display: flex;162 align-items: center;163 gap: 0.4rem;164 }165 .section-title::after {166 content: '';167 flex: 1;168 height: 2px;169 background: linear-gradient(90deg, #c7cef5, transparent);170 margin-left: 0.5rem;171 }172 173 /* ── Login card ──────────────────────────── */174 .login-card {175 max-width: 420px;176 margin: 4rem auto;177 padding: 2.5rem 2rem;178 background: #ffffff;179 border-radius: 16px;180 box-shadow: 0 4px 32px rgba(67,92,220,0.13);181 border: 1.5px solid #c7cef5;182 }183 .login-card h1 {184 color: #435cdc;185 font-size: 1.8rem;186 font-weight: 800;187 margin-bottom: 0.25rem;188 }189 .login-card p {190 color: #7b8de7;191 font-size: 0.95rem;192 margin-bottom: 1.5rem;193 }194 195 /* ── Divider ─────────────────────────────── */196 hr.styled { border: none; border-top: 1.5px solid #c7cef5; margin: 1.2rem 0; }197 198 /* ── JSON output ─────────────────────────── */199 .profile-json {200 background: #f4f6ff;201 border-radius: 10px;202 border: 1.5px solid #c7cef5;203 padding: 1rem 1.25rem;204 font-size: 0.85rem;205 color: #333e4a;206 white-space: pre-wrap;207 word-break: break-word;208 max-height: 500px;209 overflow-y: auto;210 }211 212 /* ── Table ───────────────────────────────── */213 [data-testid="stDataFrame"] {214 border-radius: 10px;215 overflow: hidden;216 border: 1.5px solid #c7cef5;217 }218 </style>219 """,220 unsafe_allow_html=True,221)222 223 224# ─────────────────────────────────────────────225# SESSION STATE INIT226# ─────────────────────────────────────────────227for key, default in [228 ("token", None),229 ("user", None),230 ("upload_results", []),231 ("extract_result", None),232 ("user_files", []),233]:234 if key not in st.session_state:235 st.session_state[key] = default236 237 238# ─────────────────────────────────────────────239# API HELPERS240# ─────────────────────────────────────────────241def _headers():242 return {"Authorization": f"Bearer {st.session_state.token}"}243 244 245def api_login(email: str, password: str):246 """POST /admin/login — returns (token, error_msg)"""247 try:248 resp = requests.post(249 f"{BASE_URL}/admin/login",250 data={"username": email, "password": password},251 timeout=15,252 )253 if resp.status_code == 200:254 return resp.json().get("access_token"), None255 detail = resp.json().get("detail", resp.text)256 return None, str(detail)257 except requests.exceptions.ConnectionError:258 return None, "Cannot connect to backend. Check BACKEND_BASE_URL."259 except Exception as e:260 return None, str(e)261 262 263def api_get_me():264 """GET /admin/me — returns user dict"""265 try:266 resp = requests.get(f"{BASE_URL}/admin/me", headers=_headers(), timeout=10)267 if resp.status_code == 200:268 return resp.json(), None269 return None, resp.json().get("detail", resp.text)270 except Exception as e:271 return None, str(e)272 273 274def api_get_scorecard():275 """GET /file/score_card — returns data dict"""276 try:277 resp = requests.get(f"{BASE_URL}/file/score_card", headers=_headers(), timeout=10)278 if resp.status_code == 200:279 return resp.json().get("data", {}), None280 return None, resp.json().get("detail", resp.text)281 except Exception as e:282 return None, str(e)283 284 285def api_upload_files(uploaded_files):286 """POST /file/upload — returns list of results"""287 try:288 files = [289 ("files", (f.name, f.read(), "application/pdf"))290 for f in uploaded_files291 ]292 resp = requests.post(293 f"{BASE_URL}/file/upload",294 headers=_headers(),295 files=files,296 timeout=60,297 )298 if resp.status_code == 201:299 return resp.json().get("files", []), None300 return None, resp.json().get("detail", resp.text)301 except Exception as e:302 return None, str(e)303 304 305def api_get_user_files(user_id: str):306 """GET /file/user/{user_id} — returns list of file dicts"""307 try:308 resp = requests.get(309 f"{BASE_URL}/file/user/{user_id}",310 headers=_headers(),311 timeout=10,312 )313 if resp.status_code == 200:314 return resp.json().get("files", []), None315 return None, resp.json().get("detail", resp.text)316 except Exception as e:317 return None, str(e)318 319 320def api_extract_profile(filename: str):321 """POST /profile/extract_profile?filename=... — returns profile dict"""322 try:323 resp = requests.post(324 f"{BASE_URL}/profile/extract_profile",325 headers=_headers(),326 params={"filename": filename},327 timeout=120,328 )329 if resp.status_code == 200:330 return resp.json(), None331 return None, resp.json().get("detail", resp.text)332 except Exception as e:333 return None, str(e)334 335 336def api_delete_file(filename: str):337 """DELETE /file/{filename}"""338 try:339 resp = requests.delete(340 f"{BASE_URL}/file/{filename}",341 headers=_headers(),342 timeout=15,343 )344 if resp.status_code == 200:345 return True, None346 return False, resp.json().get("detail", resp.text)347 except Exception as e:348 return False, str(e)349 350 351# ─────────────────────────────────────────────352# UI COMPONENTS353# ─────────────────────────────────────────────354def render_scorecard():355 sc, err = api_get_scorecard()356 if err:357 st.warning(f"Could not load scorecard: {err}")358 return359 360 total_file = sc.get("total_file", 0)361 total_extracted = sc.get("total_extracted", 0)362 pct = sc.get("percent_extracted", 0)363 # percent_extracted may be a float like 75.0 or string "75%"364 if isinstance(pct, str):365 pct_display = pct366 else:367 pct_display = f"{pct:.1f}%"368 369 st.markdown(370 f"""371 <div class="scorecard-wrap">372 <div class="scorecard-card">373 <div class="sc-label">📁 Total CVs Uploaded</div>374 <div class="sc-value">{total_file}</div>375 <div class="sc-sub">files in your workspace</div>376 </div>377 <div class="scorecard-card">378 <div class="sc-label">✅ Profiles Extracted</div>379 <div class="sc-value">{total_extracted}</div>380 <div class="sc-sub">structured profiles</div>381 </div>382 <div class="scorecard-card">383 <div class="sc-label">📊 Extraction Rate</div>384 <div class="sc-value" style="color:#dcc343">{pct_display}</div>385 <div class="sc-sub">of uploaded CVs processed</div>386 </div>387 </div>388 """,389 unsafe_allow_html=True,390 )391 392 393def render_sidebar():394 user = st.session_state.user or {}395 with st.sidebar:396 st.markdown(397 f"""398 <div style="text-align:center;padding:1rem 0 0.5rem;">399 <div style="font-size:2.5rem;">👤</div>400 <div style="font-weight:800;font-size:1.1rem;color:#435cdc;">401 {user.get('full_name', 'User')}402 </div>403 <div style="font-size:0.82rem;color:#7b8de7;margin-top:2px;">404 {user.get('email', '')}405 </div>406 <span class="badge-success" style="margin-top:6px;display:inline-block;">407 {user.get('role', 'user').upper()}408 </span>409 </div>410 <hr class="styled">411 """,412 unsafe_allow_html=True,413 )414 415 # st.markdown(416 # "<div style='font-size:0.78rem;color:#7b8de7;margin-bottom:4px;'>BACKEND</div>",417 # unsafe_allow_html=True,418 # )419 # st.markdown(420 # f"<code style='font-size:0.75rem;color:#435cdc;'>{BASE_URL}</code>",421 # unsafe_allow_html=True,422 # )423 424 # st.markdown("<hr class='styled'>", unsafe_allow_html=True)425 426 if st.button("🚪 Logout", use_container_width=True):427 for key in ["token", "user", "upload_results", "extract_result", "user_files"]:428 st.session_state[key] = None if key in ("token", "user", "extract_result") else []429 st.rerun()430 431 432# ─────────────────────────────────────────────433# PAGES434# ─────────────────────────────────────────────435def page_login():436 # Center the login card437 _, col, _ = st.columns([1, 1.4, 1])438 with col:439 st.markdown(440 """441 <div class="login-card">442 <h1>🔍 Candidate Explorer</h1>443 <p>Sign in to manage and analyze candidate CVs.</p>444 </div>445 """,446 unsafe_allow_html=True,447 )448 449 with st.form("login_form", clear_on_submit=False):450 st.markdown(451 "<div class='section-title'>Sign In</div>", unsafe_allow_html=True452 )453 email = st.text_input("Email address", placeholder="you@company.com")454 password = st.text_input("Password", type="password", placeholder="••••••••")455 submitted = st.form_submit_button("Sign In →", use_container_width=True)456 457 if submitted:458 if not email or not password:459 st.error("Please enter both email and password.")460 else:461 with st.spinner("Signing in…"):462 token, err = api_login(email, password)463 if err:464 st.error(f"Login failed: {err}")465 else:466 st.session_state.token = token467 user, err2 = api_get_me()468 if err2:469 st.session_state.user = {"email": email, "full_name": email, "role": "user"}470 else:471 st.session_state.user = user472 st.rerun()473 474 475def page_main():476 render_sidebar()477 478 # ── Page header ──────────────────────────────479 st.markdown(480 "<h1 style='color:#435cdc;font-size:1.75rem;font-weight:800;margin-bottom:0.1rem;'>"481 "🔍 Candidate Explorer"482 "</h1>"483 "<p style='color:#7b8de7;margin-top:0;margin-bottom:1.25rem;font-size:0.95rem;'>"484 "Upload CVs, extract candidate profiles, and track your workspace.</p>",485 unsafe_allow_html=True,486 )487 488 # ── Scorecard ─────────────────────────────────489 st.markdown("<div class='section-title'>📊 Dashboard Overview</div>", unsafe_allow_html=True)490 render_scorecard()491 492 st.markdown("<hr class='styled'>", unsafe_allow_html=True)493 494 # ── Tabs ──────────────────────────────────────495 tab_upload, tab_extract = st.tabs(["📁 Upload CV", "🧠 Extract Profile"])496 497 # ══════════════════════════════════════════498 # TAB 1 — UPLOAD499 # ══════════════════════════════════════════500 with tab_upload:501 st.markdown("<br>", unsafe_allow_html=True)502 st.markdown(503 "<div class='section-title'>Upload Candidate CVs</div>",504 unsafe_allow_html=True,505 )506 507 uploaded = st.file_uploader(508 "Drop PDF files here or click to browse",509 type=["pdf"],510 accept_multiple_files=True,511 help="Only PDF files are accepted.",512 )513 514 col_btn, col_info = st.columns([1, 3])515 with col_btn:516 do_upload = st.button("⬆️ Upload", use_container_width=True, disabled=not uploaded)517 518 if do_upload and uploaded:519 with st.spinner(f"Uploading {len(uploaded)} file(s)…"):520 results, err = api_upload_files(uploaded)521 if err:522 st.error(f"Upload failed: {err}")523 else:524 st.session_state.upload_results = results525 # Refresh user files list526 user = st.session_state.user or {}527 uid = str(user.get("user_id", ""))528 if uid:529 files, _ = api_get_user_files(uid)530 st.session_state.user_files = files or []531 st.rerun()532 533 # ── Results table ──534 if st.session_state.upload_results:535 st.markdown("<hr class='styled'>", unsafe_allow_html=True)536 st.markdown(537 "<div class='section-title'>Upload Results</div>",538 unsafe_allow_html=True,539 )540 for r in st.session_state.upload_results:541 fname = r.get("filename", r.get("name", ""))542 status = r.get("status", "uploaded")543 badge_cls = "badge-success" if "success" in status.lower() or status == "uploaded" else "badge-error"544 st.markdown(545 f"<span class='badge-success'>✓</span> "546 f"<strong style='color:#333e4a;'>{fname}</strong> "547 f"<span class='{badge_cls}'>{status}</span>",548 unsafe_allow_html=True,549 )550 551 # ── Existing files ──552 st.markdown("<hr class='styled'>", unsafe_allow_html=True)553 st.markdown(554 "<div class='section-title'>Your Uploaded Files</div>",555 unsafe_allow_html=True,556 )557 558 user = st.session_state.user or {}559 uid = str(user.get("user_id", ""))560 561 col_refresh, _ = st.columns([1, 5])562 with col_refresh:563 if st.button("🔄 Refresh List", use_container_width=True):564 if uid:565 files, err = api_get_user_files(uid)566 if err:567 st.warning(f"Could not load files: {err}")568 else:569 st.session_state.user_files = files or []570 571 if not st.session_state.user_files and uid:572 # Auto-load on first visit573 files, _ = api_get_user_files(uid)574 st.session_state.user_files = files or []575 576 if st.session_state.user_files:577 rows = []578 for f in st.session_state.user_files:579 rows.append(580 {581 "Filename": f.get("filename", ""),582 "Type": f.get("file_type", ""),583 "Extracted": "✅" if f.get("is_extracted") else "⏳",584 "Uploaded": str(f.get("uploaded_at", ""))[:19],585 }586 )587 st.dataframe(rows, use_container_width=True, hide_index=True)588 else:589 st.info("No files uploaded yet.")590 591 # ══════════════════════════════════════════592 # TAB 2 — EXTRACT PROFILE593 # ══════════════════════════════════════════594 with tab_extract:595 st.markdown("<br>", unsafe_allow_html=True)596 st.markdown(597 "<div class='section-title'>Extract Structured Profile from CV</div>",598 unsafe_allow_html=True,599 )600 601 # Load files if needed602 user = st.session_state.user or {}603 uid = str(user.get("user_id", ""))604 if not st.session_state.user_files and uid:605 files, _ = api_get_user_files(uid)606 st.session_state.user_files = files or []607 608 file_options = [f.get("filename", "") for f in st.session_state.user_files if f.get("filename")]609 610 if not file_options:611 st.info("No CVs found. Upload files first in the **Upload CV** tab.")612 else:613 col_sel, col_ex = st.columns([3, 1])614 with col_sel:615 chosen = st.selectbox(616 "Select a CV file to extract",617 options=file_options,618 help="Choose a PDF you have already uploaded.",619 )620 with col_ex:621 st.markdown("<div style='margin-top:1.72rem;'></div>", unsafe_allow_html=True)622 do_extract = st.button("🧠 Extract", use_container_width=True)623 624 if do_extract and chosen:625 with st.spinner(f"Extracting profile from **{chosen}**… this may take a moment."):626 result, err = api_extract_profile(chosen)627 if err:628 st.error(f"Extraction failed: {err}")629 st.session_state.extract_result = None630 else:631 st.session_state.extract_result = result632 # Refresh files to update is_extracted flag633 if uid:634 files, _ = api_get_user_files(uid)635 st.session_state.user_files = files or []636 st.rerun()637 638 # ── Display extracted profile ──639 if st.session_state.extract_result:640 st.markdown("<hr class='styled'>", unsafe_allow_html=True)641 result = st.session_state.extract_result642 643 # Try to highlight key fields644 fullname = result.get("fullname") or result.get("full_name", "")645 if fullname:646 st.markdown(647 f"<div style='background:#c7cef5;border-radius:10px;padding:0.75rem 1.2rem;"648 f"margin-bottom:1rem;'>"649 f"<span style='color:#435cdc;font-weight:800;font-size:1.1rem;'>👤 {fullname}</span>"650 f"</div>",651 unsafe_allow_html=True,652 )653 654 col_l, col_r = st.columns(2)655 656 with col_l:657 st.markdown("<div class='section-title'>Education</div>", unsafe_allow_html=True)658 for i in range(1, 4):659 univ = result.get(f"univ_edu_{i}", "")660 major = result.get(f"major_edu_{i}", "")661 gpa = result.get(f"gpa_edu_{i}", "")662 if univ or major:663 gpa_str = f" · GPA {gpa}" if gpa else ""664 st.markdown(665 f"<div style='margin-bottom:0.5rem;padding:0.6rem 1rem;"666 f"background:#f4f6ff;border-radius:8px;border-left:3px solid #435cdc;'>"667 f"<strong style='color:#333e4a;'>{univ or '—'}</strong><br>"668 f"<span style='color:#7b8de7;font-size:0.85rem;'>{major or ''}{gpa_str}</span>"669 f"</div>",670 unsafe_allow_html=True,671 )672 673 st.markdown("<div class='section-title' style='margin-top:1rem;'>Experience</div>", unsafe_allow_html=True)674 yoe = result.get("yoe")675 domicile = result.get("domicile", "")676 st.markdown(677 f"<div style='padding:0.6rem 1rem;background:#f4f6ff;border-radius:8px;"678 f"border-left:3px solid #dcc343;'>"679 f"<span style='color:#333e4a;font-weight:600;'>Years of Experience:</span> "680 f"<span style='color:#435cdc;font-weight:800;'>{yoe if yoe is not None else '—'}</span><br>"681 f"<span style='color:#333e4a;font-weight:600;'>Domicile:</span> "682 f"<span style='color:#435cdc;'>{domicile or '—'}</span>"683 f"</div>",684 unsafe_allow_html=True,685 )686 687 with col_r:688 def _tag_list(label, items, color="#c7cef5", text_color="#435cdc"):689 if not items:690 return691 st.markdown(692 f"<div class='section-title'>{label}</div>",693 unsafe_allow_html=True,694 )695 tags = "".join(696 f"<span style='background:{color};color:{text_color};border-radius:99px;"697 f"padding:3px 10px;font-size:0.78rem;font-weight:600;margin:2px;display:inline-block;'>"698 f"{t}</span>"699 for t in items700 )701 st.markdown(702 f"<div style='margin-bottom:0.75rem;line-height:2;'>{tags}</div>",703 unsafe_allow_html=True,704 )705 706 _tag_list("💻 Hard Skills", result.get("hardskills", []))707 _tag_list("🤝 Soft Skills", result.get("softskills", []), "#f4f6ff", "#333e4a")708 _tag_list("🏆 Certifications", result.get("certifications", []), "#fff3c4", "#a07c00")709 _tag_list("🏢 Business Domains", result.get("business_domain", []), "#c7cef5", "#435cdc")710 711 # Raw JSON toggle712 with st.expander("📄 Raw JSON response"):713 st.markdown(714 f"<div class='profile-json'>{json.dumps(result, indent=2, default=str)}</div>",715 unsafe_allow_html=True,716 )717 718 719# ─────────────────────────────────────────────720# ROUTER721# ─────────────────────────────────────────────722if st.session_state.token:723 page_main()724else:725 page_login()726 