CoolFace
Apppublic

ByteRiot/CandidateExplorer

sourceHugging Faceupdated 7mo agoView on Hugging Face
1likes
streamlit_app.py726 linesDownload Raw Back to root
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>&nbsp;"546                    f"<strong style='color:#333e4a;'>{fname}</strong>&nbsp;"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