""" 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"""
{pct:.1f}%
""" 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"
{val}
" f"
{label}
", 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"
" f"{sl}
Status
", 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"
" f"{dd}
Vector Dim
", 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, )