JAA-ATS-Tool / ui.py
saitejatirunagari's picture
fix(ui): prevent resume upload rerun loop, show start button on all steps
0a411cc
Raw
History Blame
105 kB
"""
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("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* ── Global ── */
.stApp {
background: #F7F9FC !important;
color: #0F172A;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
[data-testid="stSidebar"] { display: none !important; }
.block-container {
padding: 1rem 2rem !important;
max-width: 1400px !important;
}
[data-testid="stHeader"] { background: transparent !important; }
/* ── Streamlit overrides ── */
h1, h2, h3, h4, h5, h6 {
font-family: 'Inter', -apple-system, sans-serif !important;
color: #0F172A !important;
}
p, span, label, .stMarkdown { color: #0F172A; }
.stSelectbox label, .stMultiSelect label, .stSlider label,
.stNumberInput label, .stFileUploader label {
color: #334155 !important; font-weight: 500 !important;
}
/* ── Buttons ── */
.stButton > button[kind="primary"], .stButton > button {
background: linear-gradient(135deg, #2563EB 0%, #7C3AED 100%) !important;
color: white !important; border: none !important; border-radius: 10px !important;
padding: 10px 24px !important; font-weight: 600 !important;
font-family: 'Inter', sans-serif !important;
transition: all 0.2s ease !important;
box-shadow: 0 2px 8px rgba(37,99,235,0.25) !important;
}
.stButton > button:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 16px rgba(37,99,235,0.35) !important;
}
.stButton > button:disabled {
opacity: 0.5 !important;
transform: none !important;
box-shadow: none !important;
}
/* Secondary / ghost buttons */
.secondary-btn .stButton > button {
background: white !important;
color: #2563EB !important;
border: 1.5px solid #E2E8F0 !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.04) !important;
}
.secondary-btn .stButton > button:hover {
border-color: #2563EB !important;
background: #F0F4FF !important;
}
/* ── Progress bar ── */
.stProgress > div > div { background: linear-gradient(90deg, #2563EB, #7C3AED) !important; }
/* ── File uploader ── */
div[data-testid="stFileUploaderDropzone"] {
background: #FFFFFF !important;
border: 2px dashed #CBD5E1 !important;
border-radius: 12px !important;
transition: all 0.2s ease;
}
div[data-testid="stFileUploaderDropzone"]:hover {
border-color: #2563EB !important;
background: #F0F4FF !important;
}
/* ── Inputs ── */
.stTextInput > div > div > input,
.stTextArea > div > div > textarea,
.stSelectbox > div > div,
.stMultiSelect > div {
background: #FFFFFF !important;
border-color: #E2E8F0 !important;
border-radius: 8px !important;
color: #0F172A !important;
}
/* ── Expander ── */
.streamlit-expanderHeader {
background: #FFFFFF !important;
border: 1px solid #E2E8F0 !important;
border-radius: 10px !important;
color: #334155 !important;
font-weight: 600 !important;
}
details {
border: 1px solid #E2E8F0 !important;
border-radius: 10px !important;
background: #FFFFFF !important;
}
/* ── Tabs ── */
.stTabs [data-baseweb="tab-list"] {
gap: 4px;
background: #F1F5F9;
border-radius: 12px;
padding: 4px;
}
.stTabs [data-baseweb="tab"] {
border-radius: 8px !important;
color: #64748B !important;
font-weight: 500 !important;
padding: 8px 16px !important;
}
.stTabs [aria-selected="true"] {
background: white !important;
color: #2563EB !important;
font-weight: 600 !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.08) !important;
}
/* ── Divider ── */
hr { border-color: #E2E8F0 !important; opacity: 0.5 !important; }
/* ══════════════════════════════════════════════════════════════════════
CUSTOM COMPONENT CLASSES
══════════════════════════════════════════════════════════════════════ */
/* ── Header ── */
.jaa-header {
background: linear-gradient(135deg, #2563EB 0%, #7C3AED 100%);
border-radius: 16px;
padding: 20px 28px;
margin-bottom: 20px;
display: flex; align-items: center; justify-content: space-between;
box-shadow: 0 4px 20px rgba(37,99,235,0.2);
}
.jaa-header-left { flex: 1; }
.jaa-header-title {
color: #fff; font-size: 1.5rem; font-weight: 800;
margin: 0; letter-spacing: -0.3px;
font-family: 'Inter', sans-serif;
}
.jaa-header-sub {
color: rgba(255,255,255,0.8); font-size: 0.85rem;
margin: 4px 0 0; font-weight: 400;
}
.jaa-header-badge {
background: rgba(255,255,255,0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 20px;
padding: 6px 14px;
color: white; font-size: 0.8rem; font-weight: 600;
white-space: nowrap;
}
/* ── Step Card ── */
.step-card-container {
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 14px;
padding: 20px 24px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: all 0.2s ease;
}
.step-card-container:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
.step-card-container.completed {
border-left: 3px solid #16A34A;
}
.step-card-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 8px;
}
.step-number {
width: 28px; height: 28px;
background: linear-gradient(135deg, #2563EB, #7C3AED);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: white; font-weight: 700; font-size: 0.85rem;
flex-shrink: 0;
}
.step-number.done {
background: #16A34A;
}
.step-title-text {
font-size: 1rem; font-weight: 700;
color: #0F172A; margin: 0;
}
.step-helper {
font-size: 0.82rem; color: #64748B;
margin: 0 0 12px 40px; line-height: 1.4;
}
/* ── Readiness Panel ── */
.readiness-panel {
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 14px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.readiness-title {
font-size: 1.1rem; font-weight: 700; color: #0F172A;
margin: 0 0 16px 0;
}
.readiness-progress-ring {
width: 100px; height: 100px; margin: 0 auto 16px;
position: relative;
}
.readiness-score {
text-align: center; font-size: 2rem; font-weight: 800;
background: linear-gradient(135deg, #2563EB, #7C3AED);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin: 0 0 4px;
}
.readiness-label {
text-align: center; font-size: 0.82rem; color: #64748B;
margin: 0 0 20px;
}
.readiness-badge {
display: inline-block;
background: linear-gradient(135deg, #EFF6FF, #F0ECFF);
border: 1px solid #BFDBFE;
border-radius: 20px;
padding: 4px 12px;
font-size: 0.78rem; font-weight: 600; color: #2563EB;
text-align: center;
width: 100%;
box-sizing: border-box;
margin-bottom: 16px;
}
.readiness-badge.gold {
background: linear-gradient(135deg, #FFFBEB, #FEF3C7);
border-color: #FCD34D; color: #92400E;
}
.checklist-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #F1F5F9;
font-size: 0.85rem;
}
.checklist-item:last-child { border-bottom: none; }
.check-done {
width: 20px; height: 20px;
background: #16A34A;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 0.7rem; flex-shrink: 0;
}
.check-pending {
width: 20px; height: 20px;
border: 2px solid #CBD5E1;
border-radius: 50%;
flex-shrink: 0;
}
.check-label { color: #334155; font-weight: 500; }
.check-label.done { color: #16A34A; }
.check-label.pending { color: #94A3B8; }
.summary-row {
display: flex; justify-content: space-between;
padding: 6px 0;
font-size: 0.82rem;
border-bottom: 1px solid #F8FAFC;
}
.summary-key { color: #64748B; }
.summary-val { color: #0F172A; font-weight: 600; }
/* ── Achievement Badges ── */
.badge-row {
display: flex; flex-wrap: wrap; gap: 6px;
margin: 12px 0;
}
.achievement-badge {
padding: 4px 10px;
border-radius: 16px;
font-size: 0.72rem; font-weight: 600;
display: inline-flex; align-items: center; gap: 4px;
}
.badge-earned {
background: #F0FDF4; border: 1px solid #BBF7D0; color: #16A34A;
}
.badge-locked {
background: #F8FAFC; border: 1px solid #E2E8F0; color: #CBD5E1;
}
/* ── Microcopy ── */
.micro-success {
background: #F0FDF4;
border: 1px solid #BBF7D0;
border-radius: 8px;
padding: 8px 14px;
font-size: 0.82rem; color: #16A34A; font-weight: 500;
margin: 8px 0;
}
/* ── Platform cards ── */
.platform-summary {
background: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 10px;
padding: 10px 14px;
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.platform-count {
background: #EFF6FF;
color: #2563EB;
border-radius: 16px;
padding: 2px 10px;
font-size: 0.78rem; font-weight: 700;
}
/* ── Step pipeline (running) ── */
.steps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px; margin: 12px 0;
}
.step-card {
background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 10px;
padding: 10px 14px; display: flex; align-items: center; gap: 8px;
transition: all 0.2s ease;
}
.step-card.active { border-color: #2563EB; background: #EFF6FF; }
.step-card.done { border-color: #16A34A; background: #F0FDF4; }
.step-card.error { border-color: #EF4444; background: #FEF2F2; }
.step-card.skip { opacity: 0.45; }
.step-icon { font-size: 1.2rem; flex-shrink: 0; }
.step-body { flex: 1; min-width: 0; }
.step-title-run {
font-size: 0.82rem; font-weight: 600; margin: 0;
color: #0F172A;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.step-detail {
font-size: 0.73rem; color: #64748B; margin: 1px 0 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.step-time { font-size: 0.72rem; color: #94A3B8; white-space: nowrap; }
/* ── Log box ── */
.log-box {
background: #1E293B; border: 1px solid #334155; border-radius: 10px;
padding: 12px 16px; font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 0.76rem; max-height: 160px; overflow-y: auto; color: #E2E8F0;
}
.log-ok { color: #4ade80; } .log-warn { color: #facc15; }
.log-err { color: #f87171; } .log-info { color: #93c5fd; }
/* ── Metric summary row ── */
.metrics-row {
display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap;
}
.mbox {
background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 12px;
padding: 16px 20px; text-align: center; flex: 1; min-width: 100px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: all 0.2s ease;
}
.mbox:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.mbox .mv { font-size: 1.8rem; font-weight: 800; }
.mbox .ml { font-size: 0.75rem; color: #64748B; margin-top: 4px; font-weight: 500; }
.mbox.blue .mv { color: #2563EB; }
.mbox.red .mv { color: #EF4444; }
.mbox.yellow .mv { color: #F59E0B; }
.mbox.green .mv { color: #16A34A; }
.mbox.purple .mv { color: #7C3AED; }
/* ── Job card ── */
.job-card {
background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 12px;
padding: 16px 20px; margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: all 0.2s ease;
}
.job-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.job-card.jc-high { border-left: 4px solid #EF4444; }
.job-card.jc-medium { border-left: 4px solid #F59E0B; }
.job-card.jc-low { border-left: 4px solid #CBD5E1; }
.jc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.jc-title { font-size: 1rem; font-weight: 700; color: #0F172A; margin: 0; }
.jc-company { font-size: 0.85rem; color: #64748B; margin: 3px 0; }
.jc-badge {
padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700;
white-space: nowrap; flex-shrink: 0;
}
.badge-high { background: #FEF2F2; color: #EF4444; border: 1px solid #FECACA; }
.badge-medium { background: #FFFBEB; color: #D97706; border: 1px solid #FDE68A; }
.badge-low { background: #F8FAFC; color: #94A3B8; border: 1px solid #E2E8F0; }
.jc-meta {
display: flex; gap: 12px; margin-top: 10px;
align-items: center; flex-wrap: wrap;
}
.jc-ats-before { color: #94A3B8; font-size: 0.8rem; }
.jc-ats-after { color: #16A34A; font-size: 0.85rem; font-weight: 700; }
.jc-ats-gain { color: #16A34A; font-size: 0.8rem; }
.jc-salary { color: #2563EB; font-size: 0.8rem; }
.jc-platform { color: #94A3B8; font-size: 0.78rem; }
/* ── History panel ── */
.history-panel {
background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 14px;
padding: 20px 24px; margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.history-run {
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 10px;
padding: 12px 16px; margin-bottom: 8px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.history-run-meta { flex: 1; }
.history-run-date { font-size: 0.82rem; color: #64748B; font-weight: 500; }
.history-run-stats { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.htag {
padding: 3px 10px; border-radius: 14px; font-size: 0.75rem; font-weight: 600;
background: #EFF6FF; color: #2563EB;
}
.htag-red { background: #FEF2F2; color: #EF4444; }
.htag-green { background: #F0FDF4; color: #16A34A; }
/* ── Stepper indicator ── */
.stepper-bar {
display: flex; align-items: center; justify-content: center;
gap: 0; margin: 0 0 24px; padding: 0 20px;
}
.stepper-item {
display: flex; align-items: center; gap: 0;
}
.stepper-dot {
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.82rem; font-weight: 700;
flex-shrink: 0;
transition: all 0.2s ease;
}
.stepper-dot.active {
background: linear-gradient(135deg, #2563EB, #7C3AED);
color: white;
box-shadow: 0 2px 10px rgba(37,99,235,0.3);
}
.stepper-dot.done {
background: #16A34A;
color: white;
}
.stepper-dot.pending {
background: #F1F5F9;
color: #94A3B8;
border: 2px solid #E2E8F0;
}
.stepper-line {
width: 32px; height: 2px;
flex-shrink: 0;
}
.stepper-line.done { background: #16A34A; }
.stepper-line.pending { background: #E2E8F0; }
.stepper-labels {
display: flex; justify-content: space-between;
padding: 0 8px; margin-top: 8px;
}
.stepper-label {
font-size: 0.68rem; color: #94A3B8;
text-align: center; width: 64px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stepper-label.active { color: #2563EB; font-weight: 600; }
.stepper-label.done { color: #16A34A; }
/* ── Nav buttons ── */
.nav-btn-row {
display: flex; gap: 12px; margin-top: 20px;
justify-content: space-between;
}
/* ── Welcome state ── */
.welcome-card {
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 16px;
padding: 48px 32px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.welcome-icon { font-size: 3.5rem; margin-bottom: 16px; }
.welcome-title {
font-size: 1.3rem; font-weight: 700; color: #0F172A;
margin: 0 0 8px;
}
.welcome-desc {
font-size: 0.9rem; color: #64748B; max-width: 480px;
margin: 0 auto; line-height: 1.6;
}
</style>
""", 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 = '<div class="steps-grid">'
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("<","&lt;").replace(">","&gt;")
html += f"""
<div class="step-card {css}">
<span class="step-icon">{icon}</span>
<div class="step-body">
<p class="step-title-run">{s['icon']} {s['title']}</p>
<p class="step-detail">{safe or ('Waiting…' if status=='pending' else '')}</p>
</div>
<span class="step-time">{elapsed}</span>
</div>"""
html += '</div>'
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("<","&lt;").replace(">","&gt;")
lines += f'<span class="{cls}">{safe}</span>\n'
return f'<div class="log-box"><pre style="margin:0;white-space:pre-wrap">{lines}</pre></div>'
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'<span class="jc-ats-before">ATS {ats_b}%</span>'
f'<span style="color:#CBD5E1">→</span>'
f'<span class="jc-ats-after">{ats_a}%</span>'
f'<span class="jc-ats-gain">(+{imp}pp)</span>'
)
sal_html = f'<span class="jc-salary">💰 {salary}</span>' if salary and salary != "Not specified" else ""
link_html = f'<a href="{url}" target="_blank" style="color:#2563EB;font-size:0.8rem;text-decoration:none;font-weight:500">Apply →</a>' if url else ""
t = title.replace("<","&lt;").replace(">","&gt;")
co = company.replace("<","&lt;").replace(">","&gt;")
lo = location.replace("<","&lt;").replace(">","&gt;")
return f"""
<div class="job-card jc-{cls}">
<div class="jc-top">
<div>
<p class="jc-title">#{rank} {t}</p>
<p class="jc-company">🏢 {co} · 📍 {lo}</p>
</div>
<span class="{badge} jc-badge">{emoji} {score}/10</span>
</div>
<div class="jc-meta">
{ats_html}
{sal_html}
<span class="jc-platform">via {platform}</span>
{link_html}
</div>
</div>"""
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"""
<div class="metrics-row">
<div class="mbox blue"> <div class="mv">{total}</div> <div class="ml">Total Jobs</div> </div>
<div class="mbox red"> <div class="mv">{high}</div> <div class="ml">High Priority</div> </div>
<div class="mbox yellow"><div class="mv">{med}</div> <div class="ml">Good Match</div> </div>
<div class="mbox green"> <div class="mv">{llm_cnt}</div><div class="ml">LLM Resumes</div> </div>
<div class="mbox purple"><div class="mv">{pdf_cnt}</div><div class="ml">PDFs Ready</div> </div>
<div class="mbox green"> <div class="mv">{avg_ats}%</div><div class="ml">Avg ATS After</div> </div>
</div>"""
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"""
<div class="jaa-header">
<div class="jaa-header-left">
<p class="jaa-header-title">🤖 Job Automation Agent</p>
<p class="jaa-header-sub">AI-powered job discovery, resume matching, and application tracking</p>
</div>
<span class="jaa-header-badge">{"⏳" if st.session_state.running else "✨"} {status_text}</span>
</div>""")
with hdr_r:
st.markdown("<br>", 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('<div class="history-panel">')
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"""
<div class="history-run">
<div class="history-run-meta">
<div class="history-run-date">📅 {date}</div>
<div class="history-run-stats">
<span class="htag">{total} jobs</span>
<span class="htag htag-red">{high} 🔴 high</span>
<span class="htag">{resumes} resumes</span>
<span class="htag htag-green">ATS {ats_b}%→{ats_a}% (+{imp}pp)</span>
<span class="htag">{plat}</span>
</div>
</div>
</div>""")
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('</div>')
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 = '<div class="stepper-bar">'
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'<div class="stepper-dot {dot_cls}">{dot_text}</div>'
if i < len(SETUP_STEPS) - 1:
line_cls = "done" if step_done.get(n, False) else "pending"
stepper_html += f'<div class="stepper-line {line_cls}"></div>'
stepper_html += '</div>'
labels_html = '<div class="stepper-labels">'
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'<span class="stepper-label {lbl_cls}">{s["label"]}</span>'
labels_html += '</div>'
st.html(f"""
<div style="background:#FFFFFF; border:1px solid #E2E8F0; border-radius:14px;
padding:20px 16px 12px; margin-bottom:20px; box-shadow:0 1px 3px rgba(0,0,0,0.04);">
{stepper_html}
{labels_html}
</div>""")
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"""
<div class="step-card-container {('completed' if has_resume else '')}">
<div class="step-card-header">
<div class="step-number {"done" if has_resume else ""}">{"✓" if has_resume else "1"}</div>
<p class="step-title-text">Upload your resume</p>
</div>
<p class="step-helper">We'll analyze your resume to match you with better-fit roles and generate ATS-optimized versions.</p>
</div>""")
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"""
<div class="micro-success">
✅ <strong>resume.pdf</strong> uploaded ({fsize} KB) — Ready for AI matching
</div>""")
else:
st.caption("Upload your resume to unlock AI matching.")
# ────────────────────────────────────────────────────────────────────
# STEP 2: Target Roles
# ────────────────────────────────────────────────────────────────────
if current_step == 2:
st.html("""
<div class="step-card-container">
<div class="step-card-header">
<div class="step-number">2</div>
<p class="step-title-text">Choose your target roles</p>
</div>
<p class="step-helper">Select up to 5 roles for more focused results. We'll search all selected roles across every platform.</p>
</div>""")
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'<div class="micro-success">🎯 Great focus — {len(roles)} target role{"s" if len(roles)!=1 else ""} selected</div>')
else:
roles = _roles_val
# ────────────────────────────────────────────────────────────────────
# STEP 3: Locations
# ────────────────────────────────────────────────────────────────────
if current_step == 3:
st.html("""
<div class="step-card-container">
<div class="step-card-header">
<div class="step-number">3</div>
<p class="step-title-text">Where should we search?</p>
</div>
<p class="step-helper">You can mix cities, countries, and remote preferences.</p>
</div>""")
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'<div class="micro-success">📍 Searching in {len(locations)} location{"s" if len(locations)!=1 else ""}</div>')
else:
locations = _locs_val
# ────────────────────────────────────────────────────────────────────
# STEP 4: Job Freshness & Volume
# ────────────────────────────────────────────────────────────────────
if current_step == 4:
st.html("""
<div class="step-card-container">
<div class="step-card-header">
<div class="step-number">4</div>
<p class="step-title-text">Job freshness and search volume</p>
</div>
<p class="step-helper">Lower values make results more focused. Higher values increase coverage.</p>
</div>""")
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"""
<div class="step-card-container">
<div class="step-card-header">
<div class="step-number">5</div>
<p class="step-title-text">Job platforms</p>
</div>
<p class="step-helper">Select platforms where the agent should search. {total_platforms} platforms available across 3 categories.</p>
</div>""")
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'<div class="micro-success">🌐 {len(ever_jobs_platforms)} platform{"s" if len(ever_jobs_platforms)!=1 else ""} selected</div>')
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("""
<div class="step-card-container">
<div class="step-card-header">
<div class="step-number">6</div>
<p class="step-title-text">AI match score threshold</p>
</div>
<p class="step-helper">Jobs scoring below this get a basic template resume. Jobs above get a fully AI-tailored ATS-optimized version.</p>
</div>""")
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"""
<div style="background:#F8FAFC; border:1px solid #E2E8F0; border-radius:10px; padding:14px; text-align:center;">
<div style="font-size:0.78rem; color:#64748B; margin-bottom:4px;">Estimated scan</div>
<div style="font-size:1.3rem; font-weight:700; color:#2563EB;">~{min(est_jobs, 500)} jobs</div>
<div style="font-size:0.75rem; color:#94A3B8; margin-top:2px;">~{est_time}{est_time*2} minutes</div>
</div>""")
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"""
<div class="step-card-container {gs_status_cls}">
<div class="step-card-header">
<div class="step-number {gs_num_cls}">{"✓" if sheets_ok else "7"}</div>
<p class="step-title-text">Application tracker</p>
</div>
<p class="step-helper">Save all discovered jobs into a Google Sheet for easy tracking and sharing.</p>
</div>""")
if sheets_ok:
st.html(f'<div class="micro-success">✅ {sheets_status}</div>')
from config import GOOGLE as _G
st.caption(f"Sheet: `{_G['sheet_id'][:20]}…` · Tab: `{_G['sheet_tab']}`")
else:
st.html(f"""
<div style="background:#FFFBEB; border:1px solid #FDE68A; border-radius:10px; padding:12px 16px; margin:8px 0;">
<span style="font-size:0.85rem; color:#92400E;">
{sheets_status}. Results will be saved locally.
</span>
</div>""")
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"""
<div style="background:linear-gradient(135deg,#EFF6FF 0%,#F5F3FF 100%);
border:1px solid #DBEAFE; border-radius:14px; padding:20px 24px;">
<h4 style="margin:0 0 12px; color:#1E293B; font-size:1rem;">Review your search</h4>
<div class="summary-row"><span class="summary-key">Roles</span><span class="summary-val">{', '.join(roles[:3])}{"…" if len(roles)>3 else ""}</span></div>
<div class="summary-row"><span class="summary-key">Locations</span><span class="summary-val">{', '.join(locations[:3])}{"…" if len(locations)>3 else ""}</span></div>
<div class="summary-row"><span class="summary-key">Platforms</span><span class="summary-val">{len(ever_jobs_platforms)} selected</span></div>
<div class="summary-row"><span class="summary-key">Freshness</span><span class="summary-val">Last {days_posted} days</span></div>
<div class="summary-row"><span class="summary-key">Max / platform</span><span class="summary-val">{max_jobs}</span></div>
<div class="summary-row"><span class="summary-key">AI match score</span><span class="summary-val">{min_score}/10</span></div>
<div class="summary-row"><span class="summary-key">Google Sheet</span><span class="summary-val">{"✅ Connected" if sheets_ok else "⚠ Not connected"}</span></div>
</div>""")
# ────────────────────────────────────────────────────────────────────
# NAVIGATION BUTTONS
# ────────────────────────────────────────────────────────────────────
st.markdown("---")
nav_l, nav_c, nav_r = st.columns([1, 2, 1])
with nav_l:
if current_step > 1:
st.markdown('<div class="secondary-btn">', 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('</div>', unsafe_allow_html=True)
with nav_c:
step_label = SETUP_STEPS[current_step - 1]["label"]
st.html(f"""
<div style="text-align:center; padding:8px 0;">
<span style="font-size:0.85rem; color:#64748B; font-weight:500;">
Step {current_step} of {len(SETUP_STEPS)} · {step_label}
</span>
</div>""")
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"""
<div class="readiness-panel">
<p class="readiness-title">Run Readiness</p>
<p class="readiness-score">{readiness}%</p>
<p class="readiness-label">Setup completeness</p>
<div class="readiness-badge {"gold" if is_gold else ""}">{level_name}</div>
</div>""")
# 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"""
<div class="checklist-item">
<div class="{icon_cls}">{icon_txt}</div>
<span class="check-label {label_cls}">{label}</span>
</div>"""
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"""
<div style="background:#FFFFFF; border:1px solid #E2E8F0; border-radius:14px; padding:16px; margin-top:12px;
box-shadow:0 1px 3px rgba(0,0,0,0.04);">
{checklist_html}
<p style="font-size:0.8rem; color:#64748B; margin:12px 0 0; text-align:center; font-weight:500;">
{cta_copy}
</p>
</div>""")
# 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'<span class="achievement-badge {cls}">{label}</span>'
st.html(f"""
<div style="background:#FFFFFF; border:1px solid #E2E8F0; border-radius:14px; padding:16px; margin-top:12px;
box-shadow:0 1px 3px rgba(0,0,0,0.04);">
<p style="font-size:0.85rem; font-weight:700; color:#0F172A; margin:0 0 10px;">Achievements</p>
<div class="badge-row">{badges_html}</div>
</div>""")
# 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('<div class="secondary-btn">', 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('</div>', 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(
'<p style="font-weight:700;color:#0F172A;margin:0 0 4px 0">Live Log</p>' +
_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("&","&amp;").replace("<","&lt;").replace(">","&gt;")
if any(x in line for x in ("[ERROR]","❌","FATAL","Traceback","Error:")):
colored += f'<span style="color:#f87171">{safe}</span>'
elif any(x in line for x in ("[WARNING]","⚠")):
colored += f'<span style="color:#facc15">{safe}</span>'
elif "[INFO]" in line and any(x in line for x in ("[DONE]","✅")):
colored += f'<span style="color:#4ade80">{safe}</span>'
elif "[INFO]" in line:
colored += f'<span style="color:#93c5fd">{safe}</span>'
else:
colored += f'<span style="color:#d1d5db">{safe}</span>'
st.markdown(
f'<div style="background:#1E293B;border:1px solid #334155;border-radius:10px;'
f'padding:14px;font-family:JetBrains Mono,Courier New,monospace;font-size:0.78rem;'
f'max-height:500px;overflow-y:auto;white-space:pre-wrap">'
f'{colored}</div>',
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("""
<div class="welcome-card">
<div class="welcome-icon">🤖</div>
<p class="welcome-title">Ready to find your next PM role</p>
<p class="welcome-desc">
Configure your search settings, then click <strong>Start AI Job Search</strong>.<br>
The agent will scrape multiple platforms, assess every job with AI models,
and generate ATS-optimized resumes for all matches.
</p>
</div>""")