ishaq101's picture
[KM-385] [CEX] [FE] Admin Page for upload and extract CV
fb20f60
import os
import dotenv
dotenv.load_dotenv()
import json
import requests
import streamlit as st
# ─────────────────────────────────────────────
# CONFIG
# ─────────────────────────────────────────────
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",
)
# ─────────────────────────────────────────────
# GLOBAL CSS
# ─────────────────────────────────────────────
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,
)
# ─────────────────────────────────────────────
# SESSION STATE INIT
# ─────────────────────────────────────────────
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
# ─────────────────────────────────────────────
# API HELPERS
# ─────────────────────────────────────────────
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)
# ─────────────────────────────────────────────
# UI COMPONENTS
# ─────────────────────────────────────────────
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)
# percent_extracted may be a float like 75.0 or string "75%"
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,
)
# st.markdown(
# "<div style='font-size:0.78rem;color:#7b8de7;margin-bottom:4px;'>BACKEND</div>",
# unsafe_allow_html=True,
# )
# st.markdown(
# f"<code style='font-size:0.75rem;color:#435cdc;'>{BASE_URL}</code>",
# unsafe_allow_html=True,
# )
# st.markdown("<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()
# ─────────────────────────────────────────────
# PAGES
# ─────────────────────────────────────────────
def page_login():
# Center the login card
_, 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()
# ── Page header ──────────────────────────────
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,
)
# ── Scorecard ─────────────────────────────────
st.markdown("<div class='section-title'>πŸ“Š Dashboard Overview</div>", unsafe_allow_html=True)
render_scorecard()
st.markdown("<hr class='styled'>", unsafe_allow_html=True)
# ── Tabs ──────────────────────────────────────
tab_upload, tab_extract = st.tabs(["πŸ“ Upload CV", "🧠 Extract Profile"])
# ══════════════════════════════════════════
# TAB 1 β€” UPLOAD
# ══════════════════════════════════════════
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
# Refresh user files list
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()
# ── Results table ──
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>&nbsp;"
f"<strong style='color:#333e4a;'>{fname}</strong>&nbsp;"
f"<span class='{badge_cls}'>{status}</span>",
unsafe_allow_html=True,
)
# ── Existing files ──
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:
# Auto-load on first visit
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.")
# ══════════════════════════════════════════
# TAB 2 β€” EXTRACT PROFILE
# ══════════════════════════════════════════
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,
)
# Load files if needed
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
# Refresh files to update is_extracted flag
if uid:
files, _ = api_get_user_files(uid)
st.session_state.user_files = files or []
st.rerun()
# ── Display extracted profile ──
if st.session_state.extract_result:
st.markdown("<hr class='styled'>", unsafe_allow_html=True)
result = st.session_state.extract_result
# Try to highlight key fields
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")
# Raw JSON toggle
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,
)
# ─────────────────────────────────────────────
# ROUTER
# ─────────────────────────────────────────────
if st.session_state.token:
page_main()
else:
page_login()