""" Job Automation Agent — Streamlit UI v3 (Light SaaS Dashboard) Run: streamlit run ui.py """ import streamlit as st import os, sys, json, time, threading, queue, logging import pandas as pd from pathlib import Path from dotenv import load_dotenv import src.app_logger as app_logger load_dotenv() # ── HF Spaces: write Google credentials from env var ───────────────────────── _gcreds_json = os.getenv("GOOGLE_CREDENTIALS_JSON", "") if _gcreds_json and not os.path.exists("google_credentials.json"): try: with open("google_credentials.json", "w") as _f: _f.write(_gcreds_json) except Exception: pass # ── Page config ─────────────────────────────────────────────────────────────── st.set_page_config( page_title="Job Automation Agent", page_icon="🤖", layout="wide", initial_sidebar_state="collapsed", ) # ══════════════════════════════════════════════════════════════════════════════ # CSS — Light SaaS Dashboard Theme # ══════════════════════════════════════════════════════════════════════════════ st.markdown(""" """, unsafe_allow_html=True) # ── Playwright install (runs once per server lifetime on HF Spaces) ────────── # On HF Spaces, Chromium is pre-installed in the Dockerfile so this is a fast # no-op check. We omit --with-deps because system-package installs require root # and would fail silently, adding ~10s of pointless startup latency every boot. @st.cache_resource(show_spinner=False) def _ensure_playwright(): import subprocess, sys as _sys result = subprocess.run( [_sys.executable, "-m", "playwright", "install", "chromium"], capture_output=True, text=True, timeout=30, ) return result.returncode == 0 _ensure_playwright() # ── Session state defaults ──────────────────────────────────────────────────── _DEFAULTS = { "results": None, "running": False, "excel_path": "", "log_msgs": [], "progress_pct": 0, "progress_label": "", "current_log_file": "", "steps": {}, "show_history": False, "loaded_run": "", "setup_step": 1, "completed_jobs": [], # per-job results streamed in during a run "custom_roles": [], # user-added custom role titles "_gen_version": "v1", # resume generation mode: "v1" or "v2" "logged_in": False, "user_id": None, "user_email": "", } for _k, _v in _DEFAULTS.items(): if _k not in st.session_state: st.session_state[_k] = _v # ── Restore run history + resumes from the private HF Dataset (Option B) ────── # HF Spaces wipe the local disk on restart; pull persisted runs back once per # session so the History panel and per-job downloads work after a rebuild. if not st.session_state.get("_hf_synced"): try: from src.hf_storage import sync_down, is_enabled if is_enabled(): sync_down() except Exception: pass st.session_state["_hf_synced"] = True # ── Restore uploaded resume from Supabase Storage (survives HF restarts) ────── if not st.session_state.get("_sb_resume_synced"): st.session_state["_sb_resume_synced"] = True if not os.path.exists("data/resume/resume.pdf"): try: from src.supabase_client import get_service_client, is_configured, get_owner_user_id if is_configured(): uid = get_owner_user_id() if uid: pdf_data = get_service_client().storage.from_("resumes").download( f"{uid}/resume.pdf" ) if pdf_data: os.makedirs("data/resume", exist_ok=True) with open("data/resume/resume.pdf", "wb") as _f: _f.write(pdf_data) except Exception: pass # ── Seed the hardcoded default resume when the user has none (R20) ──────────── if not st.session_state.get("_default_resume_seeded"): st.session_state["_default_resume_seeded"] = True _has_pdf = os.path.exists("data/resume/resume.pdf") _has_tex = os.path.exists("data/resume/resume.tex") or bool(st.session_state.get("_resume_tex")) if not _has_pdf and not _has_tex: try: from src.default_resume import get_default_resume_latex os.makedirs("data/resume", exist_ok=True) with open("data/resume/resume.tex", "w", encoding="utf-8") as _f: _f.write(get_default_resume_latex()) st.session_state["_resume_tex"] = get_default_resume_latex() except Exception: pass # ── Preferences helpers ─────────────────────────────────────────────────────── _PREF_MAP = [ # (supabase_key, session_state_key, default_value) ("setup_step", "setup_step", 1), ("roles", "_cfg_roles", ["Product Manager", "Senior Product Manager", "AI Product Manager"]), ("locations", "_cfg_locations", ["India", "Bangalore"]), ("days", "_cfg_days", 7), ("min_score", "_cfg_min_score", 1), ("max_jobs", "_cfg_max_jobs", 25), ("sb", "_cfg_sb", []), ("ats", "_cfg_ats", []), ("cp", "_cfg_cp", []), ("uploaded_sig", "_uploaded_sig", ""), ("resume_tex", "_resume_tex", ""), ] def _apply_prefs(prefs: dict): if not prefs: return for pk, sk, _ in _PREF_MAP: if pk in prefs and prefs[pk] is not None: st.session_state[sk] = prefs[pk] def _save_prefs(): uid = st.session_state.get("user_id") if not uid: return try: from src.supabase_client import save_preferences save_preferences(uid, {pk: st.session_state.get(sk, dv) for pk, sk, dv in _PREF_MAP}) except Exception: pass # ── Auto-login: restore session from Supabase client (persists within process) ─ if not st.session_state.get("logged_in"): try: from src.supabase_client import get_anon_client, load_preferences as _load_prefs_sb, is_configured if is_configured(): _sb_sess = get_anon_client().auth.get_session() if _sb_sess and _sb_sess.user: st.session_state["logged_in"] = True st.session_state["user_id"] = _sb_sess.user.id st.session_state["user_email"] = _sb_sess.user.email if not st.session_state.get("_prefs_loaded"): _apply_prefs(_load_prefs_sb(_sb_sess.user.id)) st.session_state["_prefs_loaded"] = True except Exception: pass # ── Shared progress queue ──────────────────────────────────────────────────── if "progress_q" not in st.session_state: st.session_state["progress_q"] = queue.Queue() _progress_q: queue.Queue = st.session_state["progress_q"] # ── Pipeline steps definition ──────────────────────────────────────────────── PIPELINE_STEPS = [ {"id": "resume", "icon": "📄", "title": "Parse Resume"}, {"id": "profile", "icon": "🧠", "title": "Build Profile"}, {"id": "linkedin", "icon": "🔵", "title": "LinkedIn"}, {"id": "indeed", "icon": "🟠", "title": "Indeed"}, {"id": "glassdoor", "icon": "🟢", "title": "Glassdoor"}, {"id": "remotive", "icon": "🌍", "title": "Remotive"}, {"id": "weworkremotely", "icon": "💻", "title": "WeWorkRemotely"}, {"id": "naukri", "icon": "🇮🇳", "title": "Naukri"}, {"id": "company_ats", "icon": "🏢", "title": "Company ATS"}, {"id": "ever_jobs", "icon": "🌐", "title": "EverJobs (160+)"}, {"id": "assess", "icon": "🤖", "title": "AI Assessment"}, {"id": "resumes", "icon": "📝", "title": "Generate Resumes"}, {"id": "report", "icon": "📊", "title": "Save Report"}, ] _STEP_TITLE_MAP = {s["id"]: s["title"] for s in PIPELINE_STEPS} # ══════════════════════════════════════════════════════════════════════════════ # HELPERS # ══════════════════════════════════════════════════════════════════════════════ def _score_color_cls(score): if score >= 8: return "high" if score >= 6: return "medium" return "low" def _badge_cls(score): if score >= 8: return "badge-high" if score >= 6: return "badge-medium" return "badge-low" def _score_emoji(score): if score >= 8: return "🔴" if score >= 6: return "🟡" return "⚪" def _render_steps(steps_state: dict) -> str: status_icons = {"pending": "⬜", "active": "⏳", "done": "✅", "error": "❌", "skip": "⏭"} html = '
' for s in PIPELINE_STEPS: sid = s["id"] info = steps_state.get(sid, {"status": "pending", "detail": "", "elapsed": ""}) status = info.get("status", "pending") detail = info.get("detail", "") elapsed= info.get("elapsed", "") css = {"active": "active", "done": "done", "error": "error", "skip": "skip"}.get(status, "") icon = status_icons.get(status, "⬜") safe = detail.replace("<","<").replace(">",">") html += f"""
{icon}

{s['icon']} {s['title']}

{safe or ('Waiting…' if status=='pending' else '')}

{elapsed}
""" html += '
' return html def _render_log(msgs: list) -> str: lines = "" for m in msgs[-40:]: if m.startswith(("✅","✓")): cls = "log-ok" elif m.startswith("❌"): cls = "log-err" elif m.startswith(("⚠","⚡")): cls = "log-warn" else: cls = "log-info" safe = m.replace("<","<").replace(">",">") lines += f'{safe}\n' return f'
{lines}
' def _render_uploaded_profile(): """Parse the uploaded resume and show the profile we built, on the same page.""" import html as _html try: from src.resume_parser_v2 import parse_resume_pdf_cached r = parse_resume_pdf_cached("data/resume/resume.pdf") except Exception as e: st.caption(f"⚠ Couldn't parse the resume profile: {str(e)[:120]}") return def esc(t): return _html.escape(str(t or "")) roles_html = "" for role in r.roles[:6]: meta = " · ".join(filter(None, [esc(role.company), esc(role.location), esc(role.dates)])) n_b = len(role.bullets) roles_html += ( f"
" f"{esc(role.title)}
" f"{meta} · {n_b} bullets" f"
" ) edu_html = "" for e in r.education[:4]: meta = " · ".join(filter(None, [esc(e.institution), esc(e.dates)])) edu_html += f"
{esc(e.degree)}{meta}
" summary = esc(r.summary)[:400] + ("…" if len(r.summary) > 400 else "") contact = esc(r.contact.render_line()) st.html(f"""

PROFILE WE BUILT FROM YOUR RESUME

{esc(r.name)}

{contact}

{summary}

Experience ({len(r.roles)} roles)

{roles_html or 'No roles parsed'}

Education

{edu_html or 'No education parsed'}
""") st.caption("⬆ This is what the tool extracted. If it looks wrong, re-upload the correct PDF — it will replace this.") def _job_card_html(job: dict, rank: int) -> str: score = job.get("relevance_score", 0) cls = _score_color_cls(score) badge = _badge_cls(score) emoji = _score_emoji(score) title = job.get("title", "") company = job.get("company", "") location = job.get("location", "") platform = job.get("platform", "") salary = job.get("salary", "") or "" ats_b = job.get("ats_score_before") ats_a = job.get("ats_score_after") imp = job.get("ats_improvement", 0) or 0 url = job.get("url", "") ats_html = "" if ats_b is not None and ats_a is not None: ats_html = ( f'ATS {ats_b}%' f'' f'{ats_a}%' f'(+{imp}pp)' ) sal_html = f'💰 {salary}' if salary and salary != "Not specified" else "" link_html = f'Apply →' if url else "" t = title.replace("<","<").replace(">",">") co = company.replace("<","<").replace(">",">") lo = location.replace("<","<").replace(">",">") return f"""

#{rank} {t}

🏢 {co} · 📍 {lo}

