""" Application Autopsy — Streamlit app. Tells a job seeker WHY an application likely failed, and shows the pattern across all logged applications. Built to be deployed on Streamlit Community Cloud and shared publicly: it works with the deployer's Groq key (via st.secrets) and also lets any visitor paste their own free key. """ import os import tempfile from datetime import datetime from pathlib import Path from uuid import uuid4 import pandas as pd import plotly.graph_objects as go import streamlit as st from analytics.patterns import build_report from core.diagnosis import diagnose from core.parsing import extract_text, parse_jd, parse_resume from core.schema import ApplicationRecord from core.scoring import score from storage import load_all, save, set_outcome BASE_DIR = Path(__file__).parent SAMPLES_DIR = BASE_DIR / "data" / "samples" LABELS_CSV = BASE_DIR / "data" / "labels.csv" OUTCOME_OPTIONS = ["rejected", "interview", "ghosted", "offer"] STAGE_LABELS = { "keyword_ats": "Keyword / ATS filter", "seniority_mismatch": "Seniority mismatch", "skills_gap": "Skills gap", "domain_mismatch": "Domain mismatch", "competitive": "Out-competed", "likely_fine": "Looks fine", } st.set_page_config( page_title="Application Autopsy", page_icon="🩺", layout="wide", menu_items={ "About": "Application Autopsy — find out why your job applications get " "rejected, and the pattern across all of them. Powered by Groq (free).", }, ) # ---------------------------------------------------------------- styling ----- st.markdown( """ """, unsafe_allow_html=True, ) # ------------------------------------------------------------- key handling --- def resolve_api_key() -> str | None: """Resolve the Groq key: sidebar input > st.secrets > environment.""" key = st.session_state.get("user_key", "").strip() if not key: try: key = st.secrets.get("GROQ_API_KEY", "") except Exception: key = "" if not key: key = os.environ.get("GROQ_API_KEY", "") if key: # Make the key visible to the lazily-built core clients, and reset any # cached client if the key changed so a new key actually takes effect. if os.environ.get("GROQ_API_KEY") != key: os.environ["GROQ_API_KEY"] = key import core.llm import core.parsing core.llm._client = None core.parsing._client = None return key or None # ------------------------------------------------------------------ helpers --- def severity_color(value: float) -> str: if value >= 0.7: return "#2ecc71" if value >= 0.45: return "#f39c12" return "#e74c3c" def fit_gauge(value: float) -> go.Figure: pct = value * 100 fig = go.Figure( go.Indicator( mode="gauge+number", value=pct, number={"suffix": "%", "font": {"size": 36}}, gauge={ "axis": {"range": [0, 100], "tickwidth": 1}, "bar": {"color": severity_color(value)}, "steps": [ {"range": [0, 45], "color": "rgba(231,76,60,0.18)"}, {"range": [45, 70], "color": "rgba(243,156,18,0.18)"}, {"range": [70, 100], "color": "rgba(46,204,113,0.18)"}, ], }, ) ) fig.update_layout( height=240, margin=dict(l=20, r=20, t=10, b=10), paper_bgcolor="rgba(0,0,0,0)", ) return fig @st.cache_data def list_samples() -> dict[str, dict]: """Map a human label -> {resume, jd, outcome} for every sample pair.""" out: dict[str, dict] = {} if not LABELS_CSV.exists(): return out labels = pd.read_csv(LABELS_CSV) for _, row in labels.iterrows(): jd_path = SAMPLES_DIR / row["jd_file"] role = jd_path.read_text(encoding="utf-8").splitlines()[0].strip() if jd_path.exists() else row["jd_file"] label = f"{role} · (truth: {row['real_outcome']})" out[label] = { "resume": row["resume_file"], "jd": row["jd_file"], "outcome": row["real_outcome"], } return out def read_uploaded(file) -> str: suffix = os.path.splitext(file.name)[1] with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: tmp.write(file.getvalue()) tmp_path = tmp.name try: return extract_text(tmp_path) finally: os.unlink(tmp_path) # ------------------------------------------------------------------ sidebar --- with st.sidebar: st.markdown("### 🩺 Application Autopsy") st.caption("Why your applications get rejected — and the pattern across them.") st.divider() st.markdown("#### 🔑 Groq API key") st.text_input( "Paste your free Groq key", type="password", key="user_key", placeholder="gsk_...", help="Get one free at console.groq.com. Leave blank to use the app's " "shared key if the host configured one.", ) st.markdown("[→ Get a free Groq key](https://console.groq.com/keys)") active_key = resolve_api_key() if active_key: st.success("API key active ✓") else: st.warning("No key yet — add one above to run analyses.") st.divider() st.markdown("#### 🎯 Try a sample") samples = list_samples() if samples: choice = st.selectbox( "Load a built-in resume + job", ["—"] + list(samples.keys()) ) if st.button("Load sample", use_container_width=True) and choice != "—": s = samples[choice] st.session_state["resume_text"] = (SAMPLES_DIR / s["resume"]).read_text(encoding="utf-8") st.session_state["jd_text"] = (SAMPLES_DIR / s["jd"]).read_text(encoding="utf-8") st.session_state["sample_truth"] = s["outcome"] st.session_state.pop("last_analysis", None) st.rerun() st.divider() st.caption("Built with Streamlit + Groq (Llama 3.3). Open source.") # ------------------------------------------------------------------- header --- st.markdown( """

