""" 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) ────────── @st.cache_resource(show_spinner=False) def _ensure_playwright(): import subprocess, sys as _sys result = subprocess.run( [_sys.executable, "-m", "playwright", "install", "chromium", "--with-deps"], capture_output=True, text=True, timeout=120, ) 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, } for _k, _v in _DEFAULTS.items(): if _k not in st.session_state: st.session_state[_k] = _v # ── 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": "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 _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 _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}%
Avg ATS After
""" 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) # ══════════════════════════════════════════════════════════════════════════════ # HEADER # ══════════════════════════════════════════════════════════════════════════════ has_resume = os.path.exists("data/resume/resume.pdf") 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" ) st.html(f"""

🤖 Job Automation Agent

AI-powered job discovery, resume matching, and application tracking

{"⏳" if st.session_state.running else "✨"} {status_text}
""") 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() # ══════════════════════════════════════════════════════════════════════════════ # 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 # 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

We'll analyze your resume to match you with better-fit roles and generate ATS-optimized versions.

""") resume_file = st.file_uploader( "Drop your resume PDF here", type=["pdf"], key="resume_upload", label_visibility="collapsed", help="Supported format: PDF. Max size: 10MB.", ) if resume_file and not has_resume: os.makedirs("data/resume", exist_ok=True) with open("data/resume/resume.pdf", "wb") as f: f.write(resume_file.read()) has_resume = True st.rerun() if has_resume: fsize = os.path.getsize("data/resume/resume.pdf") // 1024 st.html(f"""
resume.pdf uploaded ({fsize} KB) — Ready for AI matching
""") else: st.caption("Upload your resume 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: roles = st.multiselect( "Target roles", options=[ "Product Manager", "Senior Product Manager", "AI Product Manager", "Technical Product Manager", "Data Product Manager", "Platform Product Manager", "Growth Product Manager", "SaaS Product Manager", "Product Owner", "Associate Product Manager", "Head of Product", ], default=_roles_val, key="roles_select", label_visibility="collapsed", ) if roles: st.html(f'
🎯 Great focus — {len(roles)} target role{"s" if len(roles)!=1 else ""} selected
') 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_idx = [3, 7, 14, 30].index(_cfg("_cfg_days")) if _cfg("_cfg_days") in [3, 7, 14, 30] else 1 days_posted = st.selectbox( "Only show jobs posted within", options=[3, 7, 14, 30], index=_days_idx, format_func=lambda x: f"Last {x} 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
FreshnessLast {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 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 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}
""") # 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: 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.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"} 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") _ej_platforms = [p for p in _all_plats if p not in _legacy_keys] if _ej_platforms: from src.scrapers.ever_jobs import EverJobsScraper scraper_map["ever_jobs"] = ("EverJobs", EverJobsScraper(_ej_platforms)) 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"] 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 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") _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): 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]}") 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}") # ── 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 == "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) ) # ── 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}" ) 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) 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)) 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.

""")