{emoji} {score}/10
{ats_html} {sal_html} via {platform} {link_html}
""" def _render_card_actions(job: dict, key: str) -> None: """Per-job download row rendered under a job card. Mirrors the in-card "Apply →" link with real download buttons for THIS job's tailored resume (PDF to apply with + editable DOCX). Renders nothing when no resume was generated for the job (score below threshold). """ resume_path = job.get("resume_path", "") pdf_path = job.get("resume_pdf_path", "") or ( os.path.splitext(resume_path)[0] + ".pdf" if resume_path else "" ) has_docx = bool(resume_path) and os.path.exists(resume_path) has_pdf = bool(pdf_path) and os.path.exists(pdf_path) if not (has_docx or has_pdf): return c_pdf, c_docx, _spacer = st.columns([1.4, 1.4, 5]) if has_pdf: with c_pdf, open(pdf_path, "rb") as f: st.download_button( "⬇ PDF", f.read(), os.path.basename(pdf_path), "application/pdf", key=f"dl_pdf_{key}", use_container_width=True, help="Download this job's tailored resume (PDF — apply with this)", ) if has_docx: with c_docx, open(resume_path, "rb") as f: st.download_button( "⬇ DOCX", f.read(), os.path.basename(resume_path), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", key=f"dl_docx_{key}", use_container_width=True, help="Download this job's tailored resume (editable DOCX)", ) def _metrics_html(results: list) -> str: total = len(results) high = sum(1 for j in results if j.get("relevance_score", 0) >= 8) med = sum(1 for j in results if 6 <= j.get("relevance_score", 0) <= 7) llm_cnt = sum(1 for j in results if j.get("resume_generated") == "LLM Tailored") pdf_cnt = sum(1 for j in results if j.get("resume_pdf_path")) ats_a = [j["ats_score_after"] for j in results if j.get("ats_score_after")] avg_ats = int(sum(ats_a)/len(ats_a)) if ats_a else 0 return f"""
{total}
Total Jobs
{high}
High Priority
{med}
Good Match
{llm_cnt}
LLM Resumes
{pdf_cnt}
PDFs Ready
{avg_ats}%
Est. ATS (verify on Jobalytics)
""" def _sheets_configured() -> tuple[bool, str]: if os.path.exists("google_credentials.json"): return True, "Service account connected" if os.path.exists("google_token.json"): return True, "OAuth connected" if os.path.exists("google_oauth_client.json"): return False, "Needs one-time authorization" return False, "Not connected yet" def _readiness_score(has_resume, roles, locations, platforms, sheets_ok, min_score): score = 0 total = 6 if has_resume: score += 1 if roles: score += 1 if locations: score += 1 if platforms: score += 1 if sheets_ok: score += 1 if min_score is not None: score += 1 return int((score / total) * 100) def _readiness_level(pct): if pct >= 100: return ("Automation Pro", True) if pct >= 83: return ("Power Search Ready", False) if pct >= 50: return ("Balanced Setup", False) return ("Getting Started", False) # ══════════════════════════════════════════════════════════════════════════════ # AUTH — Login gate # ══════════════════════════════════════════════════════════════════════════════ def _render_login_page(): from src.supabase_client import is_configured, get_anon_client _, col, _ = st.columns([1, 1.4, 1]) with col: st.markdown("""
🤖

JAA · ATS Tool

Sign in to continue

