"""AI Candidate Pipeline Dashboard""" from __future__ import annotations import json import os import queue import threading import time import traceback import uuid from pathlib import Path from typing import Any, List, Optional import pandas as pd import streamlit as st from pipeline import DASHBOARD_REQUIRED_COLUMNS, PipelineArtifacts, run_full_pipeline st.set_page_config(page_title="AI Candidate Pipeline Dashboard", layout="wide") OUTPUTS_ROOT = Path("outputs") OUTPUTS_ROOT.mkdir(parents=True, exist_ok=True) # --------------------------------------------------------------------------- # Phase metadata — used for progress rendering # --------------------------------------------------------------------------- PIPELINE_PHASES = [ ("phase1", "Phase 1", "Fetch Profiles"), ("phase2", "Phase 2", "Normalize Profiles"), ("phase3", "Phase 3", "Build Dossiers"), ("phase3_filtered", "Phase 3B", "Relevance Filter"), ("phase4", "Phase 4", "LLM Scoring"), ("phase5", "Phase 5", "Ranking & Export"), ] PHASE_KEY_TOKENS: dict[str, list[str]] = { "phase1": ["phase 1", "apify", "fetch profiles", "dataset"], "phase2": ["phase 2", "normalize"], "phase3": ["phase 3", "dossier"], "phase3_filtered": ["phase 3b", "relevance"], "phase4": ["phase 4", "llm scoring", "scoring"], "phase5": ["phase 5", "ranking", "export", "campaign"], } PHASE_CSV_KEY_MAP = { "phase1": "phase1_csv_path", "phase2": "phase2_csv_path", "phase3": "phase3_csv_path", "phase3_filtered": "phase3_filtered_csv_path", "phase4": "phase4_csv_path", "phase5": "final_csv_path", } # --------------------------------------------------------------------------- # Session state bootstrap # --------------------------------------------------------------------------- def init_session_state() -> None: defaults: dict[str, Any] = { "page": "Run Pipeline", # pipeline run tracking "pipeline_running": False, "log_queue": None, "pipeline_error": None, "run_id": None, "run_completed": False, # progress "current_phase_key": None, "phase_counts": {}, "run_logs": [], # artifacts & DataFrames "artifacts": None, "final_df": None, "intermediate_df": None, # CSV paths per phase "phase1_csv_path": None, "phase2_csv_path": None, "phase3_csv_path": None, "phase3_filtered_csv_path": None, "phase4_csv_path": None, "final_csv_path": None, "intermediate_csv_path": None, # view-results state "shortlist_keys": set(), } for key, value in defaults.items(): if key not in st.session_state: st.session_state[key] = value init_session_state() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def reset_run_state() -> None: st.session_state.pipeline_running = False st.session_state.log_queue = None st.session_state.pipeline_error = None st.session_state.run_completed = False st.session_state.current_phase_key = None st.session_state.phase_counts = {} st.session_state.run_logs = [] st.session_state.artifacts = None st.session_state.final_df = None st.session_state.intermediate_df = None st.session_state.phase1_csv_path = None st.session_state.phase2_csv_path = None st.session_state.phase3_csv_path = None st.session_state.phase3_filtered_csv_path = None st.session_state.phase4_csv_path = None st.session_state.final_csv_path = None st.session_state.intermediate_csv_path = None st.session_state.shortlist_keys = set() def append_log(message: str) -> None: st.session_state.run_logs.append(str(message)) def infer_phase_key(message: str) -> Optional[str]: msg = str(message).lower() for phase_key, tokens in PHASE_KEY_TOKENS.items(): if any(tok in msg for tok in tokens): return phase_key return None def safe_read_csv(path: str | os.PathLike[str] | None) -> pd.DataFrame: if not path: return pd.DataFrame() p = Path(path) if not p.exists(): return pd.DataFrame() try: return pd.read_csv(p) except Exception: return pd.DataFrame() def ensure_required_columns(df: pd.DataFrame) -> List[str]: return [c for c in DASHBOARD_REQUIRED_COLUMNS if c not in df.columns] def store_artifacts(artifacts: PipelineArtifacts) -> None: st.session_state.artifacts = artifacts st.session_state.final_df = artifacts.phase5_df.copy() st.session_state.intermediate_df = safe_read_csv(artifacts.intermediate_csv_path) st.session_state.final_csv_path = artifacts.final_csv_path st.session_state.intermediate_csv_path = artifacts.intermediate_csv_path st.session_state.run_completed = True for attr, state_key in [ ("phase1_csv_path", "phase1_csv_path"), ("phase2_csv_path", "phase2_csv_path"), ("phase3_csv_path", "phase3_csv_path"), ("phase3_filtered_csv_path", "phase3_filtered_csv_path"), ("phase4_csv_path", "phase4_csv_path"), ]: val = getattr(artifacts, attr, None) if val: st.session_state[state_key] = val # --------------------------------------------------------------------------- # Queue drainage — called on every Streamlit rerun while pipeline is running # --------------------------------------------------------------------------- def drain_pipeline_queue() -> None: """Pull all pending messages from the thread queue and update session state. The background thread NEVER touches session_state directly — it only puts messages into this queue. This function is only ever called from the main Streamlit thread, so there are no race conditions on session_state. """ q: Optional[queue.Queue] = st.session_state.log_queue if q is None: return while True: try: item = q.get_nowait() except queue.Empty: break kind = item[0] if kind == "log": msg = item[1] append_log(msg) detected = infer_phase_key(msg) if detected: st.session_state.current_phase_key = detected elif kind == "phase_complete": _, phase_name, df, csv_path, row_count = item state_key = PHASE_CSV_KEY_MAP.get(phase_name) if state_key: st.session_state[state_key] = csv_path st.session_state.phase_counts[phase_name] = row_count if phase_name == "phase3_filtered": st.session_state.intermediate_csv_path = csv_path st.session_state.intermediate_df = df.copy() if phase_name == "phase5": st.session_state.final_df = df.copy() elif kind == "done": artifacts = item[1] store_artifacts(artifacts) st.session_state.pipeline_running = False st.session_state.current_phase_key = None append_log("✅ Pipeline complete.") elif kind == "error": _, err_msg, tb = item append_log(f"❌ ERROR: {err_msg}") append_log(tb) st.session_state.pipeline_error = err_msg st.session_state.pipeline_running = False st.session_state.current_phase_key = None # --------------------------------------------------------------------------- # Background thread target # --------------------------------------------------------------------------- def _pipeline_thread_target( keywords: List[str], locations: List[str], limit: int, output_dir: Path, log_q: queue.Queue, ) -> None: def logger(msg: str) -> None: log_q.put(("log", msg)) def on_phase_complete(phase_name: str, df: pd.DataFrame, csv_path: str) -> None: log_q.put(("phase_complete", phase_name, df.copy(), csv_path, len(df))) try: artifacts = run_full_pipeline( keywords=keywords, location=locations, limit=limit, output_dir=output_dir, logger=logger, on_phase_complete=on_phase_complete, ) log_q.put(("done", artifacts)) except Exception as e: log_q.put(("error", str(e), traceback.format_exc())) def start_pipeline_thread( keywords: List[str], locations: List[str], limit: int, output_dir: Path, ) -> None: log_q: queue.Queue = queue.Queue() st.session_state.log_queue = log_q thread = threading.Thread( target=_pipeline_thread_target, args=(keywords, locations, limit, output_dir, log_q), daemon=True, ) thread.start() st.session_state.pipeline_running = True # --------------------------------------------------------------------------- # Progress renderer # --------------------------------------------------------------------------- def render_phase_progress() -> None: done_phases: set = set(st.session_state.phase_counts.keys()) current_key: Optional[str] = st.session_state.current_phase_key is_running: bool = st.session_state.pipeline_running st.markdown("### Pipeline Progress") cols = st.columns(len(PIPELINE_PHASES)) for col, (phase_key, phase_num, phase_label) in zip(cols, PIPELINE_PHASES): row_count = st.session_state.phase_counts.get(phase_key) is_done = phase_key in done_phases is_active = (not is_done) and is_running and (current_key == phase_key) with col: if is_done: st.markdown( f"
" f"✅ {phase_num}
" f"{phase_label}
" f"{row_count} rows" f"
", unsafe_allow_html=True, ) elif is_active: st.markdown( f"
" f"🔄 {phase_num}
" f"{phase_label}
" f"running…" f"
", unsafe_allow_html=True, ) else: st.markdown( f"
" f"⏳ {phase_num}
" f"{phase_label}
" f"waiting" f"
", unsafe_allow_html=True, ) def render_live_logs(expanded: bool = True) -> None: logs = st.session_state.run_logs with st.expander(f"Live logs ({len(logs)} entries)", expanded=expanded): if logs: # Show last 50 lines to keep it readable st.code("\n".join(logs[-50:]), language="text") else: st.info("Waiting for logs…") def render_phase_downloads() -> None: phase_map = [ ("phase1_csv_path", "Phase 1 CSV"), ("phase2_csv_path", "Phase 2 CSV"), ("phase3_csv_path", "Phase 3 CSV"), ("phase3_filtered_csv_path", "Relevance CSV"), ("phase4_csv_path", "Phase 4 CSV"), ("intermediate_csv_path", "Intermediate CSV"), ("final_csv_path", "Final CSV"), ] available = [ (k, label) for k, label in phase_map if st.session_state.get(k) and Path(st.session_state[k]).exists() ] if not available: st.info("Downloads appear here as each phase completes.") return st.markdown("### Downloads") cols = st.columns(min(len(available), 3)) for i, (state_key, label) in enumerate(available): path = Path(st.session_state[state_key]) cols[i % 3].download_button( label=f"⬇ {label}", data=path.read_bytes(), file_name=path.name, mime="text/csv", use_container_width=True, key=f"dl_{state_key}", ) def render_run_summary() -> None: artifacts = st.session_state.artifacts if artifacts is None: return st.markdown("### Run Summary") c1, c2, c3, c4 = st.columns(4) c1.metric("Fetched profiles", len(artifacts.phase1_df)) c2.metric("Relevant profiles", len(artifacts.phase3_filtered_df)) c3.metric("Final candidates", len(artifacts.phase5_df)) c4.metric("Run ID", st.session_state.run_id or "n/a") render_phase_downloads() with st.expander("Preview final scored candidates", expanded=True): df = st.session_state.final_df if isinstance(df, pd.DataFrame) and not df.empty: st.dataframe(df.head(50), use_container_width=True) else: st.info("No final output yet.") with st.expander("Preview intermediate dataset", expanded=False): idf = st.session_state.intermediate_df if isinstance(idf, pd.DataFrame) and not idf.empty: st.dataframe(idf.head(50), use_container_width=True) else: st.info("No intermediate output yet.") render_live_logs(expanded=False) # --------------------------------------------------------------------------- # Sidebar / navigation # --------------------------------------------------------------------------- st.sidebar.title("Navigation") page = st.sidebar.radio("Go to", ["Run Pipeline", "View Results"], key="page") st.sidebar.markdown("---") st.sidebar.caption( "Set APIFY_API_TOKEN and OPENAI_API_KEY in your environment or HF Space secrets." ) # --------------------------------------------------------------------------- # Page: Run Pipeline # --------------------------------------------------------------------------- def page_run_pipeline() -> None: st.title("AI Candidate Pipeline") st.write( "Run the LinkedIn scrape → normalization → relevance filter → LLM scoring → export pipeline." ) with st.expander("How this page works", expanded=False): st.markdown( """ - Enter keywords and location, then click **Run pipeline**. - The phase tracker and logs update automatically every second. - Download per-phase CSVs as soon as they're ready — no need to wait for the full run. - If the pipeline errors mid-run, the logs panel will show exactly where it failed. """.strip() ) # ------------------------------------------------------------------ # Input form — only shown when no run is active # ------------------------------------------------------------------ if not st.session_state.pipeline_running: with st.form("pipeline_form"): query_text = st.text_area( "Search query keywords", value="AI speaker, GenAI workshop, LLM trainer", help="Comma-separated keywords passed into the Apify actor.", ) location_text = st.text_input( "Location filter", value="India", help="Optional. Comma-separated list for multiple locations.", ) limit = st.slider("Profile limit", min_value=5, max_value=50, value=20, step=5) submitted = st.form_submit_button("🚀 Run pipeline", use_container_width=True) if submitted: keywords = [x.strip() for x in query_text.split(",") if x.strip()] locations = [x.strip() for x in location_text.split(",") if x.strip()] if not keywords: st.error("Please provide at least one keyword.") st.stop() if not os.getenv("APIFY_API_TOKEN"): st.error("APIFY_API_TOKEN is missing.") st.stop() if not os.getenv("OPENAI_API_KEY"): st.error("OPENAI_API_KEY is missing.") st.stop() reset_run_state() st.session_state.run_id = uuid.uuid4().hex[:10] output_dir = OUTPUTS_ROOT / f"run_{st.session_state.run_id}" start_pipeline_thread(keywords, locations, limit, output_dir) st.rerun() # jump immediately into the running state below # ------------------------------------------------------------------ # Running state — drain queue, render live progress, then rerun # ------------------------------------------------------------------ if st.session_state.pipeline_running: # Always drain first so state reflects latest messages drain_pipeline_queue() st.info("⏳ Pipeline is running — refreshing every second.") render_phase_progress() st.markdown("---") render_live_logs(expanded=True) render_phase_downloads() # Poll: sleep then rerun so the next script execution picks up new messages. # This is the correct Streamlit pattern — st.rerun() raises RerunException # which terminates the current script run immediately after this line. time.sleep(1.0) st.rerun() return # unreachable; here for clarity # ------------------------------------------------------------------ # Completed / idle / error state # ------------------------------------------------------------------ if st.session_state.pipeline_error: st.error(f"Pipeline failed: {st.session_state.pipeline_error}") if st.session_state.run_completed: st.success("✅ Pipeline completed successfully.") render_phase_progress() st.markdown("---") render_run_summary() elif st.session_state.run_logs: # Partial state from a previous failed run render_phase_progress() render_live_logs(expanded=True) render_phase_downloads() # --------------------------------------------------------------------------- # Helper renderers for View Results # --------------------------------------------------------------------------- def render_evidence(evidence_str: Any) -> List[str]: if pd.isna(evidence_str) or not str(evidence_str).strip(): return [] s = str(evidence_str).strip() try: ev = json.loads(s) if isinstance(ev, list): return [str(x) for x in ev if str(x).strip()] except Exception: pass return [x.strip() for x in s.split("\n") if x.strip()] def esc(x: Any) -> str: return str(x).replace("&", "&").replace("<", "<").replace(">", ">") def bullets(items: List[str]) -> str: if not items: return "