🩺 Application Autopsy

Paste a résumé and a job description — get a brutally honest fit score, the likely reason it gets rejected, and concrete fixes. Then watch the pattern emerge across every application you log.

""", unsafe_allow_html=True, ) tab_analyze, tab_patterns = st.tabs(["🔍 Analyze an application", "📊 Your patterns"]) # ============================================================ TAB 1: ANALYZE == with tab_analyze: col1, col2 = st.columns(2) with col1: st.markdown("##### 📄 Résumé") resume_upload = st.file_uploader( "Upload (.pdf / .docx / .txt)", type=["pdf", "docx", "txt"], key="resume_up" ) st.text_area("…or paste résumé text", height=260, key="resume_text") with col2: st.markdown("##### 💼 Job description") jd_upload = st.file_uploader( "Upload (.pdf / .docx / .txt)", type=["pdf", "docx", "txt"], key="jd_up" ) st.text_area("…or paste job description text", height=260, key="jd_text") analyze = st.button("🚀 Analyze fit", type="primary", use_container_width=True) if analyze: resume_text = read_uploaded(resume_upload) if resume_upload else st.session_state.get("resume_text", "") jd_text = read_uploaded(jd_upload) if jd_upload else st.session_state.get("jd_text", "") if not active_key: st.error("⚠️ Add a Groq API key in the sidebar first (it's free).") elif not resume_text.strip() or not jd_text.strip(): st.warning("Please provide both a résumé and a job description.") else: try: with st.spinner("Reading, scoring, and diagnosing…"): p_resume = parse_resume(resume_text) p_jd = parse_jd(jd_text) fit = score(p_resume, p_jd) diag = diagnose(fit, p_resume, p_jd) st.session_state["last_analysis"] = { "resume": p_resume, "jd": p_jd, "fit": fit, "diagnosis": diag, } except ValueError as err: if "GROQ_API_KEY" in str(err): st.error("⚠️ That Groq key looks invalid or missing. Check the sidebar.") else: st.error(f"Could not parse the inputs: {err}") except Exception as err: # noqa: BLE001 st.error(f"Something went wrong talking to the model: {err}") if "last_analysis" in st.session_state: res = st.session_state["last_analysis"] fit, diag = res["fit"], res["diagnosis"] st.divider() g_col, s_col = st.columns([1, 1.4]) with g_col: st.markdown("#### Overall fit") st.plotly_chart(fit_gauge(fit.overall), use_container_width=True, config={"displayModeBar": False}) if st.session_state.get("sample_truth"): st.caption(f"Ground-truth outcome for this sample: **{st.session_state['sample_truth']}**") with s_col: st.markdown("#### Dimension scores") for sub in fit.subscores: label = sub.name.replace("_", " ").title() st.markdown( f"{label} " f"{sub.score:.0%}", unsafe_allow_html=True, ) st.progress(sub.score) st.caption(sub.rationale) st.markdown("#### Skills") sk1, sk2 = st.columns(2) with sk1: st.markdown(f"**✅ Matched ({len(fit.matched_skills)})**") chips = "".join(f"{s}" for s in fit.matched_skills) or "None" st.markdown(chips, unsafe_allow_html=True) with sk2: st.markdown(f"**❌ Missing ({len(fit.missing_skills)})**") chips = "".join(f"{s}" for s in fit.missing_skills) or "None" st.markdown(chips, unsafe_allow_html=True) st.divider() stage_val = diag.likely_stage.value badge_color = severity_color(fit.overall) st.markdown( f"#### 🩺 Diagnosis   " f"" f"{STAGE_LABELS.get(stage_val, stage_val)}", unsafe_allow_html=True, ) st.markdown( f"
{diag.headline}" f"

{diag.explanation}

", unsafe_allow_html=True, ) if diag.top_fixes: st.markdown("##### 🛠️ Top fixes") for fix in diag.top_fixes: st.markdown(f"- {fix}") st.divider() with st.expander("💾 Log this application to track patterns"): log_outcome = st.selectbox("Actual outcome (if known)", ["unknown"] + OUTCOME_OPTIONS) if st.button("Save to history"): record = ApplicationRecord( id=str(uuid4()), created_at=datetime.now(), jd=res["jd"], resume=res["resume"], fit=fit, diagnosis=diag, outcome=None if log_outcome == "unknown" else log_outcome, ) save(record) st.success("Logged! See the **Your patterns** tab.") # =========================================================== TAB 2: PATTERNS == with tab_patterns: records = load_all() if not records: st.info("No applications logged yet. Analyze one and hit **Save to history** to start building your pattern.") else: report = build_report(records) m1, m2, m3 = st.columns(3) m1.metric("Applications logged", report.total_applications) m2.metric("Average fit", f"{report.avg_overall_fit:.0%}") m3.metric("Top bottleneck", STAGE_LABELS.get(report.dominant_stage.value, report.dominant_stage.value)) st.markdown(f"
💡 Insight: {report.insight}
", unsafe_allow_html=True) st.markdown("##### 🎯 Where to focus") for focus in report.recommended_focus: st.markdown(f"- {focus}") st.divider() c1, c2 = st.columns(2) with c1: st.markdown("##### Rejection stages") stage_counts = ( pd.Series([STAGE_LABELS.get(r.diagnosis.likely_stage.value, r.diagnosis.likely_stage.value) for r in records]) .value_counts() .rename_axis("stage") .reset_index(name="count") .set_index("stage") ) st.bar_chart(stage_counts, horizontal=True) with c2: st.markdown("##### Outcomes") outcome_counts = ( pd.Series([r.outcome or "unlogged" for r in records]) .value_counts() .rename_axis("outcome") .reset_index(name="count") .set_index("outcome") ) st.bar_chart(outcome_counts, horizontal=True) st.divider() st.markdown("##### 📜 History — edit the **Outcome** column to update") df = pd.DataFrame( [ { "id": r.id, "Role": r.jd.title, "Company": r.jd.company or "—", "Fit": round(r.fit.overall, 2), "Stage": STAGE_LABELS.get(r.diagnosis.likely_stage.value, r.diagnosis.likely_stage.value), "Outcome": r.outcome or "unknown", "Date": r.created_at.strftime("%Y-%m-%d"), } for r in records ] ) edited = st.data_editor( df, hide_index=True, use_container_width=True, column_config={ "id": None, "Outcome": st.column_config.SelectboxColumn( "Outcome", options=["unknown"] + OUTCOME_OPTIONS, required=True ), "Fit": st.column_config.ProgressColumn("Fit", min_value=0.0, max_value=1.0), }, disabled=["Role", "Company", "Fit", "Stage", "Date"], key="history_editor", ) # Persist any outcome edits. for orig, new in zip(df.itertuples(index=False), edited.itertuples(index=False)): if new.Outcome != orig.Outcome and new.Outcome in OUTCOME_OPTIONS: set_outcome(orig.id, new.Outcome) st.toast(f"Updated outcome to {new.Outcome}") st.rerun() st.markdown( "", unsafe_allow_html=True, )