| import os |
| import dotenv |
| dotenv.load_dotenv() |
| import json |
| import requests |
| import streamlit as st |
|
|
| |
| |
| |
| BASE_URL = os.environ.get("BACKEND_BASE_URL", "http://localhost:8000") |
|
|
| st.set_page_config( |
| page_title="Candidate Explorer", |
| page_icon="π", |
| layout="wide", |
| initial_sidebar_state="expanded", |
| ) |
|
|
| |
| |
| |
| st.markdown( |
| """ |
| <style> |
| /* ββ Global βββββββββββββββββββββββββββββββ */ |
| html, body, [class*="css"] { |
| font-family: 'Inter', 'Segoe UI', sans-serif; |
| color: #333e4a; |
| background-color: #ffffff; |
| } |
| |
| /* ββ Sidebar βββββββββββββββββββββββββββββββ */ |
| [data-testid="stSidebar"] { |
| background-color: #f4f6ff; |
| border-right: 1px solid #c7cef5; |
| } |
| [data-testid="stSidebar"] h1, |
| [data-testid="stSidebar"] h2, |
| [data-testid="stSidebar"] h3, |
| [data-testid="stSidebar"] label { |
| color: #435cdc !important; |
| } |
| |
| /* ββ Buttons βββββββββββββββββββββββββββββββ */ |
| .stButton > button { |
| background-color: #435cdc; |
| color: #ffffff; |
| border: none; |
| border-radius: 8px; |
| padding: 0.5rem 1.25rem; |
| font-weight: 600; |
| transition: background-color 0.2s ease; |
| } |
| .stButton > button:hover { |
| background-color: #7b8de7; |
| color: #ffffff; |
| } |
| .stButton > button:focus { |
| outline: 2px solid #c7cef5; |
| } |
| |
| /* ββ Tabs βββββββββββββββββββββββββββββββββββ */ |
| [data-baseweb="tab-list"] { |
| gap: 8px; |
| border-bottom: 2px solid #c7cef5; |
| } |
| [data-baseweb="tab"] { |
| border-radius: 8px 8px 0 0; |
| padding: 0.5rem 1.25rem; |
| font-weight: 600; |
| color: #7b8de7; |
| background: transparent; |
| } |
| [aria-selected="true"][data-baseweb="tab"] { |
| color: #435cdc !important; |
| border-bottom: 3px solid #435cdc !important; |
| background: #f4f6ff; |
| } |
| |
| /* ββ Inputs ββββββββββββββββββββββββββββββββ */ |
| [data-testid="stTextInput"] input, |
| [data-testid="stSelectbox"] select, |
| textarea { |
| border-radius: 8px !important; |
| border: 1.5px solid #c7cef5 !important; |
| color: #333e4a !important; |
| } |
| [data-testid="stTextInput"] input:focus, |
| textarea:focus { |
| border-color: #435cdc !important; |
| box-shadow: 0 0 0 2px #c7cef5; |
| } |
| |
| /* ββ File uploader βββββββββββββββββββββββββ */ |
| [data-testid="stFileUploader"] { |
| border: 2px dashed #7b8de7; |
| border-radius: 10px; |
| background: #f4f6ff; |
| padding: 1rem; |
| } |
| |
| /* ββ Metric cards ββββββββββββββββββββββββββ */ |
| .scorecard-wrap { |
| display: flex; |
| gap: 1rem; |
| margin-bottom: 1.5rem; |
| } |
| .scorecard-card { |
| flex: 1; |
| background: #f4f6ff; |
| border: 1.5px solid #c7cef5; |
| border-radius: 12px; |
| padding: 1.25rem 1.5rem; |
| box-shadow: 0 2px 8px rgba(67,92,220,0.07); |
| text-align: center; |
| } |
| .scorecard-card .sc-label { |
| font-size: 0.82rem; |
| font-weight: 600; |
| color: #7b8de7; |
| text-transform: uppercase; |
| letter-spacing: 0.04em; |
| margin-bottom: 0.4rem; |
| } |
| .scorecard-card .sc-value { |
| font-size: 2rem; |
| font-weight: 800; |
| color: #435cdc; |
| line-height: 1.1; |
| } |
| .scorecard-card .sc-sub { |
| font-size: 0.78rem; |
| color: #7b8de7; |
| margin-top: 0.2rem; |
| } |
| |
| /* ββ Badge βββββββββββββββββββββββββββββββββ */ |
| .badge-success { |
| background: #c7cef5; color: #435cdc; |
| border-radius: 99px; padding: 2px 10px; |
| font-size: 0.78rem; font-weight: 700; |
| } |
| .badge-warn { |
| background: #fff3c4; color: #a07c00; |
| border-radius: 99px; padding: 2px 10px; |
| font-size: 0.78rem; font-weight: 700; |
| } |
| .badge-error { |
| background: #fde8e8; color: #c0392b; |
| border-radius: 99px; padding: 2px 10px; |
| font-size: 0.78rem; font-weight: 700; |
| } |
| |
| /* ββ Section header ββββββββββββββββββββββββ */ |
| .section-title { |
| font-size: 1.15rem; |
| font-weight: 700; |
| color: #435cdc; |
| margin-bottom: 0.75rem; |
| display: flex; |
| align-items: center; |
| gap: 0.4rem; |
| } |
| .section-title::after { |
| content: ''; |
| flex: 1; |
| height: 2px; |
| background: linear-gradient(90deg, #c7cef5, transparent); |
| margin-left: 0.5rem; |
| } |
| |
| /* ββ Login card ββββββββββββββββββββββββββββ */ |
| .login-card { |
| max-width: 420px; |
| margin: 4rem auto; |
| padding: 2.5rem 2rem; |
| background: #ffffff; |
| border-radius: 16px; |
| box-shadow: 0 4px 32px rgba(67,92,220,0.13); |
| border: 1.5px solid #c7cef5; |
| } |
| .login-card h1 { |
| color: #435cdc; |
| font-size: 1.8rem; |
| font-weight: 800; |
| margin-bottom: 0.25rem; |
| } |
| .login-card p { |
| color: #7b8de7; |
| font-size: 0.95rem; |
| margin-bottom: 1.5rem; |
| } |
| |
| /* ββ Divider βββββββββββββββββββββββββββββββ */ |
| hr.styled { border: none; border-top: 1.5px solid #c7cef5; margin: 1.2rem 0; } |
| |
| /* ββ JSON output βββββββββββββββββββββββββββ */ |
| .profile-json { |
| background: #f4f6ff; |
| border-radius: 10px; |
| border: 1.5px solid #c7cef5; |
| padding: 1rem 1.25rem; |
| font-size: 0.85rem; |
| color: #333e4a; |
| white-space: pre-wrap; |
| word-break: break-word; |
| max-height: 500px; |
| overflow-y: auto; |
| } |
| |
| /* ββ Table βββββββββββββββββββββββββββββββββ */ |
| [data-testid="stDataFrame"] { |
| border-radius: 10px; |
| overflow: hidden; |
| border: 1.5px solid #c7cef5; |
| } |
| </style> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| |
| |
| |
| for key, default in [ |
| ("token", None), |
| ("user", None), |
| ("upload_results", []), |
| ("extract_result", None), |
| ("user_files", []), |
| ]: |
| if key not in st.session_state: |
| st.session_state[key] = default |
|
|
|
|
| |
| |
| |
| def _headers(): |
| return {"Authorization": f"Bearer {st.session_state.token}"} |
|
|
|
|
| def api_login(email: str, password: str): |
| """POST /admin/login β returns (token, error_msg)""" |
| try: |
| resp = requests.post( |
| f"{BASE_URL}/admin/login", |
| data={"username": email, "password": password}, |
| timeout=15, |
| ) |
| if resp.status_code == 200: |
| return resp.json().get("access_token"), None |
| detail = resp.json().get("detail", resp.text) |
| return None, str(detail) |
| except requests.exceptions.ConnectionError: |
| return None, "Cannot connect to backend. Check BACKEND_BASE_URL." |
| except Exception as e: |
| return None, str(e) |
|
|
|
|
| def api_get_me(): |
| """GET /admin/me β returns user dict""" |
| try: |
| resp = requests.get(f"{BASE_URL}/admin/me", headers=_headers(), timeout=10) |
| if resp.status_code == 200: |
| return resp.json(), None |
| return None, resp.json().get("detail", resp.text) |
| except Exception as e: |
| return None, str(e) |
|
|
|
|
| def api_get_scorecard(): |
| """GET /file/score_card β returns data dict""" |
| try: |
| resp = requests.get(f"{BASE_URL}/file/score_card", headers=_headers(), timeout=10) |
| if resp.status_code == 200: |
| return resp.json().get("data", {}), None |
| return None, resp.json().get("detail", resp.text) |
| except Exception as e: |
| return None, str(e) |
|
|
|
|
| def api_upload_files(uploaded_files): |
| """POST /file/upload β returns list of results""" |
| try: |
| files = [ |
| ("files", (f.name, f.read(), "application/pdf")) |
| for f in uploaded_files |
| ] |
| resp = requests.post( |
| f"{BASE_URL}/file/upload", |
| headers=_headers(), |
| files=files, |
| timeout=60, |
| ) |
| if resp.status_code == 201: |
| return resp.json().get("files", []), None |
| return None, resp.json().get("detail", resp.text) |
| except Exception as e: |
| return None, str(e) |
|
|
|
|
| def api_get_user_files(user_id: str): |
| """GET /file/user/{user_id} β returns list of file dicts""" |
| try: |
| resp = requests.get( |
| f"{BASE_URL}/file/user/{user_id}", |
| headers=_headers(), |
| timeout=10, |
| ) |
| if resp.status_code == 200: |
| return resp.json().get("files", []), None |
| return None, resp.json().get("detail", resp.text) |
| except Exception as e: |
| return None, str(e) |
|
|
|
|
| def api_extract_profile(filename: str): |
| """POST /profile/extract_profile?filename=... β returns profile dict""" |
| try: |
| resp = requests.post( |
| f"{BASE_URL}/profile/extract_profile", |
| headers=_headers(), |
| params={"filename": filename}, |
| timeout=120, |
| ) |
| if resp.status_code == 200: |
| return resp.json(), None |
| return None, resp.json().get("detail", resp.text) |
| except Exception as e: |
| return None, str(e) |
|
|
|
|
| def api_delete_file(filename: str): |
| """DELETE /file/{filename}""" |
| try: |
| resp = requests.delete( |
| f"{BASE_URL}/file/{filename}", |
| headers=_headers(), |
| timeout=15, |
| ) |
| if resp.status_code == 200: |
| return True, None |
| return False, resp.json().get("detail", resp.text) |
| except Exception as e: |
| return False, str(e) |
|
|
|
|
| |
| |
| |
| def render_scorecard(): |
| sc, err = api_get_scorecard() |
| if err: |
| st.warning(f"Could not load scorecard: {err}") |
| return |
|
|
| total_file = sc.get("total_file", 0) |
| total_extracted = sc.get("total_extracted", 0) |
| pct = sc.get("percent_extracted", 0) |
| |
| if isinstance(pct, str): |
| pct_display = pct |
| else: |
| pct_display = f"{pct:.1f}%" |
|
|
| st.markdown( |
| f""" |
| <div class="scorecard-wrap"> |
| <div class="scorecard-card"> |
| <div class="sc-label">π Total CVs Uploaded</div> |
| <div class="sc-value">{total_file}</div> |
| <div class="sc-sub">files in your workspace</div> |
| </div> |
| <div class="scorecard-card"> |
| <div class="sc-label">β
Profiles Extracted</div> |
| <div class="sc-value">{total_extracted}</div> |
| <div class="sc-sub">structured profiles</div> |
| </div> |
| <div class="scorecard-card"> |
| <div class="sc-label">π Extraction Rate</div> |
| <div class="sc-value" style="color:#dcc343">{pct_display}</div> |
| <div class="sc-sub">of uploaded CVs processed</div> |
| </div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| def render_sidebar(): |
| user = st.session_state.user or {} |
| with st.sidebar: |
| st.markdown( |
| f""" |
| <div style="text-align:center;padding:1rem 0 0.5rem;"> |
| <div style="font-size:2.5rem;">π€</div> |
| <div style="font-weight:800;font-size:1.1rem;color:#435cdc;"> |
| {user.get('full_name', 'User')} |
| </div> |
| <div style="font-size:0.82rem;color:#7b8de7;margin-top:2px;"> |
| {user.get('email', '')} |
| </div> |
| <span class="badge-success" style="margin-top:6px;display:inline-block;"> |
| {user.get('role', 'user').upper()} |
| </span> |
| </div> |
| <hr class="styled"> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| if st.button("πͺ Logout", use_container_width=True): |
| for key in ["token", "user", "upload_results", "extract_result", "user_files"]: |
| st.session_state[key] = None if key in ("token", "user", "extract_result") else [] |
| st.rerun() |
|
|
|
|
| |
| |
| |
| def page_login(): |
| |
| _, col, _ = st.columns([1, 1.4, 1]) |
| with col: |
| st.markdown( |
| """ |
| <div class="login-card"> |
| <h1>π Candidate Explorer</h1> |
| <p>Sign in to manage and analyze candidate CVs.</p> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
| with st.form("login_form", clear_on_submit=False): |
| st.markdown( |
| "<div class='section-title'>Sign In</div>", unsafe_allow_html=True |
| ) |
| email = st.text_input("Email address", placeholder="you@company.com") |
| password = st.text_input("Password", type="password", placeholder="β’β’β’β’β’β’β’β’") |
| submitted = st.form_submit_button("Sign In β", use_container_width=True) |
|
|
| if submitted: |
| if not email or not password: |
| st.error("Please enter both email and password.") |
| else: |
| with st.spinner("Signing inβ¦"): |
| token, err = api_login(email, password) |
| if err: |
| st.error(f"Login failed: {err}") |
| else: |
| st.session_state.token = token |
| user, err2 = api_get_me() |
| if err2: |
| st.session_state.user = {"email": email, "full_name": email, "role": "user"} |
| else: |
| st.session_state.user = user |
| st.rerun() |
|
|
|
|
| def page_main(): |
| render_sidebar() |
|
|
| |
| st.markdown( |
| "<h1 style='color:#435cdc;font-size:1.75rem;font-weight:800;margin-bottom:0.1rem;'>" |
| "π Candidate Explorer" |
| "</h1>" |
| "<p style='color:#7b8de7;margin-top:0;margin-bottom:1.25rem;font-size:0.95rem;'>" |
| "Upload CVs, extract candidate profiles, and track your workspace.</p>", |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| st.markdown("<div class='section-title'>π Dashboard Overview</div>", unsafe_allow_html=True) |
| render_scorecard() |
|
|
| st.markdown("<hr class='styled'>", unsafe_allow_html=True) |
|
|
| |
| tab_upload, tab_extract = st.tabs(["π Upload CV", "π§ Extract Profile"]) |
|
|
| |
| |
| |
| with tab_upload: |
| st.markdown("<br>", unsafe_allow_html=True) |
| st.markdown( |
| "<div class='section-title'>Upload Candidate CVs</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
| uploaded = st.file_uploader( |
| "Drop PDF files here or click to browse", |
| type=["pdf"], |
| accept_multiple_files=True, |
| help="Only PDF files are accepted.", |
| ) |
|
|
| col_btn, col_info = st.columns([1, 3]) |
| with col_btn: |
| do_upload = st.button("β¬οΈ Upload", use_container_width=True, disabled=not uploaded) |
|
|
| if do_upload and uploaded: |
| with st.spinner(f"Uploading {len(uploaded)} file(s)β¦"): |
| results, err = api_upload_files(uploaded) |
| if err: |
| st.error(f"Upload failed: {err}") |
| else: |
| st.session_state.upload_results = results |
| |
| user = st.session_state.user or {} |
| uid = str(user.get("user_id", "")) |
| if uid: |
| files, _ = api_get_user_files(uid) |
| st.session_state.user_files = files or [] |
| st.rerun() |
|
|
| |
| if st.session_state.upload_results: |
| st.markdown("<hr class='styled'>", unsafe_allow_html=True) |
| st.markdown( |
| "<div class='section-title'>Upload Results</div>", |
| unsafe_allow_html=True, |
| ) |
| for r in st.session_state.upload_results: |
| fname = r.get("filename", r.get("name", "")) |
| status = r.get("status", "uploaded") |
| badge_cls = "badge-success" if "success" in status.lower() or status == "uploaded" else "badge-error" |
| st.markdown( |
| f"<span class='badge-success'>β</span> " |
| f"<strong style='color:#333e4a;'>{fname}</strong> " |
| f"<span class='{badge_cls}'>{status}</span>", |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| st.markdown("<hr class='styled'>", unsafe_allow_html=True) |
| st.markdown( |
| "<div class='section-title'>Your Uploaded Files</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
| user = st.session_state.user or {} |
| uid = str(user.get("user_id", "")) |
|
|
| col_refresh, _ = st.columns([1, 5]) |
| with col_refresh: |
| if st.button("π Refresh List", use_container_width=True): |
| if uid: |
| files, err = api_get_user_files(uid) |
| if err: |
| st.warning(f"Could not load files: {err}") |
| else: |
| st.session_state.user_files = files or [] |
|
|
| if not st.session_state.user_files and uid: |
| |
| files, _ = api_get_user_files(uid) |
| st.session_state.user_files = files or [] |
|
|
| if st.session_state.user_files: |
| rows = [] |
| for f in st.session_state.user_files: |
| rows.append( |
| { |
| "Filename": f.get("filename", ""), |
| "Type": f.get("file_type", ""), |
| "Extracted": "β
" if f.get("is_extracted") else "β³", |
| "Uploaded": str(f.get("uploaded_at", ""))[:19], |
| } |
| ) |
| st.dataframe(rows, use_container_width=True, hide_index=True) |
| else: |
| st.info("No files uploaded yet.") |
|
|
| |
| |
| |
| with tab_extract: |
| st.markdown("<br>", unsafe_allow_html=True) |
| st.markdown( |
| "<div class='section-title'>Extract Structured Profile from CV</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| user = st.session_state.user or {} |
| uid = str(user.get("user_id", "")) |
| if not st.session_state.user_files and uid: |
| files, _ = api_get_user_files(uid) |
| st.session_state.user_files = files or [] |
|
|
| file_options = [f.get("filename", "") for f in st.session_state.user_files if f.get("filename")] |
|
|
| if not file_options: |
| st.info("No CVs found. Upload files first in the **Upload CV** tab.") |
| else: |
| col_sel, col_ex = st.columns([3, 1]) |
| with col_sel: |
| chosen = st.selectbox( |
| "Select a CV file to extract", |
| options=file_options, |
| help="Choose a PDF you have already uploaded.", |
| ) |
| with col_ex: |
| st.markdown("<div style='margin-top:1.72rem;'></div>", unsafe_allow_html=True) |
| do_extract = st.button("π§ Extract", use_container_width=True) |
|
|
| if do_extract and chosen: |
| with st.spinner(f"Extracting profile from **{chosen}**β¦ this may take a moment."): |
| result, err = api_extract_profile(chosen) |
| if err: |
| st.error(f"Extraction failed: {err}") |
| st.session_state.extract_result = None |
| else: |
| st.session_state.extract_result = result |
| |
| if uid: |
| files, _ = api_get_user_files(uid) |
| st.session_state.user_files = files or [] |
| st.rerun() |
|
|
| |
| if st.session_state.extract_result: |
| st.markdown("<hr class='styled'>", unsafe_allow_html=True) |
| result = st.session_state.extract_result |
|
|
| |
| fullname = result.get("fullname") or result.get("full_name", "") |
| if fullname: |
| st.markdown( |
| f"<div style='background:#c7cef5;border-radius:10px;padding:0.75rem 1.2rem;" |
| f"margin-bottom:1rem;'>" |
| f"<span style='color:#435cdc;font-weight:800;font-size:1.1rem;'>π€ {fullname}</span>" |
| f"</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
| col_l, col_r = st.columns(2) |
|
|
| with col_l: |
| st.markdown("<div class='section-title'>Education</div>", unsafe_allow_html=True) |
| for i in range(1, 4): |
| univ = result.get(f"univ_edu_{i}", "") |
| major = result.get(f"major_edu_{i}", "") |
| gpa = result.get(f"gpa_edu_{i}", "") |
| if univ or major: |
| gpa_str = f" Β· GPA {gpa}" if gpa else "" |
| st.markdown( |
| f"<div style='margin-bottom:0.5rem;padding:0.6rem 1rem;" |
| f"background:#f4f6ff;border-radius:8px;border-left:3px solid #435cdc;'>" |
| f"<strong style='color:#333e4a;'>{univ or 'β'}</strong><br>" |
| f"<span style='color:#7b8de7;font-size:0.85rem;'>{major or ''}{gpa_str}</span>" |
| f"</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
| st.markdown("<div class='section-title' style='margin-top:1rem;'>Experience</div>", unsafe_allow_html=True) |
| yoe = result.get("yoe") |
| domicile = result.get("domicile", "") |
| st.markdown( |
| f"<div style='padding:0.6rem 1rem;background:#f4f6ff;border-radius:8px;" |
| f"border-left:3px solid #dcc343;'>" |
| f"<span style='color:#333e4a;font-weight:600;'>Years of Experience:</span> " |
| f"<span style='color:#435cdc;font-weight:800;'>{yoe if yoe is not None else 'β'}</span><br>" |
| f"<span style='color:#333e4a;font-weight:600;'>Domicile:</span> " |
| f"<span style='color:#435cdc;'>{domicile or 'β'}</span>" |
| f"</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
| with col_r: |
| def _tag_list(label, items, color="#c7cef5", text_color="#435cdc"): |
| if not items: |
| return |
| st.markdown( |
| f"<div class='section-title'>{label}</div>", |
| unsafe_allow_html=True, |
| ) |
| tags = "".join( |
| f"<span style='background:{color};color:{text_color};border-radius:99px;" |
| f"padding:3px 10px;font-size:0.78rem;font-weight:600;margin:2px;display:inline-block;'>" |
| f"{t}</span>" |
| for t in items |
| ) |
| st.markdown( |
| f"<div style='margin-bottom:0.75rem;line-height:2;'>{tags}</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
| _tag_list("π» Hard Skills", result.get("hardskills", [])) |
| _tag_list("π€ Soft Skills", result.get("softskills", []), "#f4f6ff", "#333e4a") |
| _tag_list("π Certifications", result.get("certifications", []), "#fff3c4", "#a07c00") |
| _tag_list("π’ Business Domains", result.get("business_domain", []), "#c7cef5", "#435cdc") |
|
|
| |
| with st.expander("π Raw JSON response"): |
| st.markdown( |
| f"<div class='profile-json'>{json.dumps(result, indent=2, default=str)}</div>", |
| unsafe_allow_html=True, |
| ) |
|
|
|
|
| |
| |
| |
| if st.session_state.token: |
| page_main() |
| else: |
| page_login() |
|
|