"""
streamlit_app.py
----------------
SmartHire AI โ Dark Mode Recruiter Dashboard
Powered by all-MiniLM-L6-v2 Sentence Transformer Embeddings
Run with:
streamlit run app/streamlit_app.py
Author: SmartHire AI
"""
import logging
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
import torch
ROOT = Path(__file__).parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.model import get_model
from src.parser import parse_job_description, parse_resume
from src.preprocess import preprocess_text
from src.ranking import CandidateResult, rank_candidates, results_to_dataframe, summarize_rankings
from src.similarity import batch_similarity
from src.skills import full_skill_analysis
from src.vector_store import get_vector_store
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("SmartHireAI-App")
# Bump this string any time vector_store.py changes โ forces Streamlit cache bust
_VS_CACHE_KEY = "v3"
st.set_page_config(
page_title="SmartHire AI",
page_icon="๐ค",
layout="wide",
initial_sidebar_state="expanded",
)
st.markdown(
"""
""",
unsafe_allow_html=True,
)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Cached Resource Loading
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@st.cache_resource(show_spinner=False)
def load_model():
return get_model()
@st.cache_resource(show_spinner=False)
def load_vector_store(_cache_key: str = "v3"):
"""_cache_key forces Streamlit to reload when incremented."""
return get_vector_store(persist_dir=str(ROOT / "vector_db"))
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Helper Functions
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def badge_html(recommendation: str) -> str:
class_map = {
"Highly Recommended": "badge-hr",
"Recommended": "badge-rec",
"Consider": "badge-con",
"Not Recommended": "badge-nr",
}
return f'{recommendation}'
def render_skill_chips(skills: list, chip_class: str) -> str:
if not skills:
return "None detected"
return " ".join(f'{s}' for s in skills)
def score_color(pct: float) -> str:
if pct >= 90: return "#4ade80"
elif pct >= 80: return "#00d4ff"
elif pct >= 70: return "#fbbf24"
else: return "#f87171"
def render_progress_bar(pct: float, color: str, height: int = 22) -> str:
return f"""
"""
def safe_get_all_metadata(vs) -> List[Dict]:
"""Safe wrapper โ works even if old cached VectorStore lacks get_all_metadata."""
if hasattr(vs, "get_all_metadata"):
return vs.get_all_metadata()
names = vs.get_all_names() if hasattr(vs, "get_all_names") else []
return [{"name": n, "text_length": 0, "embedding_dim": "N/A", "indexed_at": "N/A"} for n in names]
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Load Resources
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with st.spinner("โก Loading SmartHire AI model (first load ~10s, then cached)..."):
model = load_model()
st.success("โ
SmartHire AI model ready", icon="๐ค")
vector_store = load_vector_store(_cache_key=_VS_CACHE_KEY)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Sidebar
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with st.sidebar:
st.markdown(
"""
๐ค
SmartHire AI
Transformer-Based Hiring
""",
unsafe_allow_html=True,
)
st.markdown("", unsafe_allow_html=True)
st.markdown("### โ๏ธ Settings")
similarity_weight = st.slider(
"Semantic Similarity Weight",
min_value=0.5, max_value=0.9, value=0.7, step=0.05,
help="Weight given to semantic similarity vs skill coverage",
)
skill_weight = round(1.0 - similarity_weight, 2)
st.caption(f"Skill Coverage Weight: **{skill_weight}**")
st.markdown("", unsafe_allow_html=True)
st.markdown("### ๐ Model Info")
try:
info = model.get_model_info()
finetuned_badge = " ๐ฏ" if info.get("is_finetuned") else ""
st.markdown(
f"""
""",
unsafe_allow_html=True,
)
except Exception:
st.markdown(
"""""",
unsafe_allow_html=True,
)
st.markdown("", unsafe_allow_html=True)
st.markdown("### ๐๏ธ Vector Index")
vs_info_sb = vector_store.get_info()
vs_count_sb = vs_info_sb["count"]
vs_color_sb = "#4ade80" if vs_count_sb > 0 else "#64748b"
vs_dim_sb = vs_info_sb.get("dim", "N/A")
try:
model_dim_sb = model.get_model_info()["embedding_dim"]
except Exception:
model_dim_sb = None
dim_warn = ""
if vs_count_sb > 0 and model_dim_sb and vs_dim_sb != "N/A":
if int(vs_dim_sb) != int(model_dim_sb):
dim_warn = "
โ ๏ธ Dim mismatch โ clear & rebuild!"
st.markdown(
f"""
""",
unsafe_allow_html=True,
)
st.markdown("", unsafe_allow_html=True)
st.markdown("### ๐ฏ Score Legend")
st.markdown(
"""
โฅ90% Highly Recommended
โฅ80% Recommended
โฅ70% Consider
<70% Not Recommended
""",
unsafe_allow_html=True,
)
st.markdown("", unsafe_allow_html=True)
st.markdown(
"SmartHire AI ยท all-MiniLM-L6-v2 ยท PyTorch
",
unsafe_allow_html=True,
)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Main Header
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
st.markdown(
"""
๐ค SmartHire AI
Transformer-Based Resume & Job Matching System ยท
all-MiniLM-L6-v2 ยท Semantic NLP ยท
Candidate Ranking ยท Skill Gap Analysis ยท Vector Index
""",
unsafe_allow_html=True,
)
tab_upload, tab_results, tab_skills, tab_ranking, tab_vector = st.tabs(
["๐ค Upload & Analyze", "๐ Match Results", "๐ Skill Gap Analysis", "๐ Candidate Ranking", "๐๏ธ Vector Index"]
)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# TAB 1 โ Upload & Analyze
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with tab_upload:
col_left, col_right = st.columns([1, 1], gap="large")
with col_left:
st.markdown("๐ Job Description
", unsafe_allow_html=True)
jd_input_mode = st.radio("Input method", ["Paste text", "Upload file"],
horizontal=True, label_visibility="collapsed")
jd_text_raw: Optional[str] = None
if jd_input_mode == "Paste text":
jd_paste = st.text_area("Paste the job description here", height=280,
placeholder="e.g.\n\nWe are looking for a Machine Learning Engineer...\n\nRequirements:\n- Python, PyTorch\n- NLP, BERT, Transformers\n- Docker and AWS")
if jd_paste and jd_paste.strip():
jd_text_raw = jd_paste
else:
jd_file = st.file_uploader("Upload JD (PDF, DOCX, or TXT)", type=["pdf","docx","txt"], key="jd_file")
if jd_file:
try:
jd_text_raw = parse_job_description(jd_file.read(), filename=jd_file.name)
st.success(f"Loaded JD: {jd_file.name} ({len(jd_text_raw):,} chars)")
except Exception as e:
st.error(f"Failed to parse JD: {e}")
with col_right:
st.markdown("๐ Candidate Resumes
", unsafe_allow_html=True)
st.caption("Upload one or more resumes (PDF, DOCX, or TXT)")
resume_files = st.file_uploader("Upload resumes", type=["pdf","docx","txt"],
accept_multiple_files=True, label_visibility="collapsed")
parsed_resumes: List[dict] = []
if resume_files:
for rf in resume_files:
try:
text = parse_resume(rf.read(), filename=rf.name)
parsed_resumes.append({"name": Path(rf.name).stem, "raw_text": text})
st.success(f"โ
{rf.name} ({len(text):,} chars)")
except Exception as e:
st.error(f"โ {rf.name}: {e}")
st.markdown("", unsafe_allow_html=True)
vs_cnt = vector_store.count()
if vs_cnt > 0 and not parsed_resumes:
st.info(f"๐ก **Vector Index active** โ {vs_cnt} resume(s) indexed. Use ๐๏ธ Vector Index tab for instant search.")
can_analyze = bool(jd_text_raw) and bool(parsed_resumes)
analyze_btn = st.button("๐ Analyze Candidates", type="primary",
disabled=not can_analyze, use_container_width=True)
if not can_analyze:
if not jd_text_raw: st.info("๐ Please provide a job description.")
elif not parsed_resumes: st.info("๐ Please upload at least one resume.")
if analyze_btn and can_analyze:
with st.status("โ๏ธ Running SmartHire AI Pipeline...", expanded=True) as status:
st.write("๐ Preprocessing text...")
try:
jd_clean = preprocess_text(jd_text_raw)
except Exception as e:
st.error(f"JD preprocessing failed: {e}"); st.stop()
clean_resumes = []
for r in parsed_resumes:
try:
clean_text = preprocess_text(r["raw_text"])
clean_resumes.append({**r, "clean_text": clean_text})
except Exception as e:
st.warning(f"Skipping {r['name']}: {e}")
if not clean_resumes:
st.error("No valid resumes after preprocessing."); st.stop()
st.write(f"๐ค Encoding {len(clean_resumes)} resume(s)...")
t0 = time.time()
resume_embeddings = model.encode([r["clean_text"] for r in clean_resumes])
jd_embedding = model.encode_single(jd_clean)
encode_time = time.time() - t0
st.write("๐ Computing cosine similarities...")
scores = batch_similarity(resume_embeddings, jd_embedding)
for r, score in zip(clean_resumes, scores):
r["score"] = score
st.write("๐ Ranking candidates...")
results = rank_candidates(
[{"name": r["name"], "text": r["clean_text"], "score": r["score"]} for r in clean_resumes],
jd_clean, similarity_weight=similarity_weight, skill_weight=skill_weight,
)
summary = summarize_rankings(results)
status.update(label=f"โ
{len(results)} candidate(s) ranked in {encode_time:.1f}s", state="complete")
st.session_state.update({"results": results, "summary": summary,
"jd_clean": jd_clean, "encode_time": encode_time})
st.success(f"๐ Done! Analyzed **{len(results)}** candidate(s) in **{encode_time:.2f}s**. "
f"Switch to **Match Results** tab.", icon="โ
")
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# TAB 2 โ Match Results
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with tab_results:
if "results" not in st.session_state:
st.info("๐ Upload resumes and a JD in the **Upload & Analyze** tab first."); st.stop()
results: List[CandidateResult] = st.session_state["results"]
summary: dict = st.session_state["summary"]
encode_time: float = st.session_state.get("encode_time", 0)
st.markdown("๐ Pipeline Summary
", unsafe_allow_html=True)
c1, c2, c3, c4, c5 = st.columns(5)
for col, val, label in [
(c1, summary["total_candidates"], "Candidates"),
(c2, f"{summary['average_score']:.1f}%", "Avg Score"),
(c3, f"{summary['highest_score']:.1f}%", "Top Score"),
(c4, summary["highly_recommended"]+summary["recommended"], "Recommended"),
(c5, f"{encode_time:.1f}s", "Encode Time"),
]:
with col:
st.markdown(f"", unsafe_allow_html=True)
st.markdown("", unsafe_allow_html=True)
color_map = {"Highly Recommended":"#4ade80","Recommended":"#00d4ff",
"Consider":"#fbbf24","Not Recommended":"#f87171"}
st.markdown("๐ Match Score Distribution
", unsafe_allow_html=True)
fig_bar = px.bar(
pd.DataFrame({"Candidate":[r.name for r in results],
"Match Score (%)":[r.score_pct for r in results],
"Recommendation":[r.recommendation for r in results]}),
x="Candidate", y="Match Score (%)", color="Recommendation",
color_discrete_map=color_map, text="Match Score (%)", height=380,
)
fig_bar.update_traces(texttemplate="%{text:.1f}%", textposition="outside")
fig_bar.update_layout(plot_bgcolor="#0a0e1a", paper_bgcolor="#0a0e1a", font_color="#e2e8f0",
yaxis_range=[0,115], margin=dict(t=50,b=20),
xaxis=dict(gridcolor="#1e2a45"), yaxis=dict(gridcolor="#1e2a45"))
st.plotly_chart(fig_bar, use_container_width=True)
if len(results) > 1:
col_pie, col_scatter = st.columns(2)
with col_pie:
st.markdown("๐ฅง Recommendation Split
", unsafe_allow_html=True)
pie_data = {k: v for k, v in {
"Highly Recommended": summary["highly_recommended"],
"Recommended": summary["recommended"],
"Consider": summary["consider"],
"Not Recommended": summary["not_recommended"],
}.items() if v > 0}
fig_pie = px.pie(values=list(pie_data.values()), names=list(pie_data.keys()),
color=list(pie_data.keys()), color_discrete_map=color_map, height=320)
fig_pie.update_layout(paper_bgcolor="#0a0e1a", font_color="#e2e8f0", margin=dict(t=20,b=20))
st.plotly_chart(fig_pie, use_container_width=True)
with col_scatter:
st.markdown("๐ Similarity vs Skill Coverage
", unsafe_allow_html=True)
fig_sc = px.scatter(
pd.DataFrame({"Candidate":[r.name for r in results],
"Similarity (%)":[round(r.similarity_score*100,2) for r in results],
"Skill Coverage (%)":[r.skill_coverage_pct for r in results],
"Match Score (%)":[r.score_pct for r in results],
"Recommendation":[r.recommendation for r in results]}),
x="Similarity (%)", y="Skill Coverage (%)", size="Match Score (%)",
color="Recommendation", color_discrete_map=color_map,
hover_data=["Candidate","Match Score (%)"], height=320,
)
fig_sc.update_layout(plot_bgcolor="#0a0e1a", paper_bgcolor="#0a0e1a", font_color="#e2e8f0",
margin=dict(t=20,b=20), xaxis=dict(gridcolor="#1e2a45"),
yaxis=dict(gridcolor="#1e2a45"))
st.plotly_chart(fig_sc, use_container_width=True)
st.markdown("๐ฏ Per-Candidate Results
", unsafe_allow_html=True)
for rank, result in enumerate(results, start=1):
color = score_color(result.score_pct)
with st.expander(f"#{rank} {result.name} โ {result.score_pct:.1f}% | {result.recommendation}",
expanded=(rank == 1)):
r1, r2 = st.columns(2)
with r1:
for lbl, val, c in [("Match Score", result.score_pct, color),
("Skill Coverage", result.skill_coverage_pct, "#7c3aed"),
("Semantic Similarity", round(result.similarity_score*100,1), "#00d4ff")]:
st.markdown(f"**{lbl}**")
st.markdown(render_progress_bar(val, c), unsafe_allow_html=True)
with r2:
st.markdown("**Recommendation**")
st.markdown(badge_html(result.recommendation), unsafe_allow_html=True)
st.markdown(f"""
| Metric | Value |
|--------|-------|
| Match Score | **{result.score_pct:.1f}%** |
| Semantic Similarity | {result.similarity_score*100:.1f}% |
| Skill Coverage | {result.skill_coverage_pct:.1f}% |
| Matched Skills | {len(result.matching_skills)} |
| Missing Skills | {len(result.missing_skills)} |
| Critical Missing | {len(result.critical_missing)} |""")
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# TAB 3 โ Skill Gap Analysis
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with tab_skills:
if "results" not in st.session_state:
st.info("๐ Run an analysis first from the **Upload & Analyze** tab."); st.stop()
results: List[CandidateResult] = st.session_state["results"]
st.markdown("๐ Skill Gap Analysis
", unsafe_allow_html=True)
selected_name = st.selectbox("Select Candidate", [r.name for r in results])
selected = next(r for r in results if r.name == selected_name)
st.markdown("", unsafe_allow_html=True)
s1, s2, s3 = st.columns(3)
with s1: st.metric("Matched Skills", len(selected.matching_skills))
with s2: st.metric("Missing Skills", len(selected.missing_skills))
with s3: st.metric("Critical Missing", len(selected.critical_missing))
st.markdown("", unsafe_allow_html=True)
col_m, col_mi = st.columns(2)
with col_m:
st.markdown("#### โ
Matching Skills")
st.markdown(f"{render_skill_chips(selected.matching_skills,'skill-chip-match')}
",
unsafe_allow_html=True)
st.markdown("#### ๐ผ Additional Resume Skills")
st.markdown(f"{render_skill_chips(selected.resume_only_skills[:20],'skill-chip-match')}
",
unsafe_allow_html=True)
with col_mi:
st.markdown("#### โ Missing Skills")
st.markdown(f"{render_skill_chips(selected.missing_skills,'skill-chip-missing')}
",
unsafe_allow_html=True)
st.markdown("#### โ ๏ธ Critical Missing")
st.markdown(f"{render_skill_chips(selected.critical_missing,'skill-chip-critical')}
",
unsafe_allow_html=True)
st.markdown("", unsafe_allow_html=True)
st.markdown("๐ Skill Coverage Breakdown
", unsafe_allow_html=True)
all_skills = list(set(selected.matching_skills + selected.missing_skills))
if all_skills:
skill_df = pd.DataFrame({
"Skill" : all_skills,
"Status" : ["Matched" if s in selected.matching_skills else "Missing" for s in all_skills],
})
skill_df["Present"] = skill_df["Status"].apply(lambda x: 1 if x=="Matched" else 0)
fig_sk = px.bar(skill_df.sort_values("Status").head(30), x="Skill", y="Present", color="Status",
color_discrete_map={"Matched":"#4ade80","Missing":"#f87171"},
title=f"Skill Matrix โ {selected_name}", height=360)
fig_sk.update_layout(yaxis=dict(tickvals=[0,1],ticktext=["Missing","Matched"],gridcolor="#1e2a45"),
plot_bgcolor="#0a0e1a", paper_bgcolor="#0a0e1a", font_color="#e2e8f0",
margin=dict(t=40,b=40), xaxis=dict(gridcolor="#1e2a45"))
st.plotly_chart(fig_sk, use_container_width=True)
if len(results) > 1:
st.markdown("๐ Cross-Candidate Comparison
", unsafe_allow_html=True)
fig_comp = px.bar(
pd.DataFrame({"Candidate":[r.name for r in results],
"Matched Skills":[len(r.matching_skills) for r in results],
"Missing Skills":[len(r.missing_skills) for r in results],
"Critical Missing":[len(r.critical_missing) for r in results]}),
x="Candidate", y=["Matched Skills","Missing Skills","Critical Missing"],
barmode="group", color_discrete_sequence=["#4ade80","#f87171","#fbbf24"], height=360,
)
fig_comp.update_layout(plot_bgcolor="#0a0e1a", paper_bgcolor="#0a0e1a", font_color="#e2e8f0",
margin=dict(t=20,b=20), xaxis=dict(gridcolor="#1e2a45"),
yaxis=dict(gridcolor="#1e2a45"))
st.plotly_chart(fig_comp, use_container_width=True)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# TAB 4 โ Candidate Ranking
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with tab_ranking:
if "results" not in st.session_state:
st.info("๐ Run an analysis first from the **Upload & Analyze** tab."); st.stop()
results: List[CandidateResult] = st.session_state["results"]
summary: dict = st.session_state["summary"]
st.markdown("๐ Candidate Ranking Leaderboard
", unsafe_allow_html=True)
df = results_to_dataframe(results)
st.dataframe(df[["Candidate","Match Score (%)","Recommendation","Skill Coverage (%)"]],
use_container_width=True, height=min(60+len(results)*42, 420))
st.markdown("", unsafe_allow_html=True)
top = results[0]
st.markdown("๐ฅ Top Candidate Spotlight
", unsafe_allow_html=True)
col_s1, col_s2 = st.columns([1, 2])
with col_s1:
color = score_color(top.score_pct)
fig_gauge = go.Figure(go.Indicator(
mode="gauge+number", value=top.score_pct,
title={"text": top.name, "font": {"size":16,"color":"#e2e8f0"}},
gauge={"axis":{"range":[0,100],"tickcolor":"#64748b"},"bar":{"color":color},
"bgcolor":"#0f1629","bordercolor":"#1e2a45",
"steps":[{"range":[0,70],"color":"#1a0a0a"},{"range":[70,80],"color":"#1a1200"},
{"range":[80,90],"color":"#0a1628"},{"range":[90,100],"color":"#052e16"}],
"threshold":{"line":{"color":color,"width":4},"thickness":0.8,"value":top.score_pct}},
number={"suffix":"%","font":{"size":36,"color":color}},
))
fig_gauge.update_layout(height=280, margin=dict(t=30,b=0),
paper_bgcolor="#0a0e1a", font_color="#e2e8f0")
st.plotly_chart(fig_gauge, use_container_width=True)
with col_s2:
st.markdown(f"**{top.name}** โ match score **{top.score_pct:.1f}%**")
st.markdown(badge_html(top.recommendation), unsafe_allow_html=True)
st.markdown("**Key Strengths:**")
st.markdown(render_skill_chips(top.matching_skills[:12],"skill-chip-match"), unsafe_allow_html=True)
if top.critical_missing:
st.markdown("**โ ๏ธ Areas to Address:**")
st.markdown(render_skill_chips(top.critical_missing,"skill-chip-critical"), unsafe_allow_html=True)
st.markdown("", unsafe_allow_html=True)
st.markdown("๐ All Candidates โ Score Breakdown
", unsafe_allow_html=True)
for rank, result in enumerate(results, start=1):
cr, cn, cb, cbadge = st.columns([0.5, 2, 4, 2])
with cr: st.markdown(f"**#{rank}**")
with cn: st.markdown(f"**{result.name}**")
with cb:
c = score_color(result.score_pct)
st.markdown(render_progress_bar(result.score_pct, c, height=26), unsafe_allow_html=True)
with cbadge: st.markdown(badge_html(result.recommendation), unsafe_allow_html=True)
st.markdown("", unsafe_allow_html=True)
st.markdown("๐พ Export Results
", unsafe_allow_html=True)
csv_bytes = results_to_dataframe(results).to_csv(index=True).encode("utf-8")
st.download_button("โฌ๏ธ Download Results as CSV", data=csv_bytes,
file_name="smarthire_ai_results.csv", mime="text/csv", use_container_width=True)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# TAB 5 โ Vector Index
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with tab_vector:
st.markdown("๐๏ธ Resume Vector Index
", unsafe_allow_html=True)
st.caption("Pre-encode and persistently store resume embeddings for instant JD search (sub-100ms).")
vs_info = vector_store.get_info()
try:
current_model_dim = model.get_model_info()["embedding_dim"]
except Exception:
current_model_dim = None
stored_dim = vs_info.get("dim", "N/A")
# Dimension mismatch banner
if vs_info["count"] > 0 and current_model_dim and stored_dim != "N/A":
if int(stored_dim) != int(current_model_dim):
st.error(
f"โ ๏ธ **Dimension Mismatch!** Stored vectors are **{stored_dim}-dim** "
f"but current model outputs **{current_model_dim}-dim**. \n"
f"**Fix:** Go to โ๏ธ Manage Index โ Clear โ Rebuild.", icon="๐จ"
)
# Status cards
vi_c1, vi_c2, vi_c3, vi_c4 = st.columns(4)
with vi_c1:
st.markdown(f""
f"{vs_info['count']}
Indexed Resumes
",
unsafe_allow_html=True)
with vi_c2:
st.markdown(f""
f"{vs_info['backend'].upper()}
Backend
",
unsafe_allow_html=True)
with vi_c3:
sl, sc = ("Ready","#4ade80") if vs_info["count"] > 0 else ("Empty","#f87171")
st.markdown(f"", unsafe_allow_html=True)
with vi_c4:
dd = stored_dim if stored_dim != "N/A" else (current_model_dim or "N/A")
st.markdown(f"", unsafe_allow_html=True)
st.markdown("", unsafe_allow_html=True)
# โโ Section A: Build Index โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
st.markdown("โก Build / Update Index
", unsafe_allow_html=True)
index_files = st.file_uploader("Upload resumes to index (PDF, DOCX, TXT)",
type=["pdf","docx","txt"], accept_multiple_files=True, key="index_upload")
col_b1, col_b2 = st.columns([2, 1])
with col_b1:
rebuild_mode = st.radio("Index mode",
["Add to existing index", "Rebuild from scratch (clear first)"],
horizontal=True)
with col_b2:
build_btn = st.button("โก Build Index", type="primary",
disabled=not bool(index_files), use_container_width=True)
if not index_files:
st.info("๐ Upload at least one resume to build or update the index.")
if build_btn and index_files:
to_index, parse_errors = [], []
for rf in index_files:
try:
raw = parse_resume(rf.read(), filename=rf.name)
clean = preprocess_text(raw)
to_index.append({"name": Path(rf.name).stem, "text": clean})
except Exception as e:
parse_errors.append(f"{rf.name}: {e}")
for err in parse_errors:
st.warning(f"โ ๏ธ Skipped โ {err}")
if not to_index:
st.error("No valid resumes could be parsed.")
else:
if "Rebuild" in rebuild_mode:
vector_store.clear()
st.info("๐๏ธ Existing index cleared.")
progress_bar = st.progress(0, text="Starting...")
status_txt = st.empty()
def update_progress(i, total, name):
progress_bar.progress(int((i+1)/total*100), text=f"Encoding {i+1}/{total}: {name}")
status_txt.markdown(f"๐ค Encoding **{name}**...")
with st.spinner("Building vector index..."):
t0 = time.time()
stats = vector_store.build_index(resumes=to_index, model=model,
progress_callback=update_progress)
dur = time.time() - t0
progress_bar.progress(100, text="Done!")
status_txt.empty()
st.success(f"โ
**{stats['indexed']}** resume(s) indexed in **{dur:.1f}s** | "
f"Total: **{stats['total']}** | Backend: **{stats['backend'].upper()}**", icon="๐๏ธ")
if stats["skipped"]:
st.warning(f"โ ๏ธ {stats['skipped']} resume(s) skipped.")
st.rerun()
# โโ Section B: Indexed Candidates โโโโโโโโโโโโโโโโโโโโโโโโ
st.markdown("", unsafe_allow_html=True)
st.markdown("๐ Indexed Candidates
", unsafe_allow_html=True)
all_meta = safe_get_all_metadata(vector_store)
if all_meta:
idx_df = pd.DataFrame([
{
"#" : i + 1,
"Candidate" : m.get("name", "Unknown"),
"Text Length": f"{m.get('text_length', 0):,} chars" if m.get("text_length") else "N/A",
"Vector Dim" : m.get("embedding_dim", "N/A"),
"Indexed At" : m.get("indexed_at", "N/A"),
"Status" : "โ
Indexed",
}
for i, m in enumerate(all_meta)
])
st.dataframe(idx_df, use_container_width=True, height=min(60+len(all_meta)*38, 400))
else:
st.markdown("No resumes indexed yet.
",
unsafe_allow_html=True)
# โโ Section C: Instant JD Search โโโโโโโโโโโโโโโโโโโโโโโโโ
st.markdown("", unsafe_allow_html=True)
st.markdown("๐ Instant JD Search
", unsafe_allow_html=True)
st.caption("Search your indexed resume pool against any JD โ results in under 100ms.")
search_jd_text = st.text_area(
"Paste Job Description to search",
height=180,
placeholder="e.g.\n\nWe are looking for a Senior Data Scientist with Python, ML, and AWS experience...",
key="vs_jd_search",
)
max_k = max(1, vs_info["count"])
top_k = st.slider("Top K results", min_value=1, max_value=min(20, max_k), value=min(5, max_k))
search_btn = st.button("๐ Search Vector Index", type="primary",
disabled=(not bool(search_jd_text and search_jd_text.strip())
or vector_store.is_empty()),
use_container_width=True)
if vector_store.is_empty():
st.info("๐ Build the index first.")
if search_btn and search_jd_text and search_jd_text.strip():
try:
with st.spinner("๐ Searching..."):
t_s = time.time()
jd_emb_vs = model.encode_single(preprocess_text(search_jd_text))
vs_results = vector_store.search(jd_emb_vs, top_k=top_k)
search_ms = (time.time() - t_s) * 1000
st.success(f"โก Found **{len(vs_results)}** result(s) in **{search_ms:.1f}ms**", icon="๐")
st.markdown("๐ Top Matching Candidates
", unsafe_allow_html=True)
if vs_results:
fig_vs = px.bar(
pd.DataFrame({"Candidate":[r["name"] for r in vs_results],
"Similarity (%)":[round(r["score"]*100,2) for r in vs_results]}),
x="Candidate", y="Similarity (%)", text="Similarity (%)", height=320,
color="Similarity (%)",
color_continuous_scale=["#f87171","#fbbf24","#4ade80"], range_color=[0,100],
)
fig_vs.update_traces(texttemplate="%{text:.1f}%", textposition="outside")
fig_vs.update_layout(plot_bgcolor="#0a0e1a", paper_bgcolor="#0a0e1a",
font_color="#e2e8f0", yaxis_range=[0,115],
margin=dict(t=20,b=20), coloraxis_showscale=False,
xaxis=dict(gridcolor="#1e2a45"), yaxis=dict(gridcolor="#1e2a45"))
st.plotly_chart(fig_vs, use_container_width=True)
for i, res in enumerate(vs_results, start=1):
sim_pct = round(res["score"]*100, 2)
with st.expander(f"#{i} {res['name']} โ Similarity: {sim_pct:.1f}%", expanded=(i==1)):
st.markdown("**Semantic Similarity**")
st.markdown(render_progress_bar(sim_pct, score_color(sim_pct)), unsafe_allow_html=True)
meta = res.get("metadata", {})
tl = meta.get("text_length", 0)
st.markdown(
f"๐
Indexed at: {meta.get('indexed_at','N/A')}
"
f"๐ Text length: {f'{tl:,} chars' if tl else 'N/A'}
"
f"๐ Vector dim: {meta.get('embedding_dim','N/A')}
",
unsafe_allow_html=True,
)
preview = res.get("text") or meta.get("text_preview","")
if preview:
st.caption(f"Preview: {preview[:300]}...")
except RuntimeError as e:
st.error(f"๐จ Search failed: {e}")
# โโ Section D: Manage Index โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
st.markdown("", unsafe_allow_html=True)
st.markdown("โ๏ธ Manage Index
", unsafe_allow_html=True)
col_m1, col_m2 = st.columns(2)
with col_m1:
st.markdown(
f"""
๐๏ธ Index Details
๐ฆ Total indexed: {vs_info['count']}
๐ง Backend: {vs_info['backend'].upper()}
๐ Stored vector dim: {stored_dim}
๐ค Current model dim: {current_model_dim or 'N/A'}
๐พ Store path: vector_db/
๐ Persistent: Yes (survives restarts)
""",
unsafe_allow_html=True,
)
with col_m2:
st.markdown("**โ ๏ธ Danger Zone**")
st.caption("Clear index if you changed models or want to start fresh.")
clear_confirm = st.checkbox("I confirm I want to clear all indexed vectors")
if st.button("๐๏ธ Clear Entire Index", disabled=not clear_confirm, use_container_width=True):
vector_store.clear()
st.success("โ
Index cleared.")
st.rerun()
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Footer
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
st.markdown("", unsafe_allow_html=True)
st.markdown(
"""
SmartHire AI ยท
all-MiniLM-L6-v2 + PyTorch + Hugging Face + ChromaDB + Streamlit ยท
Transformer-Based Semantic Resume Screening
""",
unsafe_allow_html=True,
)