No evidence provided.

" lis = "".join(f"
  • {esc(item)}
  • " for item in items) return f"" def candidate_key(row: pd.Series) -> str: return f"{row.get('full_name', '')}||{row.get('url', '')}" def save_uploaded_final_csv(uploaded_file: Any) -> pd.DataFrame: df = pd.read_csv(uploaded_file) missing = ensure_required_columns(df) if missing: raise ValueError(f"Missing required columns: {missing}") st.session_state.final_df = df st.session_state.final_csv_path = None return df def load_current_results_df() -> pd.DataFrame: if isinstance(st.session_state.final_df, pd.DataFrame) and not st.session_state.final_df.empty: return st.session_state.final_df.copy() if st.session_state.final_csv_path: df = safe_read_csv(st.session_state.final_csv_path) if not df.empty: st.session_state.final_df = df return df.copy() return pd.DataFrame() # --------------------------------------------------------------------------- # Page: View Results # --------------------------------------------------------------------------- def page_view_results() -> None: st.title("Candidate Evaluation Dashboard") with st.expander("How scoring works", expanded=False): st.markdown( """ **Tier** — A → Top priority | B → Strong | C → Lower priority **Best Fit** — The collaboration category with strongest alignment. **Total Score (0–20)** — Blogs + Courses + Hack Session + PowerTalk. **Adjusted Score** — Total + small boosts from influence and confidence. **Confidence (0–5)** — Model certainty in its evaluation. **Influence Score** — Speaking roles, publications, certifications, awards, projects, orgs. """.strip() ) uploaded_file = st.sidebar.file_uploader( "Upload final CSV", type="csv", key="results_csv_uploader" ) if uploaded_file is not None: try: df = save_uploaded_final_csv(uploaded_file) st.sidebar.success("Uploaded CSV loaded.") except Exception as e: st.sidebar.error(str(e)) df = pd.DataFrame() else: df = load_current_results_df() if df.empty: st.warning("Run the pipeline first or upload the final CSV to view results.") return missing = ensure_required_columns(df) if missing: st.error(f"Missing required columns: {missing}") return st.sidebar.subheader("Filters") tiers = sorted(df["tier"].dropna().astype(str).unique().tolist()) tier_filter = st.sidebar.multiselect("Tier", tiers, default=tiers) bestfits = df["best_fit"].dropna().astype(str).unique().tolist() bestfit_filter = st.sidebar.multiselect("Best Fit", bestfits, default=bestfits) conf_series = pd.to_numeric(df["confidence"], errors="coerce").fillna(0) min_conf = int(conf_series.min()) if len(df) else 0 max_conf = int(conf_series.max()) if len(df) else 100 if min_conf == max_conf: conf_range = (min_conf, max_conf) st.sidebar.caption(f"Confidence: {min_conf}") else: conf_range = st.sidebar.slider( "Confidence range", min_value=min_conf, max_value=max_conf, value=(min_conf, max_conf), ) total_series = pd.to_numeric(df["total_score"], errors="coerce").fillna(0) min_total = int(total_series.min()) if len(df) else 0 max_total = int(total_series.max()) if len(df) else 20 if min_total == max_total: total_range = (min_total, max_total) st.sidebar.caption(f"Total score: {min_total}") else: total_range = st.sidebar.slider( "Total score range", min_value=min_total, max_value=max_total, value=(min_total, max_total), ) view_df = df.copy() view_df = view_df[view_df["tier"].isin(tier_filter)] view_df = view_df[view_df["best_fit"].isin(bestfit_filter)] view_df = view_df[ (pd.to_numeric(view_df["confidence"], errors="coerce") >= conf_range[0]) & (pd.to_numeric(view_df["confidence"], errors="coerce") <= conf_range[1]) ] view_df = view_df[ (pd.to_numeric(view_df["total_score"], errors="coerce") >= total_range[0]) & (pd.to_numeric(view_df["total_score"], errors="coerce") <= total_range[1]) ] st.subheader("Sort candidates") sort_map = { "Adjusted Score": "adjusted_score", "Total Score": "total_score", "Confidence": "confidence", "Influence Score": "influence_score", "Blogs Score": "blogs_score", "Courses Score": "courses_score", "Hack Session": "hack_session_score", "PowerTalk Score": "powertalk_score", } chosen = st.multiselect( "Sort by (descending priority order):", list(sort_map.keys()), default=["Adjusted Score"] ) if chosen: sort_cols = [sort_map[c] for c in chosen] view_df = view_df.sort_values( by=sort_cols, ascending=[False] * len(sort_cols) ).reset_index(drop=True) st.caption(f"Showing {len(view_df)} candidates") st.subheader("Tick candidates to shortlist") shortlist_rows = [] for idx, row in view_df.iterrows(): row_key = candidate_key(row) cols = st.columns([0.06, 0.94]) with cols[0]: checked = st.checkbox( "Select", key=f"tick_{idx}_{row_key}", value=row_key in st.session_state.shortlist_keys, label_visibility="collapsed", ) if checked: st.session_state.shortlist_keys.add(row_key) else: st.session_state.shortlist_keys.discard(row_key) with cols[1]: name = row["full_name"] headline = row.get("headline", "") tier = row.get("tier", "") best_fit = row.get("best_fit", "") total_score = row.get("total_score", "") adj_score = row.get("adjusted_score", "") confidence = row.get("confidence", "") influence = row.get("influence_score", "") url = row.get("url", "") risk_flags = row.get("risk_flags", "") risks: List[str] = [] if isinstance(risk_flags, str) and risk_flags.strip(): try: parsed = json.loads(risk_flags) risks = parsed if isinstance(parsed, list) else [risk_flags] except Exception: risks = [risk_flags] blogs_score = row.get("blogs_score", 0) courses_score = row.get("courses_score", 0) hack_score = row.get("hack_session_score", 0) power_score = row.get("powertalk_score", 0) blogs_reason = row.get("blogs_reasoning", "") courses_reason = row.get("courses_reasoning", "") hack_reason = row.get("hack_session_reasoning", "") power_reason = row.get("powertalk_reasoning", "") blogs_ev = render_evidence(row.get("blogs_evidence", "")) courses_ev = render_evidence(row.get("courses_evidence", "")) hack_ev = render_evidence(row.get("hack_session_evidence", "")) power_ev = render_evidence(row.get("powertalk_evidence", "")) risks_html = "" if risks: risks_html = ( "

    Risk flags: " + esc(", ".join(str(r) for r in risks)) + "

    " ) link_html = ( f"

    🔗 View LinkedIn

    " if str(url).startswith("http") else "" ) st.markdown( f"""

    {esc(name)}

    {esc(headline)}

    {link_html}

    Tier: {esc(tier)}

    Best fit: {esc(best_fit)}

    Total: {esc(total_score)} | Adj: {esc(adj_score)}

    Conf: {esc(confidence)} | Influence: {esc(influence)}


    Scores | 📝 Blogs: {esc(blogs_score)}/5 | 📚 Courses: {esc(courses_score)}/5 | ⚙️ Hack: {esc(hack_score)}/5 | 🎤 PowerTalk: {esc(power_score)}/5

    {risks_html}
    Blogs (reasoning + evidence)

    {esc(blogs_reason)}

    {bullets(blogs_ev)}
    Courses (reasoning + evidence)

    {esc(courses_reason)}

    {bullets(courses_ev)}
    Hack Session (reasoning + evidence)

    {esc(hack_reason)}

    {bullets(hack_ev)}
    PowerTalk (reasoning + evidence)

    {esc(power_reason)}

    {bullets(power_ev)}
    """, unsafe_allow_html=True, ) if row_key in st.session_state.shortlist_keys: shortlist_rows.append(row) st.sidebar.subheader("Shortlist") if shortlist_rows: shortlist_df = pd.DataFrame(shortlist_rows).drop_duplicates(subset=["full_name", "url"]) cols_to_show = [ c for c in ["full_name", "tier", "best_fit", "total_score", "confidence", "url"] if c in shortlist_df.columns ] st.sidebar.dataframe(shortlist_df[cols_to_show], use_container_width=True) st.sidebar.download_button( label="⬇ Download shortlist CSV", data=shortlist_df.to_csv(index=False).encode("utf-8"), file_name="shortlisted_candidates_llm.csv", mime="text/csv", use_container_width=True, ) if st.sidebar.button("Clear shortlist", use_container_width=True): st.session_state.shortlist_keys = set() st.rerun() else: st.sidebar.info("No candidates shortlisted yet.") # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- if page == "Run Pipeline": page_run_pipeline() else: page_view_results() # import json # import streamlit as st # import pandas as pd # st.set_page_config(page_title="Candidate Evaluation Dashboard", layout="wide") # st.title("📊 Candidate Evaluation Dashboard") # with st.expander("ℹ️ How scoring works (click to expand)", expanded=False): # st.markdown(""" # **Tier** # - A → Top priority (strong total + strong confidence) # - B → Solid candidate, good outreach target # - C → Lower priority # **Best Fit** # - The collaboration category where the model sees strongest alignment. # **Total Score (0–20)** # - Sum of Blogs + Courses + Hack Session + PowerTalk scores. # - Each category scored 0–5. # **Adjusted Score** # - Used only for ranking. # - Total score + small boost from influence and confidence. # - Helps break ties. # **Confidence (0–100)** # - Model certainty in its evaluation. # - Low confidence means limited or unclear evidence. # **Influence Score** # - Deterministic signal based on: # - Speaking roles # - Publications # - Certifications # - Awards # - Projects # - Organizations # - Reflects ecosystem visibility. # """) # # --- Sidebar: CSV upload --- # uploaded_file = st.sidebar.file_uploader("Upload Phase 5 CSV (LLM scored)", type="csv") # if not uploaded_file: # st.warning("Please upload the Phase 5 CSV to continue.") # st.stop() # df = pd.read_csv(uploaded_file) # # --- Required columns for LLM-scored pipeline --- # required_columns = [ # "full_name", # "url", # "headline", # "tier", # "best_fit", # "confidence", # "risk_flags", # "blogs_score", # "blogs_reasoning", # "blogs_evidence", # "courses_score", # "courses_reasoning", # "courses_evidence", # "hack_session_score", # "hack_session_reasoning", # "hack_session_evidence", # "powertalk_score", # "powertalk_reasoning", # "powertalk_evidence", # "total_score", # "influence_score", # "adjusted_score", # ] # missing = [c for c in required_columns if c not in df.columns] # if missing: # st.error(f"Missing required columns: {missing}") # st.stop() # # --- Sidebar filters --- # st.sidebar.subheader("Filters") # tiers = sorted(df["tier"].dropna().unique().tolist()) # tier_filter = st.sidebar.multiselect("Tier", tiers, default=tiers) # bestfits = df["best_fit"].dropna().unique().tolist() # bestfit_filter = st.sidebar.multiselect("Best Fit", bestfits, default=bestfits) # min_conf = int(df["confidence"].min()) if len(df) else 0 # max_conf = int(df["confidence"].max()) if len(df) else 100 # conf_range = st.sidebar.slider("Confidence range", min_conf, max_conf, (min_conf, max_conf)) # min_total = int(df["total_score"].min()) if len(df) else 0 # max_total = int(df["total_score"].max()) if len(df) else 20 # total_range = st.sidebar.slider("Total score range", min_total, max_total, (min_total, max_total)) # # Apply filters # view_df = df.copy() # view_df = view_df[view_df["tier"].isin(tier_filter)] # view_df = view_df[view_df["best_fit"].isin(bestfit_filter)] # view_df = view_df[(view_df["confidence"] >= conf_range[0]) & (view_df["confidence"] <= conf_range[1])] # view_df = view_df[(view_df["total_score"] >= total_range[0]) & (view_df["total_score"] <= total_range[1])] # # --- Sorting --- # st.subheader("Sort candidates") # sort_map = { # "Adjusted Score": "adjusted_score", # "Total Score": "total_score", # "Confidence": "confidence", # "Influence Score": "influence_score", # "Blogs Score": "blogs_score", # "Courses Score": "courses_score", # "Hack Session Score": "hack_session_score", # "PowerTalk Score": "powertalk_score", # } # chosen = st.multiselect("Sort by (descending priority order):", list(sort_map.keys()), default=["Adjusted Score"]) # if chosen: # sort_cols = [sort_map[c] for c in chosen] # view_df = view_df.sort_values(by=sort_cols, ascending=[False] * len(sort_cols)).reset_index(drop=True) # st.caption(f"Showing {len(view_df)} candidates") # # --- Helper to render evidence lists stored as JSON strings --- # def render_evidence(evidence_str): # if pd.isna(evidence_str) or not str(evidence_str).strip(): # return [] # s = str(evidence_str).strip() # try: # ev = json.loads(s) # if isinstance(ev, list): # return [str(x) for x in ev if str(x).strip()] # except: # pass # # fallback: try split lines # return [x.strip() for x in s.split("\n") if x.strip()] # # --- Candidate cards with shortlist --- # st.subheader("✅ Tick candidates to shortlist") # shortlist = [] # for idx, row in view_df.iterrows(): # cols = st.columns([0.06, 0.94]) # with cols[0]: # if st.checkbox("Select", key=f"tick_{idx}", label_visibility="collapsed"): # shortlist.append(row) # with cols[1]: # name = row["full_name"] # headline = row.get("headline", "") # tier = row.get("tier", "") # best_fit = row.get("best_fit", "") # total_score = row.get("total_score", "") # adjusted_score = row.get("adjusted_score", "") # confidence = row.get("confidence", "") # influence = row.get("influence_score", "") # url = row.get("url", "") # risk_flags = row.get("risk_flags", "") # risks = [] # if isinstance(risk_flags, str) and risk_flags.strip(): # try: # risks = json.loads(risk_flags) # if not isinstance(risks, list): # risks = [risk_flags] # except: # risks = [risk_flags] # blogs_score = row.get("blogs_score", 0) # courses_score = row.get("courses_score", 0) # hack_score = row.get("hack_session_score", 0) # power_score = row.get("powertalk_score", 0) # blogs_reason = row.get("blogs_reasoning", "") # courses_reason = row.get("courses_reasoning", "") # hack_reason = row.get("hack_session_reasoning", "") # power_reason = row.get("powertalk_reasoning", "") # blogs_ev = render_evidence(row.get("blogs_evidence", "")) # courses_ev = render_evidence(row.get("courses_evidence", "")) # hack_ev = render_evidence(row.get("hack_session_evidence", "")) # power_ev = render_evidence(row.get("powertalk_evidence", "")) # # Basic HTML escaping for safety in our injected HTML # def esc(x): # return str(x).replace("&", "&").replace("<", "<").replace(">", ">") # # Convert lists to HTML bullets # def bullets(items): # if not items: # return "No evidence extracted" # return "" # risks_html = "" # if risks: # risks_html = "

    Risk flags: " + esc(", ".join([str(r) for r in risks])) + "

    " # st.markdown(f""" #
    #
    #
    #

    {esc(name)}

    #

    {esc(headline)}

    # {"

    🔗 View LinkedIn

    " if str(url).startswith("http") else ""} #
    #
    #

    Tier: {esc(tier)}

    #

    Best fit: {esc(best_fit)}

    #

    Total: {esc(total_score)} | Adj: {esc(adjusted_score)}

    #

    Conf: {esc(confidence)} | Influence: {esc(influence)}

    #
    #
    #
    #

    Scores | # 📝 Blogs: {esc(blogs_score)}/5 | # 📚 Courses: {esc(courses_score)}/5 | # ⚙️ Hack: {esc(hack_score)}/5 | # 🎤 PowerTalk: {esc(power_score)}/5 #

    # {risks_html} #
    # Blogs (reasoning + evidence) #

    {esc(blogs_reason)}

    # {bullets(blogs_ev)} #
    #
    # Courses (reasoning + evidence) #

    {esc(courses_reason)}

    # {bullets(courses_ev)} #
    #
    # Hack Session (reasoning + evidence) #

    {esc(hack_reason)}

    # {bullets(hack_ev)} #
    #
    # PowerTalk (reasoning + evidence) #

    {esc(power_reason)}

    # {bullets(power_ev)} #
    #
    # """, unsafe_allow_html=True) # # --- Sidebar: shortlisted download --- # st.sidebar.subheader("🎯 Shortlist") # if shortlist: # shortlist_df = pd.DataFrame(shortlist) # st.sidebar.dataframe(shortlist_df[["full_name", "tier", "best_fit", "total_score", "confidence", "url"]]) # shortlist_csv = shortlist_df.to_csv(index=False).encode("utf-8") # st.sidebar.download_button( # label="📥 Download Shortlist CSV", # data=shortlist_csv, # file_name="shortlisted_candidates_llm.csv", # mime="text/csv" # ) # else: # st.sidebar.info("No candidates shortlisted yet.")