""", unsafe_allow_html=True) if not is_configured(): st.error("Supabase not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY in HF Space → Settings → Secrets.") return with st.form("login_form", clear_on_submit=False): email = st.text_input("Email", placeholder="you@example.com") password = st.text_input("Password", type="password", placeholder="••••••••") submitted = st.form_submit_button("Sign in", use_container_width=True) if submitted: if not email or not password: st.error("Enter your email and password.") return try: resp = get_anon_client().auth.sign_in_with_password( {"email": email, "password": password} ) st.session_state["logged_in"] = True st.session_state["user_id"] = resp.user.id st.session_state["user_email"] = resp.user.email # Restore wizard state from last session if not st.session_state.get("_prefs_loaded"): from src.supabase_client import load_preferences as _lp _apply_prefs(_lp(resp.user.id)) st.session_state["_prefs_loaded"] = True st.rerun() except Exception as exc: msg = str(exc).lower() if "invalid" in msg or "credentials" in msg or "login" in msg: st.error("Incorrect email or password.") else: st.error(f"Login failed: {exc}") if not st.session_state.get("logged_in"): _render_login_page() st.stop() # ══════════════════════════════════════════════════════════════════════════════ # HEADER # ══════════════════════════════════════════════════════════════════════════════ @st.cache_data(ttl=300, show_spinner=False) def _deploy_timestamp_ist() -> str: """Latest code-update (deploy) time in IST. Prefers the HEAD git commit time (matches what GitHub/HF deployed); falls back to the newest mtime among ui.py + src/*.py if git isn't available. """ from datetime import datetime, timezone, timedelta ist = timezone(timedelta(hours=5, minutes=30)) here = os.path.dirname(os.path.abspath(__file__)) ts = None try: import subprocess out = subprocess.run( ["git", "log", "-1", "--format=%cI"], capture_output=True, text=True, timeout=4, cwd=here, ) s = (out.stdout or "").strip() if s: ts = datetime.fromisoformat(s) except Exception: ts = None if ts is None: try: cands = [os.path.join(here, "ui.py")] src_dir = os.path.join(here, "src") if os.path.isdir(src_dir): cands += [os.path.join(src_dir, f) for f in os.listdir(src_dir) if f.endswith(".py")] mt = max(os.path.getmtime(p) for p in cands if os.path.exists(p)) ts = datetime.fromtimestamp(mt, tz=timezone.utc) except Exception: ts = datetime.now(timezone.utc) return ts.astimezone(ist).strftime("%d %b %Y, %I:%M %p IST") # Resume is considered "uploaded" if EITHER: # - a compiled resume.pdf exists, OR # - a saved LaTeX source exists (compile-on-demand at pipeline launch). # Matches the Chrome extension behaviour: save now, compile when needed. has_resume = ( os.path.exists("data/resume/resume.pdf") or bool(st.session_state.get("_resume_tex")) or os.path.exists("data/resume/resume.tex") ) sheets_ok, sheets_status = _sheets_configured() hdr_l, hdr_r = st.columns([5, 1]) with hdr_l: status_text = "Running..." if st.session_state.running else ( "Results ready" if st.session_state.results else "Setup in progress" ) # Title removed per user request; keep a minimal status badge so the # running/results state is still visible. st.html(f"""
{"⏳" if st.session_state.running else "✨"} {status_text}
🟢 Last code update (deployed): {_deploy_timestamp_ist()}
""") with hdr_r: st.markdown("
", unsafe_allow_html=True) hist_label = "📜 History ✕" if st.session_state.show_history else "📜 History" if st.button(hist_label, use_container_width=True): st.session_state.show_history = not st.session_state.show_history st.rerun() _email_short = (st.session_state.get("user_email") or "").split("@")[0] if st.button(f"⏻ {_email_short or 'Sign out'}", use_container_width=True, help="Sign out"): try: from src.supabase_client import get_anon_client get_anon_client().auth.sign_out() except Exception: pass for _k in ("logged_in", "user_id", "user_email", "_prefs_loaded"): st.session_state[_k] = False if _k == "logged_in" else None st.rerun() # ══════════════════════════════════════════════════════════════════════════════ # HISTORY PANEL # ══════════════════════════════════════════════════════════════════════════════ if st.session_state.show_history: from src.run_history import list_runs, load_run past_runs = list_runs() st.html('
') st.markdown("### 📜 Run History") if not past_runs: st.info("No saved runs yet. Complete your first search to see history here.") else: for run in past_runs[:20]: imp = run.get("avg_ats_after", 0) - run.get("avg_ats_before", 0) plat = ", ".join(run.get("platforms", [])) date = run.get("date", "") total = run.get("total_jobs", 0) high = run.get("high_priority", 0) ats_b = run.get("avg_ats_before", 0) ats_a = run.get("avg_ats_after", 0) resumes = run.get("resumes", 0) col_info, col_btn = st.columns([5, 1]) with col_info: st.html(f"""
📅 {date}
{total} jobs {high} 🔴 high {resumes} resumes ATS {ats_b}%→{ats_a}% (+{imp}pp) {plat}
""") with col_btn: if st.button("Load", key=f"hist_{run.get('run_id','')}", use_container_width=True, disabled=st.session_state.running): full = load_run(run["_path"]) if full.get("jobs"): st.session_state.results = full["jobs"] st.session_state.excel_path = full.get("excel_path", "") st.session_state.loaded_run = run.get("run_id", "") st.session_state.show_history = False st.rerun() st.html('
') if st.session_state.loaded_run: st.info(f"📂 Showing results from run: **{st.session_state.loaded_run}**") st.divider() # ══════════════════════════════════════════════════════════════════════════════ # CONFIGURATION — Step-by-step wizard # ══════════════════════════════════════════════════════════════════════════════ show_config = not (st.session_state.running or st.session_state.results) start = False # set to True only on step 7 launch button # ── V1/V2 generation mode selector (home page, outside wizard) ────────── _vlabel = st.radio( "Resume generation mode", options=["V1 — Structured (keyword placement)", "V2 — Natural AI (sentence integration)"], index=0 if st.session_state.get("_gen_version", "v1") == "v1" else 1, horizontal=True, key="_gen_version_radio", ) st.session_state["_gen_version"] = "v2" if _vlabel.startswith("V2") else "v1" # Platform imports (needed even when not showing config, for pipeline) from src.ever_jobs_bridge.platforms import PLATFORM_GROUPS, INDIA_DEFAULT_PLATFORMS, EVER_JOBS_PLATFORMS SETUP_STEPS = [ {"num": 1, "label": "Resume", "icon": "📄"}, {"num": 2, "label": "Roles", "icon": "🎯"}, {"num": 3, "label": "Locations", "icon": "📍"}, {"num": 4, "label": "Freshness", "icon": "⏱"}, {"num": 5, "label": "Platforms", "icon": "🌐"}, {"num": 6, "label": "AI Score", "icon": "🤖"}, {"num": 7, "label": "Tracker", "icon": "📊"}, ] # Persistent config defaults — these survive widget keys being deleted by Streamlit # when a widget isn't rendered on the current step. _ROLES_DEFAULT = ["Product Manager", "Senior Product Manager", "AI Product Manager"] _LOCS_DEFAULT = ["India", "Bangalore"] sb_options = PLATFORM_GROUPS["Search Boards"] sb_defaults = [p for p in INDIA_DEFAULT_PLATFORMS if p in sb_options] # Sync: if a widget was just rendered, save its value to a persistent key. # Streamlit will delete the widget key on the next rerun if the widget isn't shown. for _wk, _pk, _fallback in [ ("roles_select", "_cfg_roles", _ROLES_DEFAULT), ("locations_select", "_cfg_locations", _LOCS_DEFAULT), ("days_select", "_cfg_days", 7), ("max_jobs_input", "_cfg_max_jobs", 25), ("min_score_slider", "_cfg_min_score", 1), ("ej_search_boards", "_cfg_sb", sb_defaults), ("ej_ats_platforms", "_cfg_ats", []), ("ej_company_pages", "_cfg_cp", []), ]: if _wk in st.session_state: st.session_state[_pk] = ( list(st.session_state[_wk]) if isinstance(st.session_state[_wk], list) else st.session_state[_wk] ) elif _pk not in st.session_state: st.session_state[_pk] = _fallback def _cfg(key): return st.session_state[key] if show_config: current_step = st.session_state.setup_step _roles_val = _cfg("_cfg_roles") _locs_val = _cfg("_cfg_locations") _sb_val = _cfg("_cfg_sb") _ats_val = _cfg("_cfg_ats") _cp_val = _cfg("_cfg_cp") _all_plats_preview = _sb_val + _ats_val + _cp_val step_done = { 1: has_resume, 2: bool(_roles_val), 3: bool(_locs_val), 4: True, 5: bool(_all_plats_preview), 6: True, 7: sheets_ok, } # ── Stepper bar ── stepper_html = '
' for i, s in enumerate(SETUP_STEPS): n = s["num"] if n == current_step: dot_cls = "active" elif step_done.get(n, False): dot_cls = "done" else: dot_cls = "pending" dot_text = "✓" if dot_cls == "done" and n != current_step else str(n) stepper_html += f'
{dot_text}
' if i < len(SETUP_STEPS) - 1: line_cls = "done" if step_done.get(n, False) else "pending" stepper_html += f'
' stepper_html += '
' labels_html = '
' for s in SETUP_STEPS: n = s["num"] if n == current_step: lbl_cls = "active" elif step_done.get(n, False): lbl_cls = "done" else: lbl_cls = "" labels_html += f'{s["label"]}' labels_html += '
' st.html(f"""
{stepper_html} {labels_html}
""") col_main, col_sidebar = st.columns([3, 1], gap="large") # We need to declare all widget variables with consistent keys across reruns # so Streamlit doesn't lose state. Hidden widgets hold values for non-active steps. with col_main: # ──────────────────────────────────────────────────────────────────── # STEP 1: Upload Resume # ──────────────────────────────────────────────────────────────────── if current_step == 1: st.html(f"""
{"✓" if has_resume else "1"}

Upload your resume

Paste your LaTeX source for the best ATS results, or upload a PDF below.

""") # ── PRIMARY: LaTeX paste ────────────────────────────────────────── st.html("""
📄 LaTeX source
Recommended — paste your full .tex code below for highest ATS accuracy
""") # Restore last-pasted LaTeX in priority order: # 1. Session state (set after login from Supabase preferences) # 2. Local data/resume/resume.tex (this container's last compile) _saved_latex = st.session_state.get("_resume_tex", "") or "" if not _saved_latex and os.path.exists("data/resume/resume.tex"): try: with open("data/resume/resume.tex", "r", encoding="utf-8") as _lf: _saved_latex = _lf.read() st.session_state["_resume_tex"] = _saved_latex except Exception: pass latex_input = st.text_area( "LaTeX code", value=_saved_latex, height=260, placeholder=r"""\documentclass[11pt]{article} % Paste your full resume LaTeX code here \begin{document} ... \end{document}""", key="latex_paste_input", label_visibility="collapsed", ) _latex_changed = latex_input.strip() and latex_input.strip() != _saved_latex.strip() if st.button( "💾 Save LaTeX", type="primary", use_container_width=True, disabled=not bool(latex_input and latex_input.strip()), key="save_latex_btn", help="Saves your LaTeX source. We'll compile it to PDF automatically when you start a job search.", ): if latex_input and latex_input.strip(): os.makedirs("data/resume", exist_ok=True) with open("data/resume/resume.tex", "w", encoding="utf-8") as _f: _f.write(latex_input) # Invalidate any previously-compiled PDF + parse cache so the # next pipeline launch recompiles from this fresh source. for _stale in ("data/resume/resume.pdf", "data/resume/_parsed.json"): try: if os.path.exists(_stale): os.remove(_stale) except Exception: pass st.session_state["_uploaded_sig"] = f"latex_paste:{len(latex_input)}" st.session_state["_resume_tex"] = latex_input _save_prefs() has_resume = True st.success("✅ LaTeX saved to your account. It will be compiled to PDF when you start a job search.") st.rerun() # ── SECONDARY: PDF upload ───────────────────────────────────────── st.html("""
or upload a PDF
""") resume_file = st.file_uploader( "Upload PDF resume", type=["pdf"], key="resume_upload", label_visibility="collapsed", help="Upload a PDF if you don't have a LaTeX source. Max 10MB.", ) if resume_file is not None: _sig = f"{resume_file.name}:{getattr(resume_file, 'size', 0)}" if st.session_state.get("_uploaded_sig") != _sig: os.makedirs("data/resume", exist_ok=True) with open("data/resume/resume.pdf", "wb") as f: f.write(resume_file.getvalue()) st.session_state["_uploaded_sig"] = _sig try: from src.supabase_client import get_service_client, is_configured, get_owner_user_id if is_configured(): _uid = st.session_state.get("user_id") or get_owner_user_id() if _uid: with open("data/resume/resume.pdf", "rb") as _rf: get_service_client().storage.from_("resumes").upload( f"{_uid}/resume.pdf", _rf.read(), {"upsert": "true", "content-type": "application/pdf"}, ) except Exception: pass try: if os.path.exists("data/resume/_parsed.json"): os.remove("data/resume/_parsed.json") except Exception: pass has_resume = True st.rerun() # ── Success / profile ───────────────────────────────────────────── _has_pdf = os.path.exists("data/resume/resume.pdf") _has_tex = bool(st.session_state.get("_resume_tex")) or os.path.exists("data/resume/resume.tex") if _has_pdf: fsize = os.path.getsize("data/resume/resume.pdf") // 1024 _sig_raw = st.session_state.get("_uploaded_sig", "") _src_label = "LaTeX source" if _sig_raw.startswith("latex_paste:") else (_sig_raw.split(":")[0] or "resume.pdf") st.html(f"""
{_src_label} → resume.pdf ({fsize} KB) — Ready for AI matching
""") _render_uploaded_profile() elif _has_tex: _tex_len = len(st.session_state.get("_resume_tex", "") or "") st.html(f"""
LaTeX saved ({_tex_len} chars) — Will compile when you start the job search
""") else: st.caption("Paste your LaTeX or upload a PDF to unlock AI matching.") # ──────────────────────────────────────────────────────────────────── # STEP 2: Target Roles # ──────────────────────────────────────────────────────────────────── if current_step == 2: st.html("""
2

Choose your target roles

Select up to 5 roles for more focused results. We'll search all selected roles across every platform.

""") if current_step == 2: _BASE_ROLES = [ "Product Manager", "Senior Product Manager", "Lead Product Manager", "Principal Product Manager", "Group Product Manager", "Associate Product Manager", "AI Product Manager", "Technical Product Manager", "Data Product Manager", "Platform Product Manager", "Growth Product Manager", "SaaS Product Manager", "B2B Product Manager", "B2C Product Manager", "Mobile Product Manager", "Payments Product Manager", "Fintech Product Manager", "E-commerce Product Manager", "Product Owner", "Technical Product Owner", "Senior Product Owner", "Head of Product", "Director of Product", "VP of Product", "Product Lead", "Program Manager", "Project Manager", "Business Analyst", "Product Analyst", "Product Operations Manager", "Product Marketing Manager", "Chief Product Officer", ] # Merge user-added custom roles so they're selectable + survive reruns custom = st.session_state.get("custom_roles", []) or [] role_options = _BASE_ROLES + [c for c in custom if c not in _BASE_ROLES] # ── Add a custom role ── ca, cb = st.columns([4, 1]) with ca: _new_role = st.text_input( "Add a custom role", key="new_role_input", placeholder="e.g. Conversational AI Product Manager", label_visibility="collapsed", ) with cb: if st.button("➕ Add role", use_container_width=True, key="add_role_btn"): nr = (_new_role or "").strip() if nr and nr not in role_options: st.session_state.custom_roles = custom + [nr] # Pre-select the new role sel = list(st.session_state.get("roles_select", _roles_val)) if nr not in sel: sel.append(nr) st.session_state.roles_select = sel st.rerun() roles = st.multiselect( "Target roles", options=role_options, default=[r for r in _roles_val if r in role_options], key="roles_select", label_visibility="collapsed", ) if roles: st.html(f'
🎯 Great focus — {len(roles)} target role{"s" if len(roles)!=1 else ""} selected
') st.caption("Don't see your role? Type it above and click **Add role** — it'll be searched too.") else: roles = _roles_val # ──────────────────────────────────────────────────────────────────── # STEP 3: Locations # ──────────────────────────────────────────────────────────────────── if current_step == 3: st.html("""
3

Where should we search?

You can mix cities, countries, and remote preferences.

""") if current_step == 3: locations = st.multiselect( "Locations", options=["India", "Bangalore", "Hyderabad", "Mumbai", "Delhi NCR", "Pune", "Chennai", "Noida", "Remote", "Worldwide"], default=_locs_val, key="locations_select", label_visibility="collapsed", ) if locations: st.html(f'
📍 Searching in {len(locations)} location{"s" if len(locations)!=1 else ""}
') else: locations = _locs_val # ──────────────────────────────────────────────────────────────────── # STEP 4: Job Freshness & Volume # ──────────────────────────────────────────────────────────────────── if current_step == 4: st.html("""
4

Job freshness and search volume

Lower values make results more focused. Higher values increase coverage.

""") fc1, fc2 = st.columns(2) with fc1: _DAYS_OPTS = [0.25, 1, 3, 7, 14, 30] def _fmt_days(x): if x < 1: return f"Last {int(round(x * 24))} hours" if x == 1: return "Last 1 day" return f"Last {int(x)} days" _cur = _cfg("_cfg_days") _days_idx = _DAYS_OPTS.index(_cur) if _cur in _DAYS_OPTS else 3 # default 7 days_posted = st.selectbox( "Only show jobs posted within", options=_DAYS_OPTS, index=_days_idx, format_func=_fmt_days, key="days_select", ) with fc2: max_jobs = st.number_input( "Maximum jobs per platform", min_value=5, max_value=100, value=_cfg("_cfg_max_jobs"), step=5, help="Total jobs to fetch from each platform (not per query)", key="max_jobs_input", ) else: days_posted = _cfg("_cfg_days") max_jobs = _cfg("_cfg_max_jobs") # ──────────────────────────────────────────────────────────────────── # STEP 5: Job Platforms # ──────────────────────────────────────────────────────────────────── sb_count = len(PLATFORM_GROUPS["Search Boards"]) ats_count = len(PLATFORM_GROUPS["ATS Platforms"]) cp_count = len(PLATFORM_GROUPS["Company Pages"]) total_platforms = sb_count + ats_count + cp_count if current_step == 5: st.html(f"""
5

Job platforms

Select platforms where the agent should search. {total_platforms} platforms available across 3 categories.

""") ej_col1, ej_col2, ej_col3 = st.columns(3) with ej_col1: with st.expander(f"🔍 Search Boards ({sb_count})", expanded=True): if st.button("↻ Reset to recommended", key="sb_rec", use_container_width=True): st.session_state["ej_search_boards"] = sb_defaults st.rerun() selected_search_boards = st.multiselect( "Search Boards", options=sb_options, default=_sb_val, format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k), label_visibility="collapsed", key="ej_search_boards", ) with ej_col2: with st.expander(f"🏢 ATS Platforms ({ats_count})"): ats_options = PLATFORM_GROUPS["ATS Platforms"] selected_ats = st.multiselect( "ATS Platforms", options=ats_options, default=[], format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k), label_visibility="collapsed", key="ej_ats_platforms", help="Applicant Tracking System platforms (Greenhouse, Lever, Workday, etc.).", ) with ej_col3: with st.expander(f"🏭 Company Pages ({cp_count})"): cp_options = PLATFORM_GROUPS["Company Pages"] selected_company = st.multiselect( "Company Pages", options=cp_options, default=[], format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k), label_visibility="collapsed", key="ej_company_pages", help="Direct company career page scrapers.", ) ever_jobs_platforms = selected_search_boards + selected_ats + selected_company if len(ever_jobs_platforms) > 30: st.warning( f"⚠ **{len(ever_jobs_platforms)} platforms selected.** " "Runs with >30 platforms may take 5–10 minutes.", icon="⚠️", ) elif ever_jobs_platforms: st.html(f'
🌐 {len(ever_jobs_platforms)} platform{"s" if len(ever_jobs_platforms)!=1 else ""} selected
') else: st.caption("Select platforms where the agent should search.") else: ever_jobs_platforms = _all_plats_preview # ──────────────────────────────────────────────────────────────────── # STEP 6: AI Match Score # ──────────────────────────────────────────────────────────────────── if current_step == 6: st.html("""
6

AI match score threshold

Jobs scoring below this get a basic template resume. Jobs above get a fully AI-tailored ATS-optimized version.

""") sc1, sc2 = st.columns([2, 1]) with sc1: min_score = st.slider( "Minimum AI match score", 1, 10, _cfg("_cfg_min_score"), help="Set to 1 to generate LLM resumes for ALL jobs. Set higher for more focused tailoring.", key="min_score_slider", ) labels = {1: "Broad — all jobs", 4: "Balanced", 7: "Focused", 10: "Highly targeted"} nearest = min(labels.keys(), key=lambda k: abs(k - min_score)) st.caption(f"Mode: **{labels[nearest]}** — Score {min_score}/10") with sc2: est_jobs = max_jobs * max(1, len(ever_jobs_platforms)) est_time = max(2, est_jobs // 50) st.html(f"""
Estimated scan
~{min(est_jobs, 500)} jobs
~{est_time}–{est_time*2} minutes
""") else: min_score = _cfg("_cfg_min_score") # ──────────────────────────────────────────────────────────────────── # STEP 7: Google Sheet + Review & Launch # ──────────────────────────────────────────────────────────────────── if current_step == 7: gs_status_cls = "completed" if sheets_ok else "" gs_num_cls = "done" if sheets_ok else "" st.html(f"""
{"✓" if sheets_ok else "7"}

Application tracker

Save all discovered jobs into a Google Sheet for easy tracking and sharing.

""") if sheets_ok: st.html(f'
✅ {sheets_status}
') from config import GOOGLE as _G st.caption(f"Sheet: `{_G['sheet_id'][:20]}…` · Tab: `{_G['sheet_tab']}`") else: st.html(f"""
⚠ {sheets_status}. Results will be saved locally.
""") with st.expander("🔧 Advanced setup"): st.markdown(""" 1. Create a Google service account at [console.cloud.google.com](https://console.cloud.google.com) 2. Download the JSON credentials file 3. Save as `google_credentials.json` in the project root 4. Share your Google Sheet with the service account email Or run `python connect_google.py` for OAuth-based setup. """) # Review summary st.markdown("---") st.html(f"""

Review your search

Roles{', '.join(roles[:3])}{"…" if len(roles)>3 else ""}
Locations{', '.join(locations[:3])}{"…" if len(locations)>3 else ""}
Platforms{len(ever_jobs_platforms)} selected
Freshness{("Last " + str(int(round(days_posted*24))) + " hours") if days_posted < 1 else ("Last 1 day" if days_posted == 1 else "Last " + str(int(days_posted)) + " days")}
Max / platform{max_jobs}
AI match score{min_score}/10
Google Sheet{"✅ Connected" if sheets_ok else "⚠ Not connected"}
""") # ──────────────────────────────────────────────────────────────────── # NAVIGATION BUTTONS # ──────────────────────────────────────────────────────────────────── st.markdown("---") nav_l, nav_c, nav_r = st.columns([1, 2, 1]) with nav_l: if current_step > 1: st.markdown('
', unsafe_allow_html=True) if st.button(f"← Back", use_container_width=True, key="nav_back"): st.session_state.setup_step = current_step - 1 _save_prefs() st.rerun() st.markdown('
', unsafe_allow_html=True) with nav_c: step_label = SETUP_STEPS[current_step - 1]["label"] st.html(f"""
Step {current_step} of {len(SETUP_STEPS)} · {step_label}
""") with nav_r: if current_step < len(SETUP_STEPS): if st.button(f"Next →", use_container_width=True, type="primary", key="nav_next"): st.session_state.setup_step = current_step + 1 _save_prefs() st.rerun() # ──────────────────────────────────────────────────────────────────── # RIGHT SIDEBAR — Run Readiness Panel # ──────────────────────────────────────────────────────────────────── with col_sidebar: readiness = _readiness_score(has_resume, roles, locations, ever_jobs_platforms, sheets_ok, min_score) level_name, is_gold = _readiness_level(readiness) st.html(f"""

Run Readiness

{readiness}%

Setup completeness

{level_name}
""") # Checklist — clicking a step jumps to it checks = [ (1, has_resume, "Resume uploaded", "Upload resume"), (2, bool(roles), f"{len(roles)} role{'s' if len(roles)!=1 else ''} selected", "Select target roles"), (3, bool(locations), f"{len(locations)} location{'s' if len(locations)!=1 else ''} set", "Choose locations"), (5, bool(ever_jobs_platforms), f"{len(ever_jobs_platforms)} platform{'s' if len(ever_jobs_platforms)!=1 else ''} active", "Select platforms"), (7, sheets_ok, "Tracker connected", "Connect Google Sheet"), (6, min_score is not None, f"Match score: {min_score}/10", "Set match score"), ] checklist_html = "" for step_num, done, done_label, pending_label in checks: icon_cls = "check-done" if done else "check-pending" icon_txt = "✓" if done else "" label_cls = "done" if done else "pending" label = done_label if done else pending_label checklist_html += f"""
{icon_txt}
{label}
""" pending_count = sum(1 for _, d, _, _ in checks if not d) if pending_count > 0: cta_copy = f"Complete {pending_count} more step{'s' if pending_count!=1 else ''} to unlock a better search." else: cta_copy = "You're all set! Go to Step 7 to launch." st.html(f"""
{checklist_html}

{cta_copy}

""") # Achievement badges badges_html = "" badge_defs = [ (has_resume, "📄 Resume Ready"), (len(roles) >= 2, "🎯 Role Focused"), (len(ever_jobs_platforms) >= 5, "🌐 Platform Explorer"), (sheets_ok, "📊 Tracker Connected"), (readiness >= 100, "⚡ Power Search"), ] for earned, label in badge_defs: cls = "badge-earned" if earned else "badge-locked" badges_html += f'{label}' st.html(f"""

Achievements

{badges_html}
""") # Restart — wipe wizard state and return to step 1 st.markdown("---") if st.button("↺ Start over", use_container_width=True, help="Clear all settings and restart from step 1"): _reset_keys = [sk for _, sk, _ in _PREF_MAP] for _rk in _reset_keys: if _rk in st.session_state: del st.session_state[_rk] st.session_state["setup_step"] = 1 _save_prefs() st.rerun() # Start Search CTA — always visible in sidebar st.markdown("---") can_start = has_resume and bool(roles) and bool(locations) and bool(ever_jobs_platforms) start = st.button( "🚀 Start AI Job Search" if not st.session_state.running else "⏳ Running…", disabled=st.session_state.running or not can_start, use_container_width=True, type="primary", key="start_btn", ) if not can_start: missing = [] if not has_resume: missing.append("resume") if not roles: missing.append("roles") if not locations: missing.append("locations") if not ever_jobs_platforms: missing.append("platforms") st.caption(f"Missing: {', '.join(missing)}") else: roles = st.session_state.get("_last_roles", _cfg("_cfg_roles")) locations = st.session_state.get("_last_locations", _cfg("_cfg_locations")) days_posted = st.session_state.get("_last_days", _cfg("_cfg_days")) max_jobs = st.session_state.get("_last_max_jobs", _cfg("_cfg_max_jobs")) min_score = st.session_state.get("_last_min_score", _cfg("_cfg_min_score")) ever_jobs_platforms = st.session_state.get("_last_platforms", _cfg("_cfg_sb") + _cfg("_cfg_ats") + _cfg("_cfg_cp")) start = False # ══════════════════════════════════════════════════════════════════════════════ # START BUTTON (also shown at top of results for re-running) # ══════════════════════════════════════════════════════════════════════════════ if not show_config and not st.session_state.running and st.session_state.results: with st.columns([1, 2, 1])[1]: st.markdown('
', unsafe_allow_html=True) if st.button("🔄 New Search", use_container_width=True, key="new_search_btn"): st.session_state.results = None st.session_state.loaded_run = "" st.session_state.progress_pct = 0 st.session_state.steps = {} st.rerun() st.markdown('
', unsafe_allow_html=True) # Progress placeholders (always declared) progress_placeholder = st.empty() steps_placeholder = st.empty() log_placeholder = st.empty() done_placeholder = st.empty() # ══════════════════════════════════════════════════════════════════════════════ # LAUNCH PIPELINE # ══════════════════════════════════════════════════════════════════════════════ if show_config and start and not st.session_state.running: # Compile-on-demand: if the user pasted LaTeX but hasn't compiled yet, # compile it now (this is the moment we actually need a PDF). if not os.path.exists("data/resume/resume.pdf"): _saved_tex = st.session_state.get("_resume_tex", "") or "" if not _saved_tex and os.path.exists("data/resume/resume.tex"): try: with open("data/resume/resume.tex", "r", encoding="utf-8") as _lf: _saved_tex = _lf.read() except Exception: _saved_tex = "" if _saved_tex and _saved_tex.strip(): import tempfile as _tmpfile, shutil as _sh from src.latex_resume import compile_latex_to_pdf with st.spinner("Compiling your LaTeX resume… (first run can take 1–2 min while packages download)"): with _tmpfile.TemporaryDirectory() as _td: _res = compile_latex_to_pdf(_saved_tex, _td, jobname="resume", timeout=420) _pdf = _res.get("pdf_path") if _res.get("compiled") and _pdf and os.path.exists(_pdf): os.makedirs("data/resume", exist_ok=True) _sh.copy(_pdf, "data/resume/resume.pdf") try: from src.supabase_client import get_service_client, is_configured, get_owner_user_id if is_configured(): _uid = st.session_state.get("user_id") or get_owner_user_id() if _uid: with open("data/resume/resume.pdf", "rb") as _rf: get_service_client().storage.from_("resumes").upload( f"{_uid}/resume.pdf", _rf.read(), {"upsert": "true", "content-type": "application/pdf"}, ) except Exception: pass else: _log = (_res.get("log") or "")[-1500:] st.error( "LaTeX compilation failed when starting the job search.\n\n" "Go back to step 1, fix your LaTeX, and click Save LaTeX again.\n\n" f"--- Compiler log (last 1500 chars) ---\n{_log}" ) st.stop() if not os.path.exists("data/resume/resume.pdf"): st.error("Please upload your resume first.") elif not roles: st.error("Please select at least one target role.") elif not locations: st.error("Please select at least one location.") elif not ever_jobs_platforms: st.error("Please select at least one platform.") else: # Save config to session state for display during run st.session_state["_last_roles"] = roles st.session_state["_last_locations"] = locations st.session_state["_last_days"] = days_posted st.session_state["_last_max_jobs"] = max_jobs st.session_state["_last_min_score"] = min_score st.session_state["_last_platforms"] = ever_jobs_platforms st.session_state.running = True st.session_state.results = None st.session_state.loaded_run = "" st.session_state.log_msgs = [] st.session_state.completed_jobs = [] st.session_state.progress_pct = 0 st.session_state.progress_label = "Starting…" st.session_state.steps = {s["id"]: {"status": "pending", "detail": "", "elapsed": ""} for s in PIPELINE_STEPS} platforms_cfg = { "all_platforms": ever_jobs_platforms, } job_search_cfg = { "roles": roles, "locations": locations, "days_posted": days_posted, "max_jobs_per_platform": max_jobs, } output_cfg = { "excel_path": "data/output/reports/job_report.xlsx", "resumes_dir": "data/output/resumes/", } # ── Pipeline thread ── def run_pipeline(_min_score=min_score, _platforms=platforms_cfg, _jscfg=job_search_cfg, _ocfg=output_cfg, _q=_progress_q): import time as _t, traceback as _tb _progress_q = _q def _q_log(msg): _q.put(("log", msg)) def _q_progress(pct, lbl=""): _q.put(("progress", pct, lbl)) try: import sys as _sys _sys.stdout.reconfigure(encoding="utf-8", errors="replace") _sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass from datetime import datetime as _dt run_id = _dt.now().strftime("%Y-%m-%d_%H-%M-%S") log_path = app_logger.setup(run_id) log = logging.getLogger("pipeline") log.info("=" * 60) log.info(f"Pipeline start run_id={run_id}") log.info(f"Platforms: {_platforms}") log.info(f"Roles: {_jscfg.get('roles')}") log.info(f"Locations: {_jscfg.get('locations')}") _q_log(f"📝 Log: {log_path}") _q.put(("logfile", log_path)) def _step_start(sid, detail=""): _q.put(("step", sid, "active", detail, "")) log.info(f"[START] {_STEP_TITLE_MAP.get(sid,sid)}: {detail}") _q_log(f"⏳ {_STEP_TITLE_MAP.get(sid,sid)}: {detail}") def _step_done(sid, detail="", t0=None): elapsed = f"{_t.time()-t0:.1f}s" if t0 else "" _q.put(("step", sid, "done", detail, elapsed)) log.info(f"[DONE] {_STEP_TITLE_MAP.get(sid,sid)}: {detail} ({elapsed})") def _step_err(sid, detail=""): _q.put(("step", sid, "error", detail, "")) log.error(f"[ERR] {_STEP_TITLE_MAP.get(sid,sid)}: {detail}") _q_log(f"❌ {sid}: {detail}") def _step_skip(sid): _q.put(("step", sid, "skip", "Disabled", "")) log.info(f"[SKIP] {sid}") try: from src.resume_parser import ResumeParser from src.llm_client import LLMClient from src.model_pool import ModelPool from src.job_assessor import JobAssessor from src.resume_customizer import ResumeCustomizer from src.excel_reporter import ExcelReporter from config import ASSESSMENT_MODELS from src.job_history import is_duplicate, bulk_mark_seen # ── Step 1: Parse resume ── t0 = _t.time() _step_start("resume", "Reading PDF…") _q_progress(4, "Parsing resume…") parser = ResumeParser("data/resume/resume.pdf") resume_text = parser.parse() _step_done("resume", f"{len(resume_text):,} chars", t0) _q_log(f"✅ Resume parsed — {len(resume_text):,} chars") # ── Step 2: Profile ── t0 = _t.time() fast_cfg = next( (m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key") and m["name"] in ("Kimi-K2.6", "Step-3.7-Flash", "Qwen3.5-397b")), None, ) model_label = fast_cfg["name"] if fast_cfg else "GLM-5.1" _step_start("profile", f"Using {model_label}…") _q_progress(8, f"Building profile with {model_label}…") llm = LLMClient() if fast_cfg: profile_json = llm.extract_profile_summary_fast(fast_cfg, resume_text) else: profile_json = llm.extract_profile_summary(resume_text) compact_profile = llm.build_compact_profile(profile_json) try: pd_data = json.loads(profile_json) name = pd_data.get("name", "") role_c = pd_data.get("current_role", "") yrs = pd_data.get("total_experience_years", "") skills = ", ".join(pd_data.get("core_skills", [])[:5]) _step_done("profile", f"{name} · {role_c} · {yrs} yrs", t0) _q_log(f"✅ Profile: {name} | {role_c} | {yrs} yrs") _q_log(f" Skills: {skills}") except Exception: _step_done("profile", "Profile extracted", t0) _q_log("✅ Profile extracted") # ── Step 3+: Scraping ── _q_progress(12, "Scraping job boards…") all_jobs: list = [] seen_urls: set = set() seen_tc: set = set() skipped_dup: int = 0 _all_plats = set(_platforms.get("all_platforms", [])) _legacy_keys = {"linkedin", "indeed", "glassdoor", "remotive", "weworkremotely", "naukri", "company_ats"} scraper_map = {} if "linkedin" in _all_plats: from src.scrapers.linkedin import LinkedInScraper scraper_map["linkedin"] = ("LinkedIn", LinkedInScraper()) else: _step_skip("linkedin") if "indeed" in _all_plats: from src.scrapers.indeed import IndeedScraper scraper_map["indeed"] = ("Indeed", IndeedScraper()) else: _step_skip("indeed") if "glassdoor" in _all_plats: from src.scrapers.glassdoor import GlassdoorScraper scraper_map["glassdoor"] = ("Glassdoor", GlassdoorScraper()) else: _step_skip("glassdoor") if "remotive" in _all_plats: from src.scrapers.remotive import RemotiveScraper scraper_map["remotive"] = ("Remotive", RemotiveScraper()) else: _step_skip("remotive") if "weworkremotely" in _all_plats: from src.scrapers.weworkremotely import WeWorkRemotelyScraper scraper_map["weworkremotely"] = ("WeWorkRemotely", WeWorkRemotelyScraper()) else: _step_skip("weworkremotely") if "naukri" in _all_plats: from src.scrapers.naukri import NaukriScraper scraper_map["naukri"] = ("Naukri", NaukriScraper()) else: _step_skip("naukri") # Direct-company ATS boards (Greenhouse/Lever/Ashby). Public JSON # APIs that rarely IP-block — the most reliable bulk source on HF. if "company_ats" in _all_plats: from src.scrapers.company_ats import CompanyATSScraper scraper_map["company_ats"] = ("CompanyATS", CompanyATSScraper()) _q_log("🏢 Direct Company ATS enabled (Greenhouse/Lever/Ashby — no IP blocks)") else: _step_skip("company_ats") _ej_platforms = [p for p in _all_plats if p not in _legacy_keys] if _ej_platforms: # The 160+ "ever-jobs" platforms need the NestJS sidecar on # :3001. If it isn't up, be LOUD about it (it silently # returned 0 before, so users saw only LinkedIn results). from src.scrapers.ever_jobs import EverJobsScraper from src.ever_jobs_bridge.server import is_running as _ej_health if _ej_health(): scraper_map["ever_jobs"] = ("EverJobs", EverJobsScraper(_ej_platforms)) _q_log(f"🌐 ever-jobs sidecar UP — {len(_ej_platforms)} extra platforms enabled") else: import os as _os _hosted = bool(_os.getenv("EVER_JOBS_API_URL")) if _hosted: _q_log(f"⚠ ever-jobs sidecar unreachable at {_os.getenv('EVER_JOBS_API_URL')} " f"— {len(_ej_platforms)} extra platforms SKIPPED. Check the hosted " f"sidecar is running. Direct scrapers (LinkedIn etc.) still run.") else: _q_log(f"⚠ ever-jobs sidecar DOWN — {len(_ej_platforms)} extra platforms " f"(Greenhouse/Lever/Google/Foundit/etc.) SKIPPED. These need the " f"Node sidecar, which can't run inside HF Spaces. To enable them, host " f"ever-jobs elsewhere and set the EVER_JOBS_API_URL secret. " f"Direct scrapers (LinkedIn/Indeed/Glassdoor/Remotive/WWR) still run.") _step_skip("ever_jobs") else: _step_skip("ever_jobs") scrape_pct = 12 pct_per_plat = 35 / max(1, len(scraper_map)) MAX_PER_PLAT = _jscfg["max_jobs_per_platform"] _days_posted = _jscfg.get("days_posted", 7) _sel_locs = _jscfg.get("locations", []) from src.geo_filter import location_allowed # Thread the freshness window into every scraper that supports it. for _pid, (_pn, _sc) in scraper_map.items(): try: _sc._days_posted = _days_posted except Exception: pass _geo_dropped = 0 for plat_id, (pname, scraper) in scraper_map.items(): t0 = _t.time() n_roles = len(_jscfg["roles"]) n_locs = len(_jscfg["locations"]) _step_start(plat_id, f"Searching {n_roles} roles × {n_locs} locations…") platform_jobs: list = [] outer_done = False for ri, role_q in enumerate(_jscfg["roles"]): if outer_done: break for loc in _jscfg["locations"]: if len(platform_jobs) >= MAX_PER_PLAT: outer_done = True break per_query = max(5, MAX_PER_PLAT - len(platform_jobs)) try: log.info(f"Scraping {pname}: role={role_q!r} loc={loc!r}") raw = scraper.search(role_q, loc, max_results=per_query) log.info(f" → {len(raw)} raw results") for j in raw: if len(platform_jobs) >= MAX_PER_PLAT: break if not j.url or j.url in seen_urls: continue tc_key = (j.title.lower().strip(), j.company.lower().strip()) if tc_key in seen_tc: skipped_dup += 1 continue if not scraper.is_pm_role(j.title): continue if not location_allowed(j.location, _sel_locs): _geo_dropped += 1 continue if is_duplicate(j.url, days=30): skipped_dup += 1 continue seen_urls.add(j.url) seen_tc.add(tc_key) platform_jobs.append(j) except Exception as e: full_tb = _tb.format_exc() log.error(f"{pname} error ({role_q}/{loc}): {e}\n{full_tb}") _q_log(f"⚠ {pname} ({role_q}): {str(e)[:80]}") _t.sleep(0.5) _q.put(("step", plat_id, "active", f"Role {ri+1}/{n_roles} — {len(platform_jobs)} jobs so far", "")) needs_desc = [j for j in platform_jobs if not j.description] if needs_desc and hasattr(scraper, "get_details_bulk"): _q.put(("step", plat_id, "active", f"Fetching {len(needs_desc)} descriptions…", "")) def _dcb(done, tot, _pid=plat_id): _q.put(("step", _pid, "active", f"Descriptions: {done}/{tot}", "")) try: scraper.get_details_bulk(platform_jobs, progress_cb=_dcb) except Exception as e: log.error(f"{pname} bulk details error: {e}\n{_tb.format_exc()}") n_desc = sum(1 for j in platform_jobs if j.description) all_jobs.extend(platform_jobs) scrape_pct += pct_per_plat _q_progress(int(scrape_pct), f"{pname}: {len(platform_jobs)} jobs") _step_done(plat_id, f"{len(platform_jobs)} PM jobs · {n_desc} with JD", t0) _q_log(f"✅ {pname}: {len(platform_jobs)} jobs ({n_desc} with JD) " f"| {skipped_dup} dupes skipped") if _geo_dropped: _q_log(f"📍 Filtered out {_geo_dropped} job(s) outside your selected " f"locations ({', '.join(_sel_locs[:4])}{'…' if len(_sel_locs) > 4 else ''}).") _q_log(f"✅ Total unique jobs: {len(all_jobs)}") if not all_jobs: _q_log("❌ No jobs found. Check internet or platform settings.") _q.put(("error", "No jobs found")) return # ── Assess ── t0 = _t.time() _step_start("assess", f"Scoring {len(all_jobs)} jobs…") _q_progress(50, f"AI assessing {len(all_jobs)} jobs…") _q_log(f"🤖 AI assessing {len(all_jobs)} jobs…") model_pool = ModelPool(ASSESSMENT_MODELS) assessor = JobAssessor(model_pool, compact_profile) assessed_jobs = assessor.assess_all(all_jobs) bulk_mark_seen(all_jobs) high = sum(1 for j in assessed_jobs if j.get("relevance_score", 0) >= 8) med = sum(1 for j in assessed_jobs if 6 <= j.get("relevance_score", 0) <= 7) low = sum(1 for j in assessed_jobs if j.get("relevance_score", 0) < 6) _step_done("assess", f"🔴 {high} 🟡 {med} ⚪ {low}", t0) _q_progress(78, "Assessment complete!") _q_log(f"✅ Assessment done — High: {high}, Good: {med}, Low: {low}") # ── Generate resumes ── t0 = _t.time() llm_elig = sum(1 for j in assessed_jobs if j.get("relevance_score",0) >= _min_score) phase2_cfgs = [m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key")] _step_start("resumes", f"Tailoring {llm_elig} LLM + {len(assessed_jobs)-llm_elig} template…") _q_progress(80, "Generating ATS-optimized resumes…") _q_log(f"📝 {llm_elig} LLM resumes (score≥{_min_score}) + {len(assessed_jobs)-llm_elig} templates") def _resume_cb(done, tot, msg, job=None): pct = 80 + int(14 * done / max(1, tot)) _q_progress(pct, f"Resumes: {done}/{tot}") _q.put(("step", "resumes", "active", f"{done}/{tot} — {msg[:70]}", "")) _q_log(f" {msg[:100]}") # Push the completed job so the UI shows it immediately with a # download button (apply while the rest keep generating). if job is not None: _q.put(("job_done", { "title": job.get("title", ""), "company": job.get("company", ""), "location": job.get("location", ""), "relevance_score": job.get("relevance_score", 0), "ats_score_before": job.get("ats_score_before", 0), "ats_score_after": job.get("ats_score_after", 0), "resume_path": job.get("resume_path", ""), "job_url": job.get("job_url", job.get("url", "")), # v2 status pipeline (anti-circular scores + gating) "status": job.get("status", ""), "quality_flag": job.get("quality_flag", ""), "jd_match": job.get("jd_match", 0), "independent_jd_match": job.get("independent_jd_match", 0), "ats_readability": job.get("ats_readability", 0), "download_allowed": job.get("download_allowed", False), "review_terms": job.get("review_terms", []), })) # ── V2 bulk: sentence-integration pipeline per job ────────── _bulk_version = st.session_state.get("_gen_version", "v1") _v2_bulk_done = False if _bulk_version == "v2": _q_log("📝 V2 mode: generating sentence-based resumes per job…") try: from src.default_resume import get_default_resume_latex from src.resume_v2_natural import generate_v2 _v2_latex = get_default_resume_latex() _v2_count = 0 for _j in assessed_jobs: if not _j.get("jd_text"): continue try: _v2_dir = os.path.join(_ocfg["resumes_dir"], f"v2_{_v2_count}") os.makedirs(_v2_dir, exist_ok=True) _v2r = generate_v2( _v2_latex, _j["jd_text"], job_title=_j.get("title", ""), company=_j.get("company", ""), out_dir=_v2_dir, compile_pdf=True, ) _j["resume_path"] = _v2r.get("pdf_path") or "" _j["ats_score"] = _v2r.get("pct", 0) _v2_count += 1 _q_log(f" V2 #{_v2_count}: {_j.get('title', '')} — {_v2r.get('pct', 0)}%") except Exception as _v2e: _q_log(f" V2 failed for {_j.get('title', '')}: {_v2e}") _q_log(f"✅ V2 generated {_v2_count} resumes") _v2_bulk_done = True except Exception as _v2_exc: _q_log(f"⚠️ V2 bulk failed, falling back to V1: {_v2_exc}") if not _v2_bulk_done: customizer = ResumeCustomizer(llm, resume_text, _ocfg["resumes_dir"], fast_model_cfg=fast_cfg) assessed_jobs = customizer.customize_for_jobs( assessed_jobs, min_score_for_llm=_min_score, max_llm_resumes=len(assessed_jobs), generate_all=True, model_cfgs=phase2_cfgs, progress_cb=_resume_cb, ) llm_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "LLM Tailored") tmpl_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "Template") pdf_done = sum(1 for j in assessed_jobs if j.get("resume_pdf_path")) ats_vals = [j.get("ats_score_after") for j in assessed_jobs if j.get("ats_score_after")] avg_ats = f" · avg ATS: {sum(ats_vals)//len(ats_vals)}%" if ats_vals else "" _step_done("resumes", f"{llm_done} LLM + {tmpl_done} template · {pdf_done} PDFs{avg_ats}", t0) _q_progress(95, "Resumes ready!") _q_log(f"✅ {llm_done} LLM resumes + {tmpl_done} templates · {pdf_done} PDFs{avg_ats}") # ── Early history checkpoint ── # Save resumes + scores to history NOW (before the slower # Sheets/Excel steps) so a page refresh can't lose the work. try: from src.run_history import save_run as _save_early _save_early(assessed_jobs, { "run_id": run_id, "excel_path": "", "platforms": list(_platforms.get("all_platforms", [])), "roles": _jscfg.get("roles", []), }) _q_log("✅ Checkpoint saved to history (resumes safe)") except Exception as _ce: log.warning(f"Early history checkpoint failed: {_ce}") # ── Report ── t0 = _t.time() _step_start("report", "Writing Excel + Google Sheet…") _q_progress(97, "Saving report…") reporter = ExcelReporter(_ocfg["excel_path"]) excel_path = reporter.generate(assessed_jobs) sheet_msg = "" try: from src.gsheets import write_jobs_to_sheet from config import GOOGLE ok = write_jobs_to_sheet( assessed_jobs, sheet_id=GOOGLE["sheet_id"], tab_name=GOOGLE["sheet_tab"], batch_label=_dt.now().strftime("%Y-%m-%d %H:%M"), ) if ok: sheet_url = f"https://docs.google.com/spreadsheets/d/{GOOGLE['sheet_id']}/edit" _q_log(f"✅ Google Sheet updated — {sheet_url}") sheet_msg = f" · [Sheet]({sheet_url})" else: _q_log("⚠ Google Sheet: write returned False — check logs for details") except FileNotFoundError as fe: _q_log(f"⚠ Google Sheet skipped: credentials not found. " f"Run setup_google.py to configure.") log.warning(f"Sheet auth missing: {fe}") except Exception as e: _q_log(f"⚠ Google Sheet error: {str(e)[:120]}") log.error(f"Sheet write failed: {e}\n{_tb.format_exc()}") _step_done("report", f"Excel ready{sheet_msg}", t0) _q_progress(100, "All done! ✅") _q_log(f"✅ Excel: {excel_path}") _q_log(f"✅ Resumes folder: {_ocfg['resumes_dir']}") try: from src.run_history import save_run save_run(assessed_jobs, { "run_id": run_id, "excel_path": excel_path, "platforms": list(_platforms.get("all_platforms", [])), "roles": _jscfg.get("roles", []), }) _q_log("✅ Run saved to history") except Exception as e: log.warning(f"History save failed: {e}") _q.put(("done", assessed_jobs, excel_path, run_id)) except Exception as e: full_tb = _tb.format_exc() log.error(f"Pipeline FATAL: {e}\n{full_tb}") _q_log(f"❌ Pipeline error: {e}") for chunk in [full_tb[i:i+150] for i in range(max(0, len(full_tb)-600), len(full_tb), 150)]: _q_log(chunk) _q.put(("error", str(e))) thread = threading.Thread(target=run_pipeline, daemon=True) thread.start() st.rerun() # ── Drain progress queue ───────────────────────────────────────────────────── if st.session_state.running: while True: try: item = _progress_q.get_nowait() kind = item[0] if kind == "log": st.session_state.log_msgs.append(item[1]) elif kind == "logfile": st.session_state.current_log_file = item[1] elif kind == "progress": st.session_state.progress_pct = item[1] st.session_state.progress_label = item[2] if len(item) > 2 else "" elif kind == "step": _, sid, status, detail, elapsed = item if not isinstance(st.session_state.steps, dict): st.session_state.steps = {} st.session_state.steps[sid] = {"status": status, "detail": detail, "elapsed": elapsed} elif kind == "job_done": if not isinstance(st.session_state.completed_jobs, list): st.session_state.completed_jobs = [] st.session_state.completed_jobs.append(item[1]) elif kind == "done": st.session_state.results = item[1] st.session_state.excel_path = item[2] if len(item) > 2 else "" st.session_state.loaded_run = item[3] if len(item) > 3 else "" st.session_state.running = False break elif kind == "error": st.session_state.running = False break except queue.Empty: break # ── Render progress ────────────────────────────────────────────────────────── if st.session_state.running or (st.session_state.progress_pct and st.session_state.results is None): pct = st.session_state.progress_pct lbl = st.session_state.progress_label progress_placeholder.progress(pct / 100, text=f"**{pct}%** — {lbl}" if lbl else f"**{pct}%**") steps_state = st.session_state.get("steps", {}) if isinstance(steps_state, dict) and steps_state: steps_placeholder.html(_render_steps(steps_state)) if st.session_state.log_msgs: log_placeholder.html( '

Live Log

' + _render_log(st.session_state.log_msgs) ) # ── Live "Ready to Apply" — show each resume as it completes ── # Lets the user start applying while the rest keep generating. _done_jobs = st.session_state.get("completed_jobs", []) if _done_jobs: # Status tiers (spec: READY first, then REVIEW, then NEEDS_INPUT, LOW_FIT last) _TIER = { "READY_90_PLUS": (0, "✅ Ready (90%+)", "#16A34A"), "READY_90_PLUS_REVIEW_RECOMMENDED": (1, "🟡 Ready · review added skills", "#CA8A04"), "NEEDS_REPAIR": (2, "🔧 Below 90 — needs work", "#DC2626"), "NEEDS_USER_INPUT": (3, "❓ Needs your input", "#9333EA"), "NOT_ELIGIBLE_LOW_FIT": (4, "⚪ Low fit", "#64748B"), "PARSE_FAILED": (5, "⚠ Export problem", "#DC2626"), } _DOWNLOADABLE = {"READY_90_PLUS", "READY_90_PLUS_REVIEW_RECOMMENDED"} def _tier_key(j): return _TIER.get(j.get("status", ""), (2, "", "#64748B"))[0] _ready = sum(1 for j in _done_jobs if j.get("status") in _DOWNLOADABLE) st.markdown(f"#### ✅ Ready to apply now — {_ready}/{len(_done_jobs)} at 90%+") st.caption("Sorted by readiness. Only 90%+ resumes are downloadable; " "review-recommended ones list the added skills to check first.") for _i, _j in enumerate(sorted(_done_jobs, key=_tier_key)): _rp = _j.get("resume_path", "") _status = _j.get("status", "") _order, _label, _color = _TIER.get(_status, (2, _status or "—", "#64748B")) _jd = _j.get("jd_match", _j.get("ats_score_after", 0)) _rd = _j.get("ats_readability", 0) _can_dl = _status in _DOWNLOADABLE c1, c2, c3 = st.columns([5, 2, 2]) with c1: _scoreline = (f"JD match {_jd}% · ATS readability {_rd}%" if _rd else f"ATS {_j.get('ats_score_after',0)}%") st.markdown( f"**{_j.get('title','')}** · {_j.get('company','')} \n" f"{_label}" f" · {_scoreline}", unsafe_allow_html=True, ) _rev = _j.get("review_terms", []) if _status == "READY_90_PLUS_REVIEW_RECOMMENDED" and _rev: st.caption("⚠ Review these added skills before applying: " + ", ".join(dict.fromkeys(_rev))[:200]) with c2: if _can_dl and _rp and os.path.exists(_rp): with open(_rp, "rb") as _f: st.download_button( "⬇ DOCX", _f.read(), os.path.basename(_rp), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", key=f"live_dl_{_i}_{_j.get('company','')[:10]}", use_container_width=True, ) elif not _can_dl: st.caption("not 90%+ yet") with c3: _url = _j.get("job_url", "") if _url: st.link_button("Apply ↗", _url, use_container_width=True) # ── Done banner ────────────────────────────────────────────────────────────── if not st.session_state.running and st.session_state.results: n = len(st.session_state.results) high = sum(1 for j in st.session_state.results if j.get("relevance_score", 0) >= 8) pdfs = sum(1 for j in st.session_state.results if j.get("resume_pdf_path")) hist_note = f" · Saved to history" if st.session_state.loaded_run else "" done_placeholder.success( f"✅ **Done!** Found **{n} jobs** · **{high} high priority** · **{pdfs} PDFs ready**{hist_note}" ) # ── Batch summary table (status + internal/independent scores per job) ── _have_status = any(j.get("status") for j in st.session_state.results) if _have_status: _ORDER = {"READY_90_PLUS": 0, "READY_90_PLUS_REVIEW_RECOMMENDED": 1, "NEEDS_USER_INPUT": 2, "NEEDS_REPAIR": 3, "NOT_ELIGIBLE_LOW_FIT": 4, "PARSE_FAILED": 5} def _risk_level(j): q = j.get("quality_flag", "") if j.get("status") == "NEEDS_USER_INPUT": return "HIGH" if q == "REVIEW_REQUIRED_90_PLUS" or j.get("review_terms"): return "MEDIUM" if q in ("WEAK_90_INTERNAL_ONLY",): return "HIGH" return "LOW" _rows = [] for j in sorted(st.session_state.results, key=lambda x: _ORDER.get(x.get("status", ""), 3)): _nrev = len(j.get("review_terms", []) or []) + \ len(j.get("_v2_report", {}).get("high_risk_terms_for_confirmation", []) or []) \ if isinstance(j.get("_v2_report"), dict) else len(j.get("review_terms", []) or []) _prov = j.get("provider_used", "") if not _prov and isinstance(j.get("_v2_report"), dict): _prov = j["_v2_report"].get("provider_used", "") _rows.append({ "Job Title": (j.get("title", "") or "")[:40], "Company": (j.get("company", "") or "")[:24], "Platform": j.get("platform", j.get("source", "")), "Provider": _prov or "—", "Status": j.get("status", ""), "Quality": j.get("quality_flag", ""), "Internal": j.get("jd_match", j.get("ats_score_after", "")), "Independent": j.get("independent_jd_match", ""), "Readability": j.get("ats_readability", ""), "Risk": _risk_level(j), "Review Terms": _nrev, "Download": "✅" if j.get("download_allowed") else "—", }) with st.expander(f"📋 Batch summary — {len(_rows)} jobs (ranked by readiness)", expanded=True): _ready = sum(1 for r in _rows if r["Download"] == "✅") st.caption(f"{_ready}/{len(_rows)} ready to apply (both internal & " f"independent ≥ 90). Independent = anti-circular evidence-based score. " f"Risk HIGH = needs your confirmation before applying.") try: import pandas as _pd st.dataframe(_pd.DataFrame(_rows), use_container_width=True, hide_index=True) except Exception: st.table(_rows) # ── Bulk vault controls (confirm/block terms across all future jobs) ── with st.expander("🔐 Manage skills vault (confirm or block terms)", expanded=False): st.caption("Confirmed terms are treated as fully safe in every future " "resume; blocked terms are never used. Only YOUR confirmation " "upgrades a term to safe — the system never auto-promotes.") vc1, vc2 = st.columns(2) with vc1: _confirm_in = st.text_input( "✅ Confirm I have these (comma-separated)", key="vault_confirm_in", placeholder="e.g. SIEM, SOAR, Tableau") if st.button("Confirm to vault", key="vault_confirm_btn") and _confirm_in.strip(): try: from src.candidate_vault import confirm_terms confirm_terms([t.strip() for t in _confirm_in.split(",") if t.strip()], confirmed=True) st.success("Confirmed — these are now safe for future resumes.") except Exception as _e: st.error(f"Could not update vault: {_e}") with vc2: _block_in = st.text_input( "🚫 Never use these (comma-separated)", key="vault_block_in", placeholder="e.g. Kubernetes, CISSP") if st.button("Block in vault", key="vault_block_btn") and _block_in.strip(): try: from src.candidate_vault import confirm_terms confirm_terms([t.strip() for t in _block_in.split(",") if t.strip()], confirmed=False) st.success("Blocked — these will never be added to resumes.") except Exception as _e: st.error(f"Could not update vault: {_e}") # ── Validation packages (spec #9) ── with st.expander("📦 Export validation packages (for manual Jobalytics testing)", expanded=False): st.caption("Bundles each resume with its parsed text, the JD, keyword lists, " "medium/high/blocked risk terms, all scores, and the provider used — " "so you can verify our numbers against a real external checker.") if st.button("Export packages for these jobs", key="valpkg_btn"): try: from src.validation_package import build_packages_for_jobs _jwr = [j for j in st.session_state.results if j.get("resume_path")] with st.spinner(f"Building {len(_jwr)} packages…"): _paths = build_packages_for_jobs(_jwr) st.success(f"Exported {len(_paths)} packages to data/output/validation/") for _p in _paths[:25]: st.caption(f"• {_p}") except Exception as _e: st.error(f"Package export failed: {_e}") # ── Jobalytics paste → regenerate (spec #8) ── with st.expander("🩹 Jobalytics repair — paste missing keywords & regenerate", expanded=False): st.caption("Paste the 'missing keywords' a real checker (Jobalytics / Simplify) " "reported. We classify each (already-present · add to skills · weave " "into experience · medium-review · high-risk · blocked), regenerate " "honestly, and show before/after coverage. Risky terms need your " "confirmation; blocked terms are never faked.") _job_opts = [f"{i}: {j.get('title','')[:30]} · {j.get('company','')[:20]}" for i, j in enumerate(st.session_state.results) if j.get("resume_path")] if _job_opts: _sel = st.selectbox("Job to repair", _job_opts, key="jobalytics_job") _kw_in = st.text_area("Missing keywords (comma or newline separated)", key="jobalytics_kw", placeholder="e.g. product strategy, A/B testing, SQL, roadmap") if st.button("Classify & regenerate", key="jobalytics_btn") and _kw_in.strip(): _idx = int(_sel.split(":", 1)[0]) _job = st.session_state.results[_idx] _kws = [k.strip() for k in _kw_in.replace("\n", ",").split(",") if k.strip()] try: from src.jobalytics_repair import regenerate_from_jobalytics with st.spinner("Classifying + regenerating (jobalytics_repair mode)…"): _jr = regenerate_from_jobalytics(_job, _kws) if _jr.get("error"): st.error(_jr["error"]) else: _sc = _jr["scores"] st.success(f"Status: {_jr['status']} · provider: {_jr.get('provider_used','')}") _m1, _m2, _m3 = st.columns(3) _m1.metric("Internal", _sc["internal_jd_match"]) _m2.metric("Independent", _sc["independent_jd_match"]) _m3.metric("Readability", _sc["ats_readability"]) st.caption(f"Pasted-keyword coverage: " f"{_jr['before_coverage']['pct']}% → {_jr['after_coverage']['pct']}%") try: import pandas as _pd st.dataframe(_pd.DataFrame(_jr["classifications"]), use_container_width=True, hide_index=True) except Exception: st.table(_jr["classifications"]) _np = _jr.get("resume_path", "") if _jr.get("download_allowed") and _np and os.path.exists(_np): with open(_np, "rb") as _f: st.download_button("⬇ Download repaired DOCX", _f.read(), os.path.basename(_np), key="jobalytics_dl") elif _np: st.caption("Regenerated, but not 90%+ on both scores — not downloadable yet.") except Exception as _e: st.error(f"Jobalytics repair failed: {_e}") else: st.caption("Run the pipeline first so there are resumes to repair.") if st.session_state.running: time.sleep(0.8) st.rerun() # ══════════════════════════════════════════════════════════════════════════════ # RESULTS TABS # ══════════════════════════════════════════════════════════════════════════════ results = st.session_state.results if results is not None: n_res = len(results) tab_results, tab_details, tab_research, tab_logs = st.tabs([ f"📊 Results ({n_res})", "📄 Job Details", "🔬 Deep Research", "📋 Logs" ]) # ══════════════════════════════════════════════════════════════════════════ # TAB 1 — RESULTS # ══════════════════════════════════════════════════════════════════════════ with tab_results: st.html(_metrics_html(results)) dl1, dl2, dl3 = st.columns(3) with dl1: xp = st.session_state.excel_path if xp and os.path.exists(xp): with open(xp, "rb") as f: st.download_button("⬇ Download Excel Report", f.read(), "job_report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True) csv_p = (os.path.splitext(xp)[0] + ".csv") if xp else "" if csv_p and os.path.exists(csv_p): with open(csv_p, "rb") as f: st.download_button("⬇ Download CSV", f.read(), "job_report.csv", "text/csv", use_container_width=True) with dl2: resume_base = Path("data/output/resumes") if st.session_state.loaded_run: first_res = next((j.get("resume_path","") for j in results if j.get("resume_path")), "") run_folder = Path(first_res).parent if first_res else None else: date_dirs = sorted( [d for d in resume_base.iterdir() if d.is_dir()], key=lambda d: d.stat().st_mtime, reverse=True, ) if resume_base.exists() else [] run_folder = date_dirs[0] if date_dirs else None if run_folder and run_folder.exists(): docx_f = list(run_folder.glob("*.docx")) pdf_f = list(run_folder.glob("*.pdf")) all_rf = docx_f + pdf_f if all_rf: import io, zipfile zbuf = io.BytesIO() with zipfile.ZipFile(zbuf, "w") as zf: for fp in all_rf: zf.write(str(fp), fp.name) zbuf.seek(0) n_pdf = len(pdf_f) n_docx = len(docx_f) st.download_button( f"⬇ Resumes ({n_docx} DOCX + {n_pdf} PDF)", zbuf.read(), "tailored_resumes.zip", "application/zip", use_container_width=True, ) st.caption(f"📁 {run_folder}") with dl3: if sheets_ok: from config import GOOGLE as _GC sheet_url = f"https://docs.google.com/spreadsheets/d/{_GC['sheet_id']}/edit" st.link_button("📊 Open Google Sheet", sheet_url, use_container_width=True) st.divider() col_f1, col_f2, col_f3 = st.columns(3) with col_f1: f_score = st.slider("Min score", 1, 10, 1, key="f_score") with col_f2: jobs_df = pd.DataFrame(results) f_plat = st.multiselect("Platform", options=jobs_df["platform"].unique().tolist() if "platform" in jobs_df else [], key="f_plat") with col_f3: f_pri = st.multiselect("Priority", ["High", "Medium", "Low"], key="f_pri") view_mode = st.radio("View", ["📋 Cards (top 10)", "📊 Full Table"], horizontal=True, key="view_mode") filtered = [j for j in results if j.get("relevance_score", 0) >= f_score] if f_plat: filtered = [j for j in filtered if j.get("platform") in f_plat] if f_pri: filtered = [j for j in filtered if j.get("application_priority") in f_pri] st.caption(f"Showing **{len(filtered)}** of **{n_res}** jobs") if view_mode.startswith("📋"): for rank, job in enumerate(filtered[:10], 1): st.html(_job_card_html(job, rank)) _render_card_actions(job, key=f"card{rank}") if len(filtered) > 10: st.caption(f"… and {len(filtered)-10} more. Switch to Table view to see all.") else: if not filtered: st.info("No jobs match the current filters.") else: disp_cols = [ "relevance_score", "title", "company", "location", "platform", "salary", "application_priority", "ats_score_before", "ats_score_after", "ats_improvement", "resume_generated", "matching_skills", "recommendation", ] fdf = pd.DataFrame(filtered) avail = [c for c in disp_cols if c in fdf.columns] fdf = fdf[avail].copy() fdf.insert(0, "#", range(1, len(fdf)+1)) def _fmt_score(s): e = "🔴" if s >= 8 else ("🟡" if s >= 6 else "⚪") return f"{e} {s}/10" if "relevance_score" in fdf.columns: fdf["relevance_score"] = fdf["relevance_score"].apply(_fmt_score) for c in ("ats_score_before", "ats_score_after"): if c in fdf.columns: fdf[c] = fdf[c].apply(lambda x: f"{x}%" if x not in ("", None) else "—") if "ats_improvement" in fdf.columns: fdf["ats_improvement"] = fdf["ats_improvement"].apply( lambda x: f"+{x}pp" if x and x > 0 else ("—" if not x else f"{x}pp") ) fdf.rename(columns={ "relevance_score": "Score", "title": "Job Title", "company": "Company", "location": "Location", "platform": "Platform", "salary": "Salary", "application_priority":"Priority", "ats_score_before": "ATS Before", "ats_score_after": "ATS After", "ats_improvement": "ATS Gain", "resume_generated": "Resume", "matching_skills": "Matching Skills", "recommendation": "Notes", }, inplace=True) st.dataframe(fdf, use_container_width=True, height=520, hide_index=True, column_config={ "#": st.column_config.NumberColumn("#", width="small"), "Score": st.column_config.TextColumn("Score", width="small"), "Job Title": st.column_config.TextColumn("Job Title", width="large"), "ATS Before": st.column_config.TextColumn("ATS Before", width="small"), "ATS After": st.column_config.TextColumn("ATS After", width="small"), "ATS Gain": st.column_config.TextColumn("ATS Gain", width="small"), "Notes": st.column_config.TextColumn("Notes", width="large"), }) # ══════════════════════════════════════════════════════════════════════════ # TAB 2 — JOB DETAILS # ══════════════════════════════════════════════════════════════════════════ with tab_details: top_jobs = [j for j in results if j.get("relevance_score", 0) >= 6] if not top_jobs: st.warning("No jobs scored 6 or above. Lower the minimum score filter.") else: job_options = { f"{_score_emoji(j['relevance_score'])} {j['relevance_score']}/10 — " f"{j['title']} @ {j['company']}": j for j in top_jobs } sel = st.selectbox("Select a job", list(job_options.keys())) job = job_options[sel] st.divider() c1, c2, c3 = st.columns(3) with c1: st.markdown(f"**🏢 Company:** {job.get('company','')}") st.markdown(f"**📍 Location:** {job.get('location','')}") st.markdown(f"**💰 Salary:** {job.get('salary','Not specified')}") st.markdown(f"**🖥 Platform:** {job.get('platform','')}") with c2: score = job.get("relevance_score", 0) scolor = "green" if score >= 8 else ("orange" if score >= 6 else "red") st.markdown(f"**⭐ Score:** :{scolor}[{score}/10]") ats_b = job.get("ats_score_before"); ats_a = job.get("ats_score_after") imp = job.get("ats_improvement", 0) or 0 if ats_b is not None: st.markdown(f"**📄 ATS Before:** {ats_b}% → **After:** {ats_a}% (+{imp}pp)") st.markdown(f"**🎯 Exp Match:** {job.get('experience_match','')}") with c3: prio = job.get("application_priority","") pcolor = "red" if prio=="High" else ("orange" if prio=="Medium" else "gray") st.markdown(f"**🚦 Priority:** :{pcolor}[{prio}]") url = job.get("url","") if url: st.markdown(f"**🔗 [View Job Posting]({url})**") st.divider() cl, cr = st.columns(2) with cl: st.markdown("#### ✅ Matching Skills") matching = [s.strip() for s in job.get("matching_skills","").split(",") if s.strip()] if matching: for s in matching: st.markdown(f"- :green[{s}]") else: st.caption("None identified") st.markdown("#### 💡 Key Strengths") for s in [s.strip() for s in job.get("key_strengths","").split(",") if s.strip()]: st.markdown(f"- {s}") with cr: st.markdown("#### ❌ Missing / Gap Skills") missing = [s.strip() for s in job.get("missing_skills","").split(",") if s.strip()] if missing: for s in missing: st.markdown(f"- :red[{s}]") else: st.markdown(":green[No major gaps!]") st.markdown("#### 🔑 ATS Keywords") kws = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()] if kws: st.markdown(" ".join([f"`{k}`" for k in kws])) rec = job.get("recommendation","") if rec: st.divider() st.markdown("#### 📝 AI Recommendation") st.info(rec) desc = job.get("description","") if desc: with st.expander("📋 Full Job Description"): st.text(desc[:3000]) resume_path = job.get("resume_path","") pdf_path = job.get("resume_pdf_path","") or ( os.path.splitext(resume_path)[0] + ".pdf" if resume_path else "" ) if resume_path and os.path.exists(resume_path): st.divider() st.markdown("#### 📄 Tailored Resume") rc1, rc2 = st.columns(2) if pdf_path and os.path.exists(pdf_path): with rc1, open(pdf_path, "rb") as f: st.download_button("⬇ Download PDF (apply with this)", f.read(), os.path.basename(pdf_path), "application/pdf", use_container_width=True) with rc2, open(resume_path, "rb") as f: st.download_button("⬇ Download DOCX (editable)", f.read(), os.path.basename(resume_path), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", use_container_width=True) else: st.caption("Resume not generated for this job (score below threshold).") # ══════════════════════════════════════════════════════════════════════════ # TAB 3 — DEEP RESEARCH # ══════════════════════════════════════════════════════════════════════════ with tab_research: st.markdown("### 🔬 Deep Research") st.markdown( "Iterative **Think → Search → Extract → Synthesize** loop. " "Uses DuckDuckGo + GLM 5.1 to produce a cited Markdown report." ) top_jobs_r = [j for j in results if j.get("relevance_score", 0) >= 7][:5] if top_jobs_r: st.markdown("**Quick research on top jobs:**") preset_cols = st.columns(min(len(top_jobs_r), 5)) for i, job in enumerate(top_jobs_r): with preset_cols[i]: if st.button(job["company"], key=f"preset_{i}", use_container_width=True): st.session_state["research_q"] = ( f"Research {job['company']} as an employer: " f"culture, salary, work-life balance, Glassdoor reviews, " f"recent news for the role of {job['title']}" ) st.divider() default_q = st.session_state.get("research_q", "") question = st.text_area( "Research question", value=default_q, placeholder=( "e.g. What is the work culture and salary range for Product Managers at Swiggy in 2026?\n" "e.g. Compare AI PM roles at Google vs Microsoft vs Flipkart\n" "e.g. What skills do top EdTech Product Managers need in India?" ), height=100, key="research_input", ) cr1, cr2, cr3 = st.columns([1,1,2]) with cr1: max_rounds = st.slider("Rounds", 2, 6, 3, key="r_rounds") with cr2: max_time = st.slider("Max time (s)", 60, 300, 180, key="r_time") with cr3: category = st.selectbox( "Report format", ["Auto-detect","product","comparison","howto","factcheck"], key="r_cat", ) run_research = st.button("🔬 Start Research", disabled=not question.strip()) prog_area = st.empty(); rep_area = st.empty() if run_research and question.strip(): st.session_state["research_report"] = None st.session_state["research_log"] = [] research_log = [] research_done = [False] research_res = [None] def _prog_cb(event: dict): phase = event.get("phase","") msgs = { "planning": "📋 Planning research strategy...", "searching": f"🔍 Round {event.get('round','')} — searching...", "reading": f"📖 Reading: {(event.get('title') or event.get('url',''))[:60]}", "analyzing": f"🧠 Synthesizing round {event.get('round','')}...", "writing": "✍️ Writing final report...", "warning": f"⚠ {event.get('message','')}", "error": f"❌ {event.get('message','')}", } msg = msgs.get(phase, str(event)) if msg: research_log.append(msg) def _run_research(): import asyncio load_dotenv() from src.research.deep_researcher import DeepResearcher cat = None if category == "Auto-detect" else category researcher = DeepResearcher( llm_endpoint="https://integrate.api.nvidia.com/v1/chat/completions", llm_model="z-ai/glm-5.1", llm_api_key=os.getenv("NVIDIA_API_KEY"), max_rounds=max_rounds, max_time=max_time, category=cat, progress_callback=_prog_cb, ) async def _go(): return await researcher.research(question) research_res[0] = asyncio.run(_go()) research_done[0] = True threading.Thread(target=_run_research, daemon=True).start() phases = ["planning","searching","reading","analyzing","writing"] pw = {p: (i+1)/len(phases) for i,p in enumerate(phases)} with prog_area.container(): pb = st.progress(0, text="Starting research...") ld = st.empty() while not research_done[0]: if research_log: last = research_log[-1] pct = max(10, next((int(w*90) for p,w in pw.items() if p in last.lower()), 10)) pb.progress(min(pct, 90), text=last) with ld.expander("📋 Research log", expanded=True): for e in research_log[-12:]: if e.startswith(("❌","⚠")): st.markdown(f":orange[{e}]") elif e.startswith("✍"): st.markdown(f":blue[{e}]") else: st.markdown(e) time.sleep(2) st.rerun() pb.progress(100, text="Research complete!") st.session_state["research_report"] = research_res[0] report = st.session_state.get("research_report") if report: st.divider() st.markdown("### 📄 Research Report") st.download_button("⬇ Download (.md)", data=report.encode("utf-8"), file_name="research_report.md", mime="text/markdown") st.markdown(report, unsafe_allow_html=False) # ══════════════════════════════════════════════════════════════════════════ # TAB 4 — LOGS # ══════════════════════════════════════════════════════════════════════════ with tab_logs: st.markdown("### 📋 Run Logs") st.caption("Full debug output — every scrape attempt, error, and traceback is captured.") log_files = app_logger.list_log_files() current = st.session_state.get("current_log_file","") if not log_files and not current: st.info("No log files yet. Run a search to generate logs.") else: display_names = [] path_map = {} if current and os.path.exists(current): lbl = f"▶ Current run — {os.path.basename(current)}" display_names.append(lbl); path_map[lbl] = current for p in log_files: if p == current: continue lbl = os.path.basename(p) display_names.append(lbl); path_map[lbl] = p sel_lbl = st.selectbox("Log file", display_names, index=0) if display_names else None sel_path = path_map.get(sel_lbl,"") if sel_lbl else "" lcol1, lcol2, lcol3 = st.columns([2,1,1]) with lcol1: tail_lines = st.slider("Lines", 50, 500, 200, step=50, key="log_tail") with lcol2: show_debug = st.checkbox("Show DEBUG", False, key="log_debug") with lcol3: st.markdown("") auto_refresh = st.checkbox("Auto-refresh (2s)", value=st.session_state.running, key="log_refresh") if sel_path and os.path.exists(sel_path): try: with open(sel_path, "r", encoding="utf-8", errors="replace") as _f: all_lines = _f.readlines() except Exception as ex: all_lines = [f"Could not read: {ex}\n"] if not show_debug: all_lines = [l for l in all_lines if "[DEBUG]" not in l] tail = all_lines[-tail_lines:] colored = "" for line in tail: safe = line.replace("&","&").replace("<","<").replace(">",">") if any(x in line for x in ("[ERROR]","❌","FATAL","Traceback","Error:")): colored += f'{safe}' elif any(x in line for x in ("[WARNING]","⚠")): colored += f'{safe}' elif "[INFO]" in line and any(x in line for x in ("[DONE]","✅")): colored += f'{safe}' elif "[INFO]" in line: colored += f'{safe}' else: colored += f'{safe}' st.markdown( f'
' f'{colored}
', unsafe_allow_html=True, ) err_cnt = sum(1 for l in all_lines if "[ERROR]" in l or "Traceback" in l) warn_cnt = sum(1 for l in all_lines if "[WARNING]" in l) st.caption( f"`{sel_path}` · {len(all_lines)} lines · " f"{err_cnt} errors · {warn_cnt} warnings" ) with open(sel_path, "rb") as _df: st.download_button("⬇ Download Full Log", _df.read(), os.path.basename(sel_path), "text/plain") else: st.info("Select a log file or run a search to generate one.") _auto_refresh = locals().get("auto_refresh", False) if _auto_refresh and st.session_state.running: time.sleep(2) st.rerun() else: # No results yet and not in config mode (shouldn't happen, but safety net) if not show_config: st.html("""
🤖

Ready to find your next PM role

Configure your search settings, then click Start AI Job Search.
The agent will scrape multiple platforms, assess every job with AI models, and generate ATS-optimized resumes for all matches.

""")