"""
Job Automation Agent — Streamlit UI v3 (Light SaaS Dashboard)
Run: streamlit run ui.py
"""
import streamlit as st
import os, sys, json, time, threading, queue, logging
import pandas as pd
from pathlib import Path
from dotenv import load_dotenv
import src.app_logger as app_logger
load_dotenv()
# ── HF Spaces: write Google credentials from env var ─────────────────────────
_gcreds_json = os.getenv("GOOGLE_CREDENTIALS_JSON", "")
if _gcreds_json and not os.path.exists("google_credentials.json"):
try:
with open("google_credentials.json", "w") as _f:
_f.write(_gcreds_json)
except Exception:
pass
# ── Page config ───────────────────────────────────────────────────────────────
st.set_page_config(
page_title="Job Automation Agent",
page_icon="🤖",
layout="wide",
initial_sidebar_state="collapsed",
)
# ══════════════════════════════════════════════════════════════════════════════
# CSS — Light SaaS Dashboard Theme
# ══════════════════════════════════════════════════════════════════════════════
st.markdown("""
""", unsafe_allow_html=True)
# ── Playwright install (runs once per server lifetime on HF Spaces) ──────────
# On HF Spaces, Chromium is pre-installed in the Dockerfile so this is a fast
# no-op check. We omit --with-deps because system-package installs require root
# and would fail silently, adding ~10s of pointless startup latency every boot.
@st.cache_resource(show_spinner=False)
def _ensure_playwright():
import subprocess, sys as _sys
result = subprocess.run(
[_sys.executable, "-m", "playwright", "install", "chromium"],
capture_output=True, text=True, timeout=30,
)
return result.returncode == 0
_ensure_playwright()
# ── Session state defaults ────────────────────────────────────────────────────
_DEFAULTS = {
"results": None, "running": False, "excel_path": "",
"log_msgs": [], "progress_pct": 0, "progress_label": "",
"current_log_file": "", "steps": {},
"show_history": False, "loaded_run": "",
"setup_step": 1,
"completed_jobs": [], # per-job results streamed in during a run
"custom_roles": [], # user-added custom role titles
"_gen_version": "v1", # resume generation mode: "v1" or "v2"
"logged_in": False, "user_id": None, "user_email": "",
}
for _k, _v in _DEFAULTS.items():
if _k not in st.session_state:
st.session_state[_k] = _v
# ── Restore run history + resumes from the private HF Dataset (Option B) ──────
# HF Spaces wipe the local disk on restart; pull persisted runs back once per
# session so the History panel and per-job downloads work after a rebuild.
if not st.session_state.get("_hf_synced"):
try:
from src.hf_storage import sync_down, is_enabled
if is_enabled():
sync_down()
except Exception:
pass
st.session_state["_hf_synced"] = True
# ── Restore uploaded resume from Supabase Storage (survives HF restarts) ──────
if not st.session_state.get("_sb_resume_synced"):
st.session_state["_sb_resume_synced"] = True
if not os.path.exists("data/resume/resume.pdf"):
try:
from src.supabase_client import get_service_client, is_configured, get_owner_user_id
if is_configured():
uid = get_owner_user_id()
if uid:
pdf_data = get_service_client().storage.from_("resumes").download(
f"{uid}/resume.pdf"
)
if pdf_data:
os.makedirs("data/resume", exist_ok=True)
with open("data/resume/resume.pdf", "wb") as _f:
_f.write(pdf_data)
except Exception:
pass
# ── Seed the hardcoded default resume when the user has none (R20) ────────────
if not st.session_state.get("_default_resume_seeded"):
st.session_state["_default_resume_seeded"] = True
_has_pdf = os.path.exists("data/resume/resume.pdf")
_has_tex = os.path.exists("data/resume/resume.tex") or bool(st.session_state.get("_resume_tex"))
if not _has_pdf and not _has_tex:
try:
from src.default_resume import get_default_resume_latex
os.makedirs("data/resume", exist_ok=True)
with open("data/resume/resume.tex", "w", encoding="utf-8") as _f:
_f.write(get_default_resume_latex())
st.session_state["_resume_tex"] = get_default_resume_latex()
except Exception:
pass
# ── Preferences helpers ───────────────────────────────────────────────────────
_PREF_MAP = [
# (supabase_key, session_state_key, default_value)
("setup_step", "setup_step", 1),
("roles", "_cfg_roles", ["Product Manager", "Senior Product Manager", "AI Product Manager"]),
("locations", "_cfg_locations", ["India", "Bangalore"]),
("days", "_cfg_days", 7),
("min_score", "_cfg_min_score", 1),
("max_jobs", "_cfg_max_jobs", 25),
("sb", "_cfg_sb", []),
("ats", "_cfg_ats", []),
("cp", "_cfg_cp", []),
("uploaded_sig", "_uploaded_sig", ""),
("resume_tex", "_resume_tex", ""),
]
def _apply_prefs(prefs: dict):
if not prefs:
return
for pk, sk, _ in _PREF_MAP:
if pk in prefs and prefs[pk] is not None:
st.session_state[sk] = prefs[pk]
def _save_prefs():
uid = st.session_state.get("user_id")
if not uid:
return
try:
from src.supabase_client import save_preferences
save_preferences(uid, {pk: st.session_state.get(sk, dv) for pk, sk, dv in _PREF_MAP})
except Exception:
pass
# ── Auto-login: restore session from Supabase client (persists within process) ─
if not st.session_state.get("logged_in"):
try:
from src.supabase_client import get_anon_client, load_preferences as _load_prefs_sb, is_configured
if is_configured():
_sb_sess = get_anon_client().auth.get_session()
if _sb_sess and _sb_sess.user:
st.session_state["logged_in"] = True
st.session_state["user_id"] = _sb_sess.user.id
st.session_state["user_email"] = _sb_sess.user.email
if not st.session_state.get("_prefs_loaded"):
_apply_prefs(_load_prefs_sb(_sb_sess.user.id))
st.session_state["_prefs_loaded"] = True
except Exception:
pass
# ── Shared progress queue ────────────────────────────────────────────────────
if "progress_q" not in st.session_state:
st.session_state["progress_q"] = queue.Queue()
_progress_q: queue.Queue = st.session_state["progress_q"]
# ── Pipeline steps definition ────────────────────────────────────────────────
PIPELINE_STEPS = [
{"id": "resume", "icon": "📄", "title": "Parse Resume"},
{"id": "profile", "icon": "🧠", "title": "Build Profile"},
{"id": "linkedin", "icon": "🔵", "title": "LinkedIn"},
{"id": "indeed", "icon": "🟠", "title": "Indeed"},
{"id": "glassdoor", "icon": "🟢", "title": "Glassdoor"},
{"id": "remotive", "icon": "🌍", "title": "Remotive"},
{"id": "weworkremotely", "icon": "💻", "title": "WeWorkRemotely"},
{"id": "naukri", "icon": "🇮🇳", "title": "Naukri"},
{"id": "company_ats", "icon": "🏢", "title": "Company ATS"},
{"id": "ever_jobs", "icon": "🌐", "title": "EverJobs (160+)"},
{"id": "assess", "icon": "🤖", "title": "AI Assessment"},
{"id": "resumes", "icon": "📝", "title": "Generate Resumes"},
{"id": "report", "icon": "📊", "title": "Save Report"},
]
_STEP_TITLE_MAP = {s["id"]: s["title"] for s in PIPELINE_STEPS}
# ══════════════════════════════════════════════════════════════════════════════
# HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def _score_color_cls(score):
if score >= 8: return "high"
if score >= 6: return "medium"
return "low"
def _badge_cls(score):
if score >= 8: return "badge-high"
if score >= 6: return "badge-medium"
return "badge-low"
def _score_emoji(score):
if score >= 8: return "🔴"
if score >= 6: return "🟡"
return "⚪"
def _render_steps(steps_state: dict) -> str:
status_icons = {"pending": "⬜", "active": "⏳", "done": "✅", "error": "❌", "skip": "⏭"}
html = '
'
for s in PIPELINE_STEPS:
sid = s["id"]
info = steps_state.get(sid, {"status": "pending", "detail": "", "elapsed": ""})
status = info.get("status", "pending")
detail = info.get("detail", "")
elapsed= info.get("elapsed", "")
css = {"active": "active", "done": "done", "error": "error", "skip": "skip"}.get(status, "")
icon = status_icons.get(status, "⬜")
safe = detail.replace("<","<").replace(">",">")
html += f"""
{icon}
{s['icon']} {s['title']}
{safe or ('Waiting…' if status=='pending' else '')}
{elapsed}
"""
html += '
'
return html
def _render_log(msgs: list) -> str:
lines = ""
for m in msgs[-40:]:
if m.startswith(("✅","✓")): cls = "log-ok"
elif m.startswith("❌"): cls = "log-err"
elif m.startswith(("⚠","⚡")): cls = "log-warn"
else: cls = "log-info"
safe = m.replace("<","<").replace(">",">")
lines += f'{safe}\n'
return f''
def _render_uploaded_profile():
"""Parse the uploaded resume and show the profile we built, on the same page."""
import html as _html
try:
from src.resume_parser_v2 import parse_resume_pdf_cached
r = parse_resume_pdf_cached("data/resume/resume.pdf")
except Exception as e:
st.caption(f"⚠ Couldn't parse the resume profile: {str(e)[:120]}")
return
def esc(t):
return _html.escape(str(t or ""))
roles_html = ""
for role in r.roles[:6]:
meta = " · ".join(filter(None, [esc(role.company), esc(role.location), esc(role.dates)]))
n_b = len(role.bullets)
roles_html += (
f""
f"{esc(role.title)}
"
f"{meta} · {n_b} bullets"
f"
"
)
edu_html = ""
for e in r.education[:4]:
meta = " · ".join(filter(None, [esc(e.institution), esc(e.dates)]))
edu_html += f"{esc(e.degree)} — {meta}
"
summary = esc(r.summary)[:400] + ("…" if len(r.summary) > 400 else "")
contact = esc(r.contact.render_line())
st.html(f"""
PROFILE WE BUILT FROM YOUR RESUME
{esc(r.name)}
{contact}
{summary}
Experience ({len(r.roles)} roles)
{roles_html or '
No roles parsed'}
Education
{edu_html or '
No education parsed'}
""")
st.caption("⬆ This is what the tool extracted. If it looks wrong, re-upload the correct PDF — it will replace this.")
def _job_card_html(job: dict, rank: int) -> str:
score = job.get("relevance_score", 0)
cls = _score_color_cls(score)
badge = _badge_cls(score)
emoji = _score_emoji(score)
title = job.get("title", "")
company = job.get("company", "")
location = job.get("location", "")
platform = job.get("platform", "")
salary = job.get("salary", "") or ""
ats_b = job.get("ats_score_before")
ats_a = job.get("ats_score_after")
imp = job.get("ats_improvement", 0) or 0
url = job.get("url", "")
ats_html = ""
if ats_b is not None and ats_a is not None:
ats_html = (
f'ATS {ats_b}%'
f'→'
f'{ats_a}%'
f'(+{imp}pp)'
)
sal_html = f'💰 {salary}' if salary and salary != "Not specified" else ""
link_html = f'Apply →' if url else ""
t = title.replace("<","<").replace(">",">")
co = company.replace("<","<").replace(">",">")
lo = location.replace("<","<").replace(">",">")
return f"""
#{rank} {t}
🏢 {co} · 📍 {lo}
{emoji} {score}/10
{ats_html}
{sal_html}
via {platform}
{link_html}
"""
def _render_card_actions(job: dict, key: str) -> None:
"""Per-job download row rendered under a job card.
Mirrors the in-card "Apply →" link with real download buttons for THIS
job's tailored resume (PDF to apply with + editable DOCX). Renders nothing
when no resume was generated for the job (score below threshold).
"""
resume_path = job.get("resume_path", "")
pdf_path = job.get("resume_pdf_path", "") or (
os.path.splitext(resume_path)[0] + ".pdf" if resume_path else ""
)
has_docx = bool(resume_path) and os.path.exists(resume_path)
has_pdf = bool(pdf_path) and os.path.exists(pdf_path)
if not (has_docx or has_pdf):
return
c_pdf, c_docx, _spacer = st.columns([1.4, 1.4, 5])
if has_pdf:
with c_pdf, open(pdf_path, "rb") as f:
st.download_button(
"⬇ PDF", f.read(), os.path.basename(pdf_path),
"application/pdf", key=f"dl_pdf_{key}",
use_container_width=True,
help="Download this job's tailored resume (PDF — apply with this)",
)
if has_docx:
with c_docx, open(resume_path, "rb") as f:
st.download_button(
"⬇ DOCX", f.read(), os.path.basename(resume_path),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
key=f"dl_docx_{key}", use_container_width=True,
help="Download this job's tailored resume (editable DOCX)",
)
def _metrics_html(results: list) -> str:
total = len(results)
high = sum(1 for j in results if j.get("relevance_score", 0) >= 8)
med = sum(1 for j in results if 6 <= j.get("relevance_score", 0) <= 7)
llm_cnt = sum(1 for j in results if j.get("resume_generated") == "LLM Tailored")
pdf_cnt = sum(1 for j in results if j.get("resume_pdf_path"))
ats_a = [j["ats_score_after"] for j in results if j.get("ats_score_after")]
avg_ats = int(sum(ats_a)/len(ats_a)) if ats_a else 0
return f"""
{avg_ats}%
Est. ATS (verify on Jobalytics)
"""
def _sheets_configured() -> tuple[bool, str]:
if os.path.exists("google_credentials.json"):
return True, "Service account connected"
if os.path.exists("google_token.json"):
return True, "OAuth connected"
if os.path.exists("google_oauth_client.json"):
return False, "Needs one-time authorization"
return False, "Not connected yet"
def _readiness_score(has_resume, roles, locations, platforms, sheets_ok, min_score):
score = 0
total = 6
if has_resume: score += 1
if roles: score += 1
if locations: score += 1
if platforms: score += 1
if sheets_ok: score += 1
if min_score is not None: score += 1
return int((score / total) * 100)
def _readiness_level(pct):
if pct >= 100: return ("Automation Pro", True)
if pct >= 83: return ("Power Search Ready", False)
if pct >= 50: return ("Balanced Setup", False)
return ("Getting Started", False)
# ══════════════════════════════════════════════════════════════════════════════
# AUTH — Login gate
# ══════════════════════════════════════════════════════════════════════════════
def _render_login_page():
from src.supabase_client import is_configured, get_anon_client
_, col, _ = st.columns([1, 1.4, 1])
with col:
st.markdown("""
🤖
JAA · ATS Tool
Sign in to continue
""", unsafe_allow_html=True)
if not is_configured():
st.error("Supabase not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY in HF Space → Settings → Secrets.")
return
with st.form("login_form", clear_on_submit=False):
email = st.text_input("Email", placeholder="you@example.com")
password = st.text_input("Password", type="password", placeholder="••••••••")
submitted = st.form_submit_button("Sign in", use_container_width=True)
if submitted:
if not email or not password:
st.error("Enter your email and password.")
return
try:
resp = get_anon_client().auth.sign_in_with_password(
{"email": email, "password": password}
)
st.session_state["logged_in"] = True
st.session_state["user_id"] = resp.user.id
st.session_state["user_email"] = resp.user.email
# Restore wizard state from last session
if not st.session_state.get("_prefs_loaded"):
from src.supabase_client import load_preferences as _lp
_apply_prefs(_lp(resp.user.id))
st.session_state["_prefs_loaded"] = True
st.rerun()
except Exception as exc:
msg = str(exc).lower()
if "invalid" in msg or "credentials" in msg or "login" in msg:
st.error("Incorrect email or password.")
else:
st.error(f"Login failed: {exc}")
if not st.session_state.get("logged_in"):
_render_login_page()
st.stop()
# ══════════════════════════════════════════════════════════════════════════════
# HEADER
# ══════════════════════════════════════════════════════════════════════════════
@st.cache_data(ttl=300, show_spinner=False)
def _deploy_timestamp_ist() -> str:
"""Latest code-update (deploy) time in IST.
Prefers the HEAD git commit time (matches what GitHub/HF deployed); falls
back to the newest mtime among ui.py + src/*.py if git isn't available.
"""
from datetime import datetime, timezone, timedelta
ist = timezone(timedelta(hours=5, minutes=30))
here = os.path.dirname(os.path.abspath(__file__))
ts = None
try:
import subprocess
out = subprocess.run(
["git", "log", "-1", "--format=%cI"],
capture_output=True, text=True, timeout=4, cwd=here,
)
s = (out.stdout or "").strip()
if s:
ts = datetime.fromisoformat(s)
except Exception:
ts = None
if ts is None:
try:
cands = [os.path.join(here, "ui.py")]
src_dir = os.path.join(here, "src")
if os.path.isdir(src_dir):
cands += [os.path.join(src_dir, f) for f in os.listdir(src_dir)
if f.endswith(".py")]
mt = max(os.path.getmtime(p) for p in cands if os.path.exists(p))
ts = datetime.fromtimestamp(mt, tz=timezone.utc)
except Exception:
ts = datetime.now(timezone.utc)
return ts.astimezone(ist).strftime("%d %b %Y, %I:%M %p IST")
# Resume is considered "uploaded" if EITHER:
# - a compiled resume.pdf exists, OR
# - a saved LaTeX source exists (compile-on-demand at pipeline launch).
# Matches the Chrome extension behaviour: save now, compile when needed.
has_resume = (
os.path.exists("data/resume/resume.pdf")
or bool(st.session_state.get("_resume_tex"))
or os.path.exists("data/resume/resume.tex")
)
sheets_ok, sheets_status = _sheets_configured()
hdr_l, hdr_r = st.columns([5, 1])
with hdr_l:
status_text = "Running..." if st.session_state.running else (
"Results ready" if st.session_state.results else "Setup in progress"
)
# Title removed per user request; keep a minimal status badge so the
# running/results state is still visible.
st.html(f"""
🟢 Last code update (deployed): {_deploy_timestamp_ist()}
""")
with hdr_r:
st.markdown("
", unsafe_allow_html=True)
hist_label = "📜 History ✕" if st.session_state.show_history else "📜 History"
if st.button(hist_label, use_container_width=True):
st.session_state.show_history = not st.session_state.show_history
st.rerun()
_email_short = (st.session_state.get("user_email") or "").split("@")[0]
if st.button(f"⏻ {_email_short or 'Sign out'}", use_container_width=True, help="Sign out"):
try:
from src.supabase_client import get_anon_client
get_anon_client().auth.sign_out()
except Exception:
pass
for _k in ("logged_in", "user_id", "user_email", "_prefs_loaded"):
st.session_state[_k] = False if _k == "logged_in" else None
st.rerun()
# ══════════════════════════════════════════════════════════════════════════════
# HISTORY PANEL
# ══════════════════════════════════════════════════════════════════════════════
if st.session_state.show_history:
from src.run_history import list_runs, load_run
past_runs = list_runs()
st.html('')
st.markdown("### 📜 Run History")
if not past_runs:
st.info("No saved runs yet. Complete your first search to see history here.")
else:
for run in past_runs[:20]:
imp = run.get("avg_ats_after", 0) - run.get("avg_ats_before", 0)
plat = ", ".join(run.get("platforms", []))
date = run.get("date", "")
total = run.get("total_jobs", 0)
high = run.get("high_priority", 0)
ats_b = run.get("avg_ats_before", 0)
ats_a = run.get("avg_ats_after", 0)
resumes = run.get("resumes", 0)
col_info, col_btn = st.columns([5, 1])
with col_info:
st.html(f"""
📅 {date}
{total} jobs
{high} 🔴 high
{resumes} resumes
ATS {ats_b}%→{ats_a}% (+{imp}pp)
{plat}
""")
with col_btn:
if st.button("Load", key=f"hist_{run.get('run_id','')}", use_container_width=True,
disabled=st.session_state.running):
full = load_run(run["_path"])
if full.get("jobs"):
st.session_state.results = full["jobs"]
st.session_state.excel_path = full.get("excel_path", "")
st.session_state.loaded_run = run.get("run_id", "")
st.session_state.show_history = False
st.rerun()
st.html('
')
if st.session_state.loaded_run:
st.info(f"📂 Showing results from run: **{st.session_state.loaded_run}**")
st.divider()
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION — Step-by-step wizard
# ══════════════════════════════════════════════════════════════════════════════
show_config = not (st.session_state.running or st.session_state.results)
start = False # set to True only on step 7 launch button
# ── V1/V2 generation mode selector (home page, outside wizard) ──────────
_vlabel = st.radio(
"Resume generation mode",
options=["V1 — Structured (keyword placement)", "V2 — Natural AI (sentence integration)"],
index=0 if st.session_state.get("_gen_version", "v1") == "v1" else 1,
horizontal=True, key="_gen_version_radio",
)
st.session_state["_gen_version"] = "v2" if _vlabel.startswith("V2") else "v1"
# Platform imports (needed even when not showing config, for pipeline)
from src.ever_jobs_bridge.platforms import PLATFORM_GROUPS, INDIA_DEFAULT_PLATFORMS, EVER_JOBS_PLATFORMS
SETUP_STEPS = [
{"num": 1, "label": "Resume", "icon": "📄"},
{"num": 2, "label": "Roles", "icon": "🎯"},
{"num": 3, "label": "Locations", "icon": "📍"},
{"num": 4, "label": "Freshness", "icon": "⏱"},
{"num": 5, "label": "Platforms", "icon": "🌐"},
{"num": 6, "label": "AI Score", "icon": "🤖"},
{"num": 7, "label": "Tracker", "icon": "📊"},
]
# Persistent config defaults — these survive widget keys being deleted by Streamlit
# when a widget isn't rendered on the current step.
_ROLES_DEFAULT = ["Product Manager", "Senior Product Manager", "AI Product Manager"]
_LOCS_DEFAULT = ["India", "Bangalore"]
sb_options = PLATFORM_GROUPS["Search Boards"]
sb_defaults = [p for p in INDIA_DEFAULT_PLATFORMS if p in sb_options]
# Sync: if a widget was just rendered, save its value to a persistent key.
# Streamlit will delete the widget key on the next rerun if the widget isn't shown.
for _wk, _pk, _fallback in [
("roles_select", "_cfg_roles", _ROLES_DEFAULT),
("locations_select", "_cfg_locations", _LOCS_DEFAULT),
("days_select", "_cfg_days", 7),
("max_jobs_input", "_cfg_max_jobs", 25),
("min_score_slider", "_cfg_min_score", 1),
("ej_search_boards", "_cfg_sb", sb_defaults),
("ej_ats_platforms", "_cfg_ats", []),
("ej_company_pages", "_cfg_cp", []),
]:
if _wk in st.session_state:
st.session_state[_pk] = (
list(st.session_state[_wk]) if isinstance(st.session_state[_wk], list)
else st.session_state[_wk]
)
elif _pk not in st.session_state:
st.session_state[_pk] = _fallback
def _cfg(key):
return st.session_state[key]
if show_config:
current_step = st.session_state.setup_step
_roles_val = _cfg("_cfg_roles")
_locs_val = _cfg("_cfg_locations")
_sb_val = _cfg("_cfg_sb")
_ats_val = _cfg("_cfg_ats")
_cp_val = _cfg("_cfg_cp")
_all_plats_preview = _sb_val + _ats_val + _cp_val
step_done = {
1: has_resume,
2: bool(_roles_val),
3: bool(_locs_val),
4: True,
5: bool(_all_plats_preview),
6: True,
7: sheets_ok,
}
# ── Stepper bar ──
stepper_html = ''
for i, s in enumerate(SETUP_STEPS):
n = s["num"]
if n == current_step:
dot_cls = "active"
elif step_done.get(n, False):
dot_cls = "done"
else:
dot_cls = "pending"
dot_text = "✓" if dot_cls == "done" and n != current_step else str(n)
stepper_html += f'
{dot_text}
'
if i < len(SETUP_STEPS) - 1:
line_cls = "done" if step_done.get(n, False) else "pending"
stepper_html += f'
'
stepper_html += '
'
labels_html = ''
for s in SETUP_STEPS:
n = s["num"]
if n == current_step:
lbl_cls = "active"
elif step_done.get(n, False):
lbl_cls = "done"
else:
lbl_cls = ""
labels_html += f'{s["label"]}'
labels_html += '
'
st.html(f"""
{stepper_html}
{labels_html}
""")
col_main, col_sidebar = st.columns([3, 1], gap="large")
# We need to declare all widget variables with consistent keys across reruns
# so Streamlit doesn't lose state. Hidden widgets hold values for non-active steps.
with col_main:
# ────────────────────────────────────────────────────────────────────
# STEP 1: Upload Resume
# ────────────────────────────────────────────────────────────────────
if current_step == 1:
st.html(f"""
Paste your LaTeX source for the best ATS results, or upload a PDF below.
""")
# ── PRIMARY: LaTeX paste ──────────────────────────────────────────
st.html("""
📄 LaTeX source
Recommended — paste your full .tex code below for highest ATS accuracy
""")
# Restore last-pasted LaTeX in priority order:
# 1. Session state (set after login from Supabase preferences)
# 2. Local data/resume/resume.tex (this container's last compile)
_saved_latex = st.session_state.get("_resume_tex", "") or ""
if not _saved_latex and os.path.exists("data/resume/resume.tex"):
try:
with open("data/resume/resume.tex", "r", encoding="utf-8") as _lf:
_saved_latex = _lf.read()
st.session_state["_resume_tex"] = _saved_latex
except Exception:
pass
latex_input = st.text_area(
"LaTeX code",
value=_saved_latex,
height=260,
placeholder=r"""\documentclass[11pt]{article}
% Paste your full resume LaTeX code here
\begin{document}
...
\end{document}""",
key="latex_paste_input",
label_visibility="collapsed",
)
_latex_changed = latex_input.strip() and latex_input.strip() != _saved_latex.strip()
if st.button(
"💾 Save LaTeX",
type="primary",
use_container_width=True,
disabled=not bool(latex_input and latex_input.strip()),
key="save_latex_btn",
help="Saves your LaTeX source. We'll compile it to PDF automatically when you start a job search.",
):
if latex_input and latex_input.strip():
os.makedirs("data/resume", exist_ok=True)
with open("data/resume/resume.tex", "w", encoding="utf-8") as _f:
_f.write(latex_input)
# Invalidate any previously-compiled PDF + parse cache so the
# next pipeline launch recompiles from this fresh source.
for _stale in ("data/resume/resume.pdf", "data/resume/_parsed.json"):
try:
if os.path.exists(_stale):
os.remove(_stale)
except Exception:
pass
st.session_state["_uploaded_sig"] = f"latex_paste:{len(latex_input)}"
st.session_state["_resume_tex"] = latex_input
_save_prefs()
has_resume = True
st.success("✅ LaTeX saved to your account. It will be compiled to PDF when you start a job search.")
st.rerun()
# ── SECONDARY: PDF upload ─────────────────────────────────────────
st.html("""
""")
resume_file = st.file_uploader(
"Upload PDF resume",
type=["pdf"], key="resume_upload",
label_visibility="collapsed",
help="Upload a PDF if you don't have a LaTeX source. Max 10MB.",
)
if resume_file is not None:
_sig = f"{resume_file.name}:{getattr(resume_file, 'size', 0)}"
if st.session_state.get("_uploaded_sig") != _sig:
os.makedirs("data/resume", exist_ok=True)
with open("data/resume/resume.pdf", "wb") as f:
f.write(resume_file.getvalue())
st.session_state["_uploaded_sig"] = _sig
try:
from src.supabase_client import get_service_client, is_configured, get_owner_user_id
if is_configured():
_uid = st.session_state.get("user_id") or get_owner_user_id()
if _uid:
with open("data/resume/resume.pdf", "rb") as _rf:
get_service_client().storage.from_("resumes").upload(
f"{_uid}/resume.pdf", _rf.read(),
{"upsert": "true", "content-type": "application/pdf"},
)
except Exception:
pass
try:
if os.path.exists("data/resume/_parsed.json"):
os.remove("data/resume/_parsed.json")
except Exception:
pass
has_resume = True
st.rerun()
# ── Success / profile ─────────────────────────────────────────────
_has_pdf = os.path.exists("data/resume/resume.pdf")
_has_tex = bool(st.session_state.get("_resume_tex")) or os.path.exists("data/resume/resume.tex")
if _has_pdf:
fsize = os.path.getsize("data/resume/resume.pdf") // 1024
_sig_raw = st.session_state.get("_uploaded_sig", "")
_src_label = "LaTeX source" if _sig_raw.startswith("latex_paste:") else (_sig_raw.split(":")[0] or "resume.pdf")
st.html(f"""
✅ {_src_label} → resume.pdf ({fsize} KB) — Ready for AI matching
""")
_render_uploaded_profile()
elif _has_tex:
_tex_len = len(st.session_state.get("_resume_tex", "") or "")
st.html(f"""
✅ LaTeX saved ({_tex_len} chars) — Will compile when you start the job search
""")
else:
st.caption("Paste your LaTeX or upload a PDF to unlock AI matching.")
# ────────────────────────────────────────────────────────────────────
# STEP 2: Target Roles
# ────────────────────────────────────────────────────────────────────
if current_step == 2:
st.html("""
Select up to 5 roles for more focused results. We'll search all selected roles across every platform.
""")
if current_step == 2:
_BASE_ROLES = [
"Product Manager", "Senior Product Manager", "Lead Product Manager",
"Principal Product Manager", "Group Product Manager", "Associate Product Manager",
"AI Product Manager", "Technical Product Manager", "Data Product Manager",
"Platform Product Manager", "Growth Product Manager", "SaaS Product Manager",
"B2B Product Manager", "B2C Product Manager", "Mobile Product Manager",
"Payments Product Manager", "Fintech Product Manager", "E-commerce Product Manager",
"Product Owner", "Technical Product Owner", "Senior Product Owner",
"Head of Product", "Director of Product", "VP of Product",
"Product Lead", "Program Manager", "Project Manager",
"Business Analyst", "Product Analyst", "Product Operations Manager",
"Product Marketing Manager", "Chief Product Officer",
]
# Merge user-added custom roles so they're selectable + survive reruns
custom = st.session_state.get("custom_roles", []) or []
role_options = _BASE_ROLES + [c for c in custom if c not in _BASE_ROLES]
# ── Add a custom role ──
ca, cb = st.columns([4, 1])
with ca:
_new_role = st.text_input(
"Add a custom role",
key="new_role_input",
placeholder="e.g. Conversational AI Product Manager",
label_visibility="collapsed",
)
with cb:
if st.button("➕ Add role", use_container_width=True, key="add_role_btn"):
nr = (_new_role or "").strip()
if nr and nr not in role_options:
st.session_state.custom_roles = custom + [nr]
# Pre-select the new role
sel = list(st.session_state.get("roles_select", _roles_val))
if nr not in sel:
sel.append(nr)
st.session_state.roles_select = sel
st.rerun()
roles = st.multiselect(
"Target roles",
options=role_options,
default=[r for r in _roles_val if r in role_options],
key="roles_select",
label_visibility="collapsed",
)
if roles:
st.html(f'🎯 Great focus — {len(roles)} target role{"s" if len(roles)!=1 else ""} selected
')
st.caption("Don't see your role? Type it above and click **Add role** — it'll be searched too.")
else:
roles = _roles_val
# ────────────────────────────────────────────────────────────────────
# STEP 3: Locations
# ────────────────────────────────────────────────────────────────────
if current_step == 3:
st.html("""
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("""
Lower values make results more focused. Higher values increase coverage.
""")
fc1, fc2 = st.columns(2)
with fc1:
_DAYS_OPTS = [0.25, 1, 3, 7, 14, 30]
def _fmt_days(x):
if x < 1:
return f"Last {int(round(x * 24))} hours"
if x == 1:
return "Last 1 day"
return f"Last {int(x)} days"
_cur = _cfg("_cfg_days")
_days_idx = _DAYS_OPTS.index(_cur) if _cur in _DAYS_OPTS else 3 # default 7
days_posted = st.selectbox(
"Only show jobs posted within",
options=_DAYS_OPTS,
index=_days_idx,
format_func=_fmt_days,
key="days_select",
)
with fc2:
max_jobs = st.number_input(
"Maximum jobs per platform",
min_value=5, max_value=100, value=_cfg("_cfg_max_jobs"), step=5,
help="Total jobs to fetch from each platform (not per query)",
key="max_jobs_input",
)
else:
days_posted = _cfg("_cfg_days")
max_jobs = _cfg("_cfg_max_jobs")
# ────────────────────────────────────────────────────────────────────
# STEP 5: Job Platforms
# ────────────────────────────────────────────────────────────────────
sb_count = len(PLATFORM_GROUPS["Search Boards"])
ats_count = len(PLATFORM_GROUPS["ATS Platforms"])
cp_count = len(PLATFORM_GROUPS["Company Pages"])
total_platforms = sb_count + ats_count + cp_count
if current_step == 5:
st.html(f"""
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("""
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"""
Save all discovered jobs into a Google Sheet for easy tracking and sharing.
""")
if sheets_ok:
st.html(f'✅ {sheets_status}
')
from config import GOOGLE as _G
st.caption(f"Sheet: `{_G['sheet_id'][:20]}…` · Tab: `{_G['sheet_tab']}`")
else:
st.html(f"""
⚠ {sheets_status}. Results will be saved locally.
""")
with st.expander("🔧 Advanced setup"):
st.markdown("""
1. Create a Google service account at [console.cloud.google.com](https://console.cloud.google.com)
2. Download the JSON credentials file
3. Save as `google_credentials.json` in the project root
4. Share your Google Sheet with the service account email
Or run `python connect_google.py` for OAuth-based setup.
""")
# Review summary
st.markdown("---")
st.html(f"""
Review your search
Roles{', '.join(roles[:3])}{"…" if len(roles)>3 else ""}
Locations{', '.join(locations[:3])}{"…" if len(locations)>3 else ""}
Platforms{len(ever_jobs_platforms)} selected
Freshness{("Last " + str(int(round(days_posted*24))) + " hours") if days_posted < 1 else ("Last 1 day" if days_posted == 1 else "Last " + str(int(days_posted)) + " days")}
Max / platform{max_jobs}
AI match score{min_score}/10
Google Sheet{"✅ Connected" if sheets_ok else "⚠ Not connected"}
""")
# ────────────────────────────────────────────────────────────────────
# NAVIGATION BUTTONS
# ────────────────────────────────────────────────────────────────────
st.markdown("---")
nav_l, nav_c, nav_r = st.columns([1, 2, 1])
with nav_l:
if current_step > 1:
st.markdown('', unsafe_allow_html=True)
if st.button(f"← Back", use_container_width=True, key="nav_back"):
st.session_state.setup_step = current_step - 1
_save_prefs()
st.rerun()
st.markdown('
', unsafe_allow_html=True)
with nav_c:
step_label = SETUP_STEPS[current_step - 1]["label"]
st.html(f"""
Step {current_step} of {len(SETUP_STEPS)} · {step_label}
""")
with nav_r:
if current_step < len(SETUP_STEPS):
if st.button(f"Next →", use_container_width=True, type="primary", key="nav_next"):
st.session_state.setup_step = current_step + 1
_save_prefs()
st.rerun()
# ────────────────────────────────────────────────────────────────────
# RIGHT SIDEBAR — Run Readiness Panel
# ────────────────────────────────────────────────────────────────────
with col_sidebar:
readiness = _readiness_score(has_resume, roles, locations, ever_jobs_platforms, sheets_ok, min_score)
level_name, is_gold = _readiness_level(readiness)
st.html(f"""
Run Readiness
{readiness}%
Setup completeness
{level_name}
""")
# Checklist — clicking a step jumps to it
checks = [
(1, has_resume, "Resume uploaded", "Upload resume"),
(2, bool(roles), f"{len(roles)} role{'s' if len(roles)!=1 else ''} selected", "Select target roles"),
(3, bool(locations), f"{len(locations)} location{'s' if len(locations)!=1 else ''} set", "Choose locations"),
(5, bool(ever_jobs_platforms), f"{len(ever_jobs_platforms)} platform{'s' if len(ever_jobs_platforms)!=1 else ''} active", "Select platforms"),
(7, sheets_ok, "Tracker connected", "Connect Google Sheet"),
(6, min_score is not None, f"Match score: {min_score}/10", "Set match score"),
]
checklist_html = ""
for step_num, done, done_label, pending_label in checks:
icon_cls = "check-done" if done else "check-pending"
icon_txt = "✓" if done else ""
label_cls = "done" if done else "pending"
label = done_label if done else pending_label
checklist_html += f"""
"""
pending_count = sum(1 for _, d, _, _ in checks if not d)
if pending_count > 0:
cta_copy = f"Complete {pending_count} more step{'s' if pending_count!=1 else ''} to unlock a better search."
else:
cta_copy = "You're all set! Go to Step 7 to launch."
st.html(f"""
{checklist_html}
{cta_copy}
""")
# Achievement badges
badges_html = ""
badge_defs = [
(has_resume, "📄 Resume Ready"),
(len(roles) >= 2, "🎯 Role Focused"),
(len(ever_jobs_platforms) >= 5, "🌐 Platform Explorer"),
(sheets_ok, "📊 Tracker Connected"),
(readiness >= 100, "⚡ Power Search"),
]
for earned, label in badge_defs:
cls = "badge-earned" if earned else "badge-locked"
badges_html += f'{label}'
st.html(f"""
Achievements
{badges_html}
""")
# Restart — wipe wizard state and return to step 1
st.markdown("---")
if st.button("↺ Start over", use_container_width=True,
help="Clear all settings and restart from step 1"):
_reset_keys = [sk for _, sk, _ in _PREF_MAP]
for _rk in _reset_keys:
if _rk in st.session_state:
del st.session_state[_rk]
st.session_state["setup_step"] = 1
_save_prefs()
st.rerun()
# Start Search CTA — always visible in sidebar
st.markdown("---")
can_start = has_resume and bool(roles) and bool(locations) and bool(ever_jobs_platforms)
start = st.button(
"🚀 Start AI Job Search" if not st.session_state.running else "⏳ Running…",
disabled=st.session_state.running or not can_start,
use_container_width=True,
type="primary",
key="start_btn",
)
if not can_start:
missing = []
if not has_resume: missing.append("resume")
if not roles: missing.append("roles")
if not locations: missing.append("locations")
if not ever_jobs_platforms: missing.append("platforms")
st.caption(f"Missing: {', '.join(missing)}")
else:
roles = st.session_state.get("_last_roles", _cfg("_cfg_roles"))
locations = st.session_state.get("_last_locations", _cfg("_cfg_locations"))
days_posted = st.session_state.get("_last_days", _cfg("_cfg_days"))
max_jobs = st.session_state.get("_last_max_jobs", _cfg("_cfg_max_jobs"))
min_score = st.session_state.get("_last_min_score", _cfg("_cfg_min_score"))
ever_jobs_platforms = st.session_state.get("_last_platforms", _cfg("_cfg_sb") + _cfg("_cfg_ats") + _cfg("_cfg_cp"))
start = False
# ══════════════════════════════════════════════════════════════════════════════
# START BUTTON (also shown at top of results for re-running)
# ══════════════════════════════════════════════════════════════════════════════
if not show_config and not st.session_state.running and st.session_state.results:
with st.columns([1, 2, 1])[1]:
st.markdown('', unsafe_allow_html=True)
if st.button("🔄 New Search", use_container_width=True, key="new_search_btn"):
st.session_state.results = None
st.session_state.loaded_run = ""
st.session_state.progress_pct = 0
st.session_state.steps = {}
st.rerun()
st.markdown('
', unsafe_allow_html=True)
# Progress placeholders (always declared)
progress_placeholder = st.empty()
steps_placeholder = st.empty()
log_placeholder = st.empty()
done_placeholder = st.empty()
# ══════════════════════════════════════════════════════════════════════════════
# LAUNCH PIPELINE
# ══════════════════════════════════════════════════════════════════════════════
if show_config and start and not st.session_state.running:
# Compile-on-demand: if the user pasted LaTeX but hasn't compiled yet,
# compile it now (this is the moment we actually need a PDF).
if not os.path.exists("data/resume/resume.pdf"):
_saved_tex = st.session_state.get("_resume_tex", "") or ""
if not _saved_tex and os.path.exists("data/resume/resume.tex"):
try:
with open("data/resume/resume.tex", "r", encoding="utf-8") as _lf:
_saved_tex = _lf.read()
except Exception:
_saved_tex = ""
if _saved_tex and _saved_tex.strip():
import tempfile as _tmpfile, shutil as _sh
from src.latex_resume import compile_latex_to_pdf
with st.spinner("Compiling your LaTeX resume… (first run can take 1–2 min while packages download)"):
with _tmpfile.TemporaryDirectory() as _td:
_res = compile_latex_to_pdf(_saved_tex, _td, jobname="resume", timeout=420)
_pdf = _res.get("pdf_path")
if _res.get("compiled") and _pdf and os.path.exists(_pdf):
os.makedirs("data/resume", exist_ok=True)
_sh.copy(_pdf, "data/resume/resume.pdf")
try:
from src.supabase_client import get_service_client, is_configured, get_owner_user_id
if is_configured():
_uid = st.session_state.get("user_id") or get_owner_user_id()
if _uid:
with open("data/resume/resume.pdf", "rb") as _rf:
get_service_client().storage.from_("resumes").upload(
f"{_uid}/resume.pdf", _rf.read(),
{"upsert": "true", "content-type": "application/pdf"},
)
except Exception:
pass
else:
_log = (_res.get("log") or "")[-1500:]
st.error(
"LaTeX compilation failed when starting the job search.\n\n"
"Go back to step 1, fix your LaTeX, and click Save LaTeX again.\n\n"
f"--- Compiler log (last 1500 chars) ---\n{_log}"
)
st.stop()
if not os.path.exists("data/resume/resume.pdf"):
st.error("Please upload your resume first.")
elif not roles:
st.error("Please select at least one target role.")
elif not locations:
st.error("Please select at least one location.")
elif not ever_jobs_platforms:
st.error("Please select at least one platform.")
else:
# Save config to session state for display during run
st.session_state["_last_roles"] = roles
st.session_state["_last_locations"] = locations
st.session_state["_last_days"] = days_posted
st.session_state["_last_max_jobs"] = max_jobs
st.session_state["_last_min_score"] = min_score
st.session_state["_last_platforms"] = ever_jobs_platforms
st.session_state.running = True
st.session_state.results = None
st.session_state.loaded_run = ""
st.session_state.log_msgs = []
st.session_state.completed_jobs = []
st.session_state.progress_pct = 0
st.session_state.progress_label = "Starting…"
st.session_state.steps = {s["id"]: {"status": "pending", "detail": "", "elapsed": ""}
for s in PIPELINE_STEPS}
platforms_cfg = {
"all_platforms": ever_jobs_platforms,
}
job_search_cfg = {
"roles": roles, "locations": locations,
"days_posted": days_posted, "max_jobs_per_platform": max_jobs,
}
output_cfg = {
"excel_path": "data/output/reports/job_report.xlsx",
"resumes_dir": "data/output/resumes/",
}
# ── Pipeline thread ──
def run_pipeline(_min_score=min_score,
_platforms=platforms_cfg, _jscfg=job_search_cfg, _ocfg=output_cfg,
_q=_progress_q):
import time as _t, traceback as _tb
_progress_q = _q
def _q_log(msg): _q.put(("log", msg))
def _q_progress(pct, lbl=""): _q.put(("progress", pct, lbl))
try:
import sys as _sys
_sys.stdout.reconfigure(encoding="utf-8", errors="replace")
_sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from datetime import datetime as _dt
run_id = _dt.now().strftime("%Y-%m-%d_%H-%M-%S")
log_path = app_logger.setup(run_id)
log = logging.getLogger("pipeline")
log.info("=" * 60)
log.info(f"Pipeline start run_id={run_id}")
log.info(f"Platforms: {_platforms}")
log.info(f"Roles: {_jscfg.get('roles')}")
log.info(f"Locations: {_jscfg.get('locations')}")
_q_log(f"📝 Log: {log_path}")
_q.put(("logfile", log_path))
def _step_start(sid, detail=""):
_q.put(("step", sid, "active", detail, ""))
log.info(f"[START] {_STEP_TITLE_MAP.get(sid,sid)}: {detail}")
_q_log(f"⏳ {_STEP_TITLE_MAP.get(sid,sid)}: {detail}")
def _step_done(sid, detail="", t0=None):
elapsed = f"{_t.time()-t0:.1f}s" if t0 else ""
_q.put(("step", sid, "done", detail, elapsed))
log.info(f"[DONE] {_STEP_TITLE_MAP.get(sid,sid)}: {detail} ({elapsed})")
def _step_err(sid, detail=""):
_q.put(("step", sid, "error", detail, ""))
log.error(f"[ERR] {_STEP_TITLE_MAP.get(sid,sid)}: {detail}")
_q_log(f"❌ {sid}: {detail}")
def _step_skip(sid):
_q.put(("step", sid, "skip", "Disabled", ""))
log.info(f"[SKIP] {sid}")
try:
from src.resume_parser import ResumeParser
from src.llm_client import LLMClient
from src.model_pool import ModelPool
from src.job_assessor import JobAssessor
from src.resume_customizer import ResumeCustomizer
from src.excel_reporter import ExcelReporter
from config import ASSESSMENT_MODELS
from src.job_history import is_duplicate, bulk_mark_seen
# ── Step 1: Parse resume ──
t0 = _t.time()
_step_start("resume", "Reading PDF…")
_q_progress(4, "Parsing resume…")
parser = ResumeParser("data/resume/resume.pdf")
resume_text = parser.parse()
_step_done("resume", f"{len(resume_text):,} chars", t0)
_q_log(f"✅ Resume parsed — {len(resume_text):,} chars")
# ── Step 2: Profile ──
t0 = _t.time()
fast_cfg = next(
(m for m in ASSESSMENT_MODELS
if m.get("phase2") and m.get("api_key")
and m["name"] in ("Kimi-K2.6", "Step-3.7-Flash", "Qwen3.5-397b")),
None,
)
model_label = fast_cfg["name"] if fast_cfg else "GLM-5.1"
_step_start("profile", f"Using {model_label}…")
_q_progress(8, f"Building profile with {model_label}…")
llm = LLMClient()
if fast_cfg:
profile_json = llm.extract_profile_summary_fast(fast_cfg, resume_text)
else:
profile_json = llm.extract_profile_summary(resume_text)
compact_profile = llm.build_compact_profile(profile_json)
try:
pd_data = json.loads(profile_json)
name = pd_data.get("name", "")
role_c = pd_data.get("current_role", "")
yrs = pd_data.get("total_experience_years", "")
skills = ", ".join(pd_data.get("core_skills", [])[:5])
_step_done("profile", f"{name} · {role_c} · {yrs} yrs", t0)
_q_log(f"✅ Profile: {name} | {role_c} | {yrs} yrs")
_q_log(f" Skills: {skills}")
except Exception:
_step_done("profile", "Profile extracted", t0)
_q_log("✅ Profile extracted")
# ── Step 3+: Scraping ──
_q_progress(12, "Scraping job boards…")
all_jobs: list = []
seen_urls: set = set()
seen_tc: set = set()
skipped_dup: int = 0
_all_plats = set(_platforms.get("all_platforms", []))
_legacy_keys = {"linkedin", "indeed", "glassdoor", "remotive",
"weworkremotely", "naukri", "company_ats"}
scraper_map = {}
if "linkedin" in _all_plats:
from src.scrapers.linkedin import LinkedInScraper
scraper_map["linkedin"] = ("LinkedIn", LinkedInScraper())
else:
_step_skip("linkedin")
if "indeed" in _all_plats:
from src.scrapers.indeed import IndeedScraper
scraper_map["indeed"] = ("Indeed", IndeedScraper())
else:
_step_skip("indeed")
if "glassdoor" in _all_plats:
from src.scrapers.glassdoor import GlassdoorScraper
scraper_map["glassdoor"] = ("Glassdoor", GlassdoorScraper())
else:
_step_skip("glassdoor")
if "remotive" in _all_plats:
from src.scrapers.remotive import RemotiveScraper
scraper_map["remotive"] = ("Remotive", RemotiveScraper())
else:
_step_skip("remotive")
if "weworkremotely" in _all_plats:
from src.scrapers.weworkremotely import WeWorkRemotelyScraper
scraper_map["weworkremotely"] = ("WeWorkRemotely", WeWorkRemotelyScraper())
else:
_step_skip("weworkremotely")
if "naukri" in _all_plats:
from src.scrapers.naukri import NaukriScraper
scraper_map["naukri"] = ("Naukri", NaukriScraper())
else:
_step_skip("naukri")
# Direct-company ATS boards (Greenhouse/Lever/Ashby). Public JSON
# APIs that rarely IP-block — the most reliable bulk source on HF.
if "company_ats" in _all_plats:
from src.scrapers.company_ats import CompanyATSScraper
scraper_map["company_ats"] = ("CompanyATS", CompanyATSScraper())
_q_log("🏢 Direct Company ATS enabled (Greenhouse/Lever/Ashby — no IP blocks)")
else:
_step_skip("company_ats")
_ej_platforms = [p for p in _all_plats if p not in _legacy_keys]
if _ej_platforms:
# The 160+ "ever-jobs" platforms need the NestJS sidecar on
# :3001. If it isn't up, be LOUD about it (it silently
# returned 0 before, so users saw only LinkedIn results).
from src.scrapers.ever_jobs import EverJobsScraper
from src.ever_jobs_bridge.server import is_running as _ej_health
if _ej_health():
scraper_map["ever_jobs"] = ("EverJobs", EverJobsScraper(_ej_platforms))
_q_log(f"🌐 ever-jobs sidecar UP — {len(_ej_platforms)} extra platforms enabled")
else:
import os as _os
_hosted = bool(_os.getenv("EVER_JOBS_API_URL"))
if _hosted:
_q_log(f"⚠ ever-jobs sidecar unreachable at {_os.getenv('EVER_JOBS_API_URL')} "
f"— {len(_ej_platforms)} extra platforms SKIPPED. Check the hosted "
f"sidecar is running. Direct scrapers (LinkedIn etc.) still run.")
else:
_q_log(f"⚠ ever-jobs sidecar DOWN — {len(_ej_platforms)} extra platforms "
f"(Greenhouse/Lever/Google/Foundit/etc.) SKIPPED. These need the "
f"Node sidecar, which can't run inside HF Spaces. To enable them, host "
f"ever-jobs elsewhere and set the EVER_JOBS_API_URL secret. "
f"Direct scrapers (LinkedIn/Indeed/Glassdoor/Remotive/WWR) still run.")
_step_skip("ever_jobs")
else:
_step_skip("ever_jobs")
scrape_pct = 12
pct_per_plat = 35 / max(1, len(scraper_map))
MAX_PER_PLAT = _jscfg["max_jobs_per_platform"]
_days_posted = _jscfg.get("days_posted", 7)
_sel_locs = _jscfg.get("locations", [])
from src.geo_filter import location_allowed
# Thread the freshness window into every scraper that supports it.
for _pid, (_pn, _sc) in scraper_map.items():
try:
_sc._days_posted = _days_posted
except Exception:
pass
_geo_dropped = 0
for plat_id, (pname, scraper) in scraper_map.items():
t0 = _t.time()
n_roles = len(_jscfg["roles"])
n_locs = len(_jscfg["locations"])
_step_start(plat_id, f"Searching {n_roles} roles × {n_locs} locations…")
platform_jobs: list = []
outer_done = False
for ri, role_q in enumerate(_jscfg["roles"]):
if outer_done:
break
for loc in _jscfg["locations"]:
if len(platform_jobs) >= MAX_PER_PLAT:
outer_done = True
break
per_query = max(5, MAX_PER_PLAT - len(platform_jobs))
try:
log.info(f"Scraping {pname}: role={role_q!r} loc={loc!r}")
raw = scraper.search(role_q, loc, max_results=per_query)
log.info(f" → {len(raw)} raw results")
for j in raw:
if len(platform_jobs) >= MAX_PER_PLAT:
break
if not j.url or j.url in seen_urls:
continue
tc_key = (j.title.lower().strip(), j.company.lower().strip())
if tc_key in seen_tc:
skipped_dup += 1
continue
if not scraper.is_pm_role(j.title):
continue
if not location_allowed(j.location, _sel_locs):
_geo_dropped += 1
continue
if is_duplicate(j.url, days=30):
skipped_dup += 1
continue
seen_urls.add(j.url)
seen_tc.add(tc_key)
platform_jobs.append(j)
except Exception as e:
full_tb = _tb.format_exc()
log.error(f"{pname} error ({role_q}/{loc}): {e}\n{full_tb}")
_q_log(f"⚠ {pname} ({role_q}): {str(e)[:80]}")
_t.sleep(0.5)
_q.put(("step", plat_id, "active",
f"Role {ri+1}/{n_roles} — {len(platform_jobs)} jobs so far", ""))
needs_desc = [j for j in platform_jobs if not j.description]
if needs_desc and hasattr(scraper, "get_details_bulk"):
_q.put(("step", plat_id, "active",
f"Fetching {len(needs_desc)} descriptions…", ""))
def _dcb(done, tot, _pid=plat_id):
_q.put(("step", _pid, "active", f"Descriptions: {done}/{tot}", ""))
try:
scraper.get_details_bulk(platform_jobs, progress_cb=_dcb)
except Exception as e:
log.error(f"{pname} bulk details error: {e}\n{_tb.format_exc()}")
n_desc = sum(1 for j in platform_jobs if j.description)
all_jobs.extend(platform_jobs)
scrape_pct += pct_per_plat
_q_progress(int(scrape_pct), f"{pname}: {len(platform_jobs)} jobs")
_step_done(plat_id,
f"{len(platform_jobs)} PM jobs · {n_desc} with JD", t0)
_q_log(f"✅ {pname}: {len(platform_jobs)} jobs ({n_desc} with JD) "
f"| {skipped_dup} dupes skipped")
if _geo_dropped:
_q_log(f"📍 Filtered out {_geo_dropped} job(s) outside your selected "
f"locations ({', '.join(_sel_locs[:4])}{'…' if len(_sel_locs) > 4 else ''}).")
_q_log(f"✅ Total unique jobs: {len(all_jobs)}")
if not all_jobs:
_q_log("❌ No jobs found. Check internet or platform settings.")
_q.put(("error", "No jobs found"))
return
# ── Assess ──
t0 = _t.time()
_step_start("assess", f"Scoring {len(all_jobs)} jobs…")
_q_progress(50, f"AI assessing {len(all_jobs)} jobs…")
_q_log(f"🤖 AI assessing {len(all_jobs)} jobs…")
model_pool = ModelPool(ASSESSMENT_MODELS)
assessor = JobAssessor(model_pool, compact_profile)
assessed_jobs = assessor.assess_all(all_jobs)
bulk_mark_seen(all_jobs)
high = sum(1 for j in assessed_jobs if j.get("relevance_score", 0) >= 8)
med = sum(1 for j in assessed_jobs if 6 <= j.get("relevance_score", 0) <= 7)
low = sum(1 for j in assessed_jobs if j.get("relevance_score", 0) < 6)
_step_done("assess", f"🔴 {high} 🟡 {med} ⚪ {low}", t0)
_q_progress(78, "Assessment complete!")
_q_log(f"✅ Assessment done — High: {high}, Good: {med}, Low: {low}")
# ── Generate resumes ──
t0 = _t.time()
llm_elig = sum(1 for j in assessed_jobs if j.get("relevance_score",0) >= _min_score)
phase2_cfgs = [m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key")]
_step_start("resumes", f"Tailoring {llm_elig} LLM + {len(assessed_jobs)-llm_elig} template…")
_q_progress(80, "Generating ATS-optimized resumes…")
_q_log(f"📝 {llm_elig} LLM resumes (score≥{_min_score}) + {len(assessed_jobs)-llm_elig} templates")
def _resume_cb(done, tot, msg, job=None):
pct = 80 + int(14 * done / max(1, tot))
_q_progress(pct, f"Resumes: {done}/{tot}")
_q.put(("step", "resumes", "active", f"{done}/{tot} — {msg[:70]}", ""))
_q_log(f" {msg[:100]}")
# Push the completed job so the UI shows it immediately with a
# download button (apply while the rest keep generating).
if job is not None:
_q.put(("job_done", {
"title": job.get("title", ""),
"company": job.get("company", ""),
"location": job.get("location", ""),
"relevance_score": job.get("relevance_score", 0),
"ats_score_before": job.get("ats_score_before", 0),
"ats_score_after": job.get("ats_score_after", 0),
"resume_path": job.get("resume_path", ""),
"job_url": job.get("job_url", job.get("url", "")),
# v2 status pipeline (anti-circular scores + gating)
"status": job.get("status", ""),
"quality_flag": job.get("quality_flag", ""),
"jd_match": job.get("jd_match", 0),
"independent_jd_match": job.get("independent_jd_match", 0),
"ats_readability": job.get("ats_readability", 0),
"download_allowed": job.get("download_allowed", False),
"review_terms": job.get("review_terms", []),
}))
# ── V2 bulk: sentence-integration pipeline per job ──────────
_bulk_version = st.session_state.get("_gen_version", "v1")
_v2_bulk_done = False
if _bulk_version == "v2":
_q_log("📝 V2 mode: generating sentence-based resumes per job…")
try:
from src.default_resume import get_default_resume_latex
from src.resume_v2_natural import generate_v2
_v2_latex = get_default_resume_latex()
_v2_count = 0
for _j in assessed_jobs:
if not _j.get("jd_text"):
continue
try:
_v2_dir = os.path.join(_ocfg["resumes_dir"], f"v2_{_v2_count}")
os.makedirs(_v2_dir, exist_ok=True)
_v2r = generate_v2(
_v2_latex, _j["jd_text"],
job_title=_j.get("title", ""),
company=_j.get("company", ""),
out_dir=_v2_dir, compile_pdf=True,
)
_j["resume_path"] = _v2r.get("pdf_path") or ""
_j["ats_score"] = _v2r.get("pct", 0)
_v2_count += 1
_q_log(f" V2 #{_v2_count}: {_j.get('title', '')} — {_v2r.get('pct', 0)}%")
except Exception as _v2e:
_q_log(f" V2 failed for {_j.get('title', '')}: {_v2e}")
_q_log(f"✅ V2 generated {_v2_count} resumes")
_v2_bulk_done = True
except Exception as _v2_exc:
_q_log(f"⚠️ V2 bulk failed, falling back to V1: {_v2_exc}")
if not _v2_bulk_done:
customizer = ResumeCustomizer(llm, resume_text, _ocfg["resumes_dir"],
fast_model_cfg=fast_cfg)
assessed_jobs = customizer.customize_for_jobs(
assessed_jobs,
min_score_for_llm=_min_score,
max_llm_resumes=len(assessed_jobs),
generate_all=True,
model_cfgs=phase2_cfgs,
progress_cb=_resume_cb,
)
llm_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "LLM Tailored")
tmpl_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "Template")
pdf_done = sum(1 for j in assessed_jobs if j.get("resume_pdf_path"))
ats_vals = [j.get("ats_score_after") for j in assessed_jobs if j.get("ats_score_after")]
avg_ats = f" · avg ATS: {sum(ats_vals)//len(ats_vals)}%" if ats_vals else ""
_step_done("resumes", f"{llm_done} LLM + {tmpl_done} template · {pdf_done} PDFs{avg_ats}", t0)
_q_progress(95, "Resumes ready!")
_q_log(f"✅ {llm_done} LLM resumes + {tmpl_done} templates · {pdf_done} PDFs{avg_ats}")
# ── Early history checkpoint ──
# Save resumes + scores to history NOW (before the slower
# Sheets/Excel steps) so a page refresh can't lose the work.
try:
from src.run_history import save_run as _save_early
_save_early(assessed_jobs, {
"run_id": run_id,
"excel_path": "",
"platforms": list(_platforms.get("all_platforms", [])),
"roles": _jscfg.get("roles", []),
})
_q_log("✅ Checkpoint saved to history (resumes safe)")
except Exception as _ce:
log.warning(f"Early history checkpoint failed: {_ce}")
# ── Report ──
t0 = _t.time()
_step_start("report", "Writing Excel + Google Sheet…")
_q_progress(97, "Saving report…")
reporter = ExcelReporter(_ocfg["excel_path"])
excel_path = reporter.generate(assessed_jobs)
sheet_msg = ""
try:
from src.gsheets import write_jobs_to_sheet
from config import GOOGLE
ok = write_jobs_to_sheet(
assessed_jobs, sheet_id=GOOGLE["sheet_id"],
tab_name=GOOGLE["sheet_tab"],
batch_label=_dt.now().strftime("%Y-%m-%d %H:%M"),
)
if ok:
sheet_url = f"https://docs.google.com/spreadsheets/d/{GOOGLE['sheet_id']}/edit"
_q_log(f"✅ Google Sheet updated — {sheet_url}")
sheet_msg = f" · [Sheet]({sheet_url})"
else:
_q_log("⚠ Google Sheet: write returned False — check logs for details")
except FileNotFoundError as fe:
_q_log(f"⚠ Google Sheet skipped: credentials not found. "
f"Run setup_google.py to configure.")
log.warning(f"Sheet auth missing: {fe}")
except Exception as e:
_q_log(f"⚠ Google Sheet error: {str(e)[:120]}")
log.error(f"Sheet write failed: {e}\n{_tb.format_exc()}")
_step_done("report", f"Excel ready{sheet_msg}", t0)
_q_progress(100, "All done! ✅")
_q_log(f"✅ Excel: {excel_path}")
_q_log(f"✅ Resumes folder: {_ocfg['resumes_dir']}")
try:
from src.run_history import save_run
save_run(assessed_jobs, {
"run_id": run_id,
"excel_path": excel_path,
"platforms": list(_platforms.get("all_platforms", [])),
"roles": _jscfg.get("roles", []),
})
_q_log("✅ Run saved to history")
except Exception as e:
log.warning(f"History save failed: {e}")
_q.put(("done", assessed_jobs, excel_path, run_id))
except Exception as e:
full_tb = _tb.format_exc()
log.error(f"Pipeline FATAL: {e}\n{full_tb}")
_q_log(f"❌ Pipeline error: {e}")
for chunk in [full_tb[i:i+150] for i in range(max(0, len(full_tb)-600), len(full_tb), 150)]:
_q_log(chunk)
_q.put(("error", str(e)))
thread = threading.Thread(target=run_pipeline, daemon=True)
thread.start()
st.rerun()
# ── Drain progress queue ─────────────────────────────────────────────────────
if st.session_state.running:
while True:
try:
item = _progress_q.get_nowait()
kind = item[0]
if kind == "log":
st.session_state.log_msgs.append(item[1])
elif kind == "logfile":
st.session_state.current_log_file = item[1]
elif kind == "progress":
st.session_state.progress_pct = item[1]
st.session_state.progress_label = item[2] if len(item) > 2 else ""
elif kind == "step":
_, sid, status, detail, elapsed = item
if not isinstance(st.session_state.steps, dict):
st.session_state.steps = {}
st.session_state.steps[sid] = {"status": status, "detail": detail, "elapsed": elapsed}
elif kind == "job_done":
if not isinstance(st.session_state.completed_jobs, list):
st.session_state.completed_jobs = []
st.session_state.completed_jobs.append(item[1])
elif kind == "done":
st.session_state.results = item[1]
st.session_state.excel_path = item[2] if len(item) > 2 else ""
st.session_state.loaded_run = item[3] if len(item) > 3 else ""
st.session_state.running = False
break
elif kind == "error":
st.session_state.running = False
break
except queue.Empty:
break
# ── Render progress ──────────────────────────────────────────────────────────
if st.session_state.running or (st.session_state.progress_pct and st.session_state.results is None):
pct = st.session_state.progress_pct
lbl = st.session_state.progress_label
progress_placeholder.progress(pct / 100, text=f"**{pct}%** — {lbl}" if lbl else f"**{pct}%**")
steps_state = st.session_state.get("steps", {})
if isinstance(steps_state, dict) and steps_state:
steps_placeholder.html(_render_steps(steps_state))
if st.session_state.log_msgs:
log_placeholder.html(
'Live Log
' +
_render_log(st.session_state.log_msgs)
)
# ── Live "Ready to Apply" — show each resume as it completes ──
# Lets the user start applying while the rest keep generating.
_done_jobs = st.session_state.get("completed_jobs", [])
if _done_jobs:
# Status tiers (spec: READY first, then REVIEW, then NEEDS_INPUT, LOW_FIT last)
_TIER = {
"READY_90_PLUS": (0, "✅ Ready (90%+)", "#16A34A"),
"READY_90_PLUS_REVIEW_RECOMMENDED": (1, "🟡 Ready · review added skills", "#CA8A04"),
"NEEDS_REPAIR": (2, "🔧 Below 90 — needs work", "#DC2626"),
"NEEDS_USER_INPUT": (3, "❓ Needs your input", "#9333EA"),
"NOT_ELIGIBLE_LOW_FIT": (4, "⚪ Low fit", "#64748B"),
"PARSE_FAILED": (5, "⚠ Export problem", "#DC2626"),
}
_DOWNLOADABLE = {"READY_90_PLUS", "READY_90_PLUS_REVIEW_RECOMMENDED"}
def _tier_key(j):
return _TIER.get(j.get("status", ""), (2, "", "#64748B"))[0]
_ready = sum(1 for j in _done_jobs if j.get("status") in _DOWNLOADABLE)
st.markdown(f"#### ✅ Ready to apply now — {_ready}/{len(_done_jobs)} at 90%+")
st.caption("Sorted by readiness. Only 90%+ resumes are downloadable; "
"review-recommended ones list the added skills to check first.")
for _i, _j in enumerate(sorted(_done_jobs, key=_tier_key)):
_rp = _j.get("resume_path", "")
_status = _j.get("status", "")
_order, _label, _color = _TIER.get(_status, (2, _status or "—", "#64748B"))
_jd = _j.get("jd_match", _j.get("ats_score_after", 0))
_rd = _j.get("ats_readability", 0)
_can_dl = _status in _DOWNLOADABLE
c1, c2, c3 = st.columns([5, 2, 2])
with c1:
_scoreline = (f"JD match {_jd}% · ATS readability {_rd}%"
if _rd else f"ATS {_j.get('ats_score_after',0)}%")
st.markdown(
f"**{_j.get('title','')}** · {_j.get('company','')} \n"
f"{_label}"
f" · {_scoreline}",
unsafe_allow_html=True,
)
_rev = _j.get("review_terms", [])
if _status == "READY_90_PLUS_REVIEW_RECOMMENDED" and _rev:
st.caption("⚠ Review these added skills before applying: "
+ ", ".join(dict.fromkeys(_rev))[:200])
with c2:
if _can_dl and _rp and os.path.exists(_rp):
with open(_rp, "rb") as _f:
st.download_button(
"⬇ DOCX", _f.read(),
os.path.basename(_rp),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
key=f"live_dl_{_i}_{_j.get('company','')[:10]}",
use_container_width=True,
)
elif not _can_dl:
st.caption("not 90%+ yet")
with c3:
_url = _j.get("job_url", "")
if _url:
st.link_button("Apply ↗", _url, use_container_width=True)
# ── Done banner ──────────────────────────────────────────────────────────────
if not st.session_state.running and st.session_state.results:
n = len(st.session_state.results)
high = sum(1 for j in st.session_state.results if j.get("relevance_score", 0) >= 8)
pdfs = sum(1 for j in st.session_state.results if j.get("resume_pdf_path"))
hist_note = f" · Saved to history" if st.session_state.loaded_run else ""
done_placeholder.success(
f"✅ **Done!** Found **{n} jobs** · **{high} high priority** · **{pdfs} PDFs ready**{hist_note}"
)
# ── Batch summary table (status + internal/independent scores per job) ──
_have_status = any(j.get("status") for j in st.session_state.results)
if _have_status:
_ORDER = {"READY_90_PLUS": 0, "READY_90_PLUS_REVIEW_RECOMMENDED": 1,
"NEEDS_USER_INPUT": 2, "NEEDS_REPAIR": 3,
"NOT_ELIGIBLE_LOW_FIT": 4, "PARSE_FAILED": 5}
def _risk_level(j):
q = j.get("quality_flag", "")
if j.get("status") == "NEEDS_USER_INPUT":
return "HIGH"
if q == "REVIEW_REQUIRED_90_PLUS" or j.get("review_terms"):
return "MEDIUM"
if q in ("WEAK_90_INTERNAL_ONLY",):
return "HIGH"
return "LOW"
_rows = []
for j in sorted(st.session_state.results,
key=lambda x: _ORDER.get(x.get("status", ""), 3)):
_nrev = len(j.get("review_terms", []) or []) + \
len(j.get("_v2_report", {}).get("high_risk_terms_for_confirmation", []) or []) \
if isinstance(j.get("_v2_report"), dict) else len(j.get("review_terms", []) or [])
_prov = j.get("provider_used", "")
if not _prov and isinstance(j.get("_v2_report"), dict):
_prov = j["_v2_report"].get("provider_used", "")
_rows.append({
"Job Title": (j.get("title", "") or "")[:40],
"Company": (j.get("company", "") or "")[:24],
"Platform": j.get("platform", j.get("source", "")),
"Provider": _prov or "—",
"Status": j.get("status", ""),
"Quality": j.get("quality_flag", ""),
"Internal": j.get("jd_match", j.get("ats_score_after", "")),
"Independent": j.get("independent_jd_match", ""),
"Readability": j.get("ats_readability", ""),
"Risk": _risk_level(j),
"Review Terms": _nrev,
"Download": "✅" if j.get("download_allowed") else "—",
})
with st.expander(f"📋 Batch summary — {len(_rows)} jobs (ranked by readiness)", expanded=True):
_ready = sum(1 for r in _rows if r["Download"] == "✅")
st.caption(f"{_ready}/{len(_rows)} ready to apply (both internal & "
f"independent ≥ 90). Independent = anti-circular evidence-based score. "
f"Risk HIGH = needs your confirmation before applying.")
try:
import pandas as _pd
st.dataframe(_pd.DataFrame(_rows), use_container_width=True, hide_index=True)
except Exception:
st.table(_rows)
# ── Bulk vault controls (confirm/block terms across all future jobs) ──
with st.expander("🔐 Manage skills vault (confirm or block terms)", expanded=False):
st.caption("Confirmed terms are treated as fully safe in every future "
"resume; blocked terms are never used. Only YOUR confirmation "
"upgrades a term to safe — the system never auto-promotes.")
vc1, vc2 = st.columns(2)
with vc1:
_confirm_in = st.text_input(
"✅ Confirm I have these (comma-separated)",
key="vault_confirm_in",
placeholder="e.g. SIEM, SOAR, Tableau")
if st.button("Confirm to vault", key="vault_confirm_btn") and _confirm_in.strip():
try:
from src.candidate_vault import confirm_terms
confirm_terms([t.strip() for t in _confirm_in.split(",") if t.strip()], confirmed=True)
st.success("Confirmed — these are now safe for future resumes.")
except Exception as _e:
st.error(f"Could not update vault: {_e}")
with vc2:
_block_in = st.text_input(
"🚫 Never use these (comma-separated)",
key="vault_block_in",
placeholder="e.g. Kubernetes, CISSP")
if st.button("Block in vault", key="vault_block_btn") and _block_in.strip():
try:
from src.candidate_vault import confirm_terms
confirm_terms([t.strip() for t in _block_in.split(",") if t.strip()], confirmed=False)
st.success("Blocked — these will never be added to resumes.")
except Exception as _e:
st.error(f"Could not update vault: {_e}")
# ── Validation packages (spec #9) ──
with st.expander("📦 Export validation packages (for manual Jobalytics testing)", expanded=False):
st.caption("Bundles each resume with its parsed text, the JD, keyword lists, "
"medium/high/blocked risk terms, all scores, and the provider used — "
"so you can verify our numbers against a real external checker.")
if st.button("Export packages for these jobs", key="valpkg_btn"):
try:
from src.validation_package import build_packages_for_jobs
_jwr = [j for j in st.session_state.results if j.get("resume_path")]
with st.spinner(f"Building {len(_jwr)} packages…"):
_paths = build_packages_for_jobs(_jwr)
st.success(f"Exported {len(_paths)} packages to data/output/validation/")
for _p in _paths[:25]:
st.caption(f"• {_p}")
except Exception as _e:
st.error(f"Package export failed: {_e}")
# ── Jobalytics paste → regenerate (spec #8) ──
with st.expander("🩹 Jobalytics repair — paste missing keywords & regenerate", expanded=False):
st.caption("Paste the 'missing keywords' a real checker (Jobalytics / Simplify) "
"reported. We classify each (already-present · add to skills · weave "
"into experience · medium-review · high-risk · blocked), regenerate "
"honestly, and show before/after coverage. Risky terms need your "
"confirmation; blocked terms are never faked.")
_job_opts = [f"{i}: {j.get('title','')[:30]} · {j.get('company','')[:20]}"
for i, j in enumerate(st.session_state.results)
if j.get("resume_path")]
if _job_opts:
_sel = st.selectbox("Job to repair", _job_opts, key="jobalytics_job")
_kw_in = st.text_area("Missing keywords (comma or newline separated)",
key="jobalytics_kw",
placeholder="e.g. product strategy, A/B testing, SQL, roadmap")
if st.button("Classify & regenerate", key="jobalytics_btn") and _kw_in.strip():
_idx = int(_sel.split(":", 1)[0])
_job = st.session_state.results[_idx]
_kws = [k.strip() for k in _kw_in.replace("\n", ",").split(",") if k.strip()]
try:
from src.jobalytics_repair import regenerate_from_jobalytics
with st.spinner("Classifying + regenerating (jobalytics_repair mode)…"):
_jr = regenerate_from_jobalytics(_job, _kws)
if _jr.get("error"):
st.error(_jr["error"])
else:
_sc = _jr["scores"]
st.success(f"Status: {_jr['status']} · provider: {_jr.get('provider_used','')}")
_m1, _m2, _m3 = st.columns(3)
_m1.metric("Internal", _sc["internal_jd_match"])
_m2.metric("Independent", _sc["independent_jd_match"])
_m3.metric("Readability", _sc["ats_readability"])
st.caption(f"Pasted-keyword coverage: "
f"{_jr['before_coverage']['pct']}% → {_jr['after_coverage']['pct']}%")
try:
import pandas as _pd
st.dataframe(_pd.DataFrame(_jr["classifications"]),
use_container_width=True, hide_index=True)
except Exception:
st.table(_jr["classifications"])
_np = _jr.get("resume_path", "")
if _jr.get("download_allowed") and _np and os.path.exists(_np):
with open(_np, "rb") as _f:
st.download_button("⬇ Download repaired DOCX", _f.read(),
os.path.basename(_np), key="jobalytics_dl")
elif _np:
st.caption("Regenerated, but not 90%+ on both scores — not downloadable yet.")
except Exception as _e:
st.error(f"Jobalytics repair failed: {_e}")
else:
st.caption("Run the pipeline first so there are resumes to repair.")
if st.session_state.running:
time.sleep(0.8)
st.rerun()
# ══════════════════════════════════════════════════════════════════════════════
# RESULTS TABS
# ══════════════════════════════════════════════════════════════════════════════
results = st.session_state.results
if results is not None:
n_res = len(results)
tab_results, tab_details, tab_research, tab_logs = st.tabs([
f"📊 Results ({n_res})", "📄 Job Details", "🔬 Deep Research", "📋 Logs"
])
# ══════════════════════════════════════════════════════════════════════════
# TAB 1 — RESULTS
# ══════════════════════════════════════════════════════════════════════════
with tab_results:
st.html(_metrics_html(results))
dl1, dl2, dl3 = st.columns(3)
with dl1:
xp = st.session_state.excel_path
if xp and os.path.exists(xp):
with open(xp, "rb") as f:
st.download_button("⬇ Download Excel Report", f.read(),
"job_report.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
use_container_width=True)
csv_p = (os.path.splitext(xp)[0] + ".csv") if xp else ""
if csv_p and os.path.exists(csv_p):
with open(csv_p, "rb") as f:
st.download_button("⬇ Download CSV", f.read(),
"job_report.csv", "text/csv",
use_container_width=True)
with dl2:
resume_base = Path("data/output/resumes")
if st.session_state.loaded_run:
first_res = next((j.get("resume_path","") for j in results if j.get("resume_path")), "")
run_folder = Path(first_res).parent if first_res else None
else:
date_dirs = sorted(
[d for d in resume_base.iterdir() if d.is_dir()],
key=lambda d: d.stat().st_mtime, reverse=True,
) if resume_base.exists() else []
run_folder = date_dirs[0] if date_dirs else None
if run_folder and run_folder.exists():
docx_f = list(run_folder.glob("*.docx"))
pdf_f = list(run_folder.glob("*.pdf"))
all_rf = docx_f + pdf_f
if all_rf:
import io, zipfile
zbuf = io.BytesIO()
with zipfile.ZipFile(zbuf, "w") as zf:
for fp in all_rf:
zf.write(str(fp), fp.name)
zbuf.seek(0)
n_pdf = len(pdf_f)
n_docx = len(docx_f)
st.download_button(
f"⬇ Resumes ({n_docx} DOCX + {n_pdf} PDF)",
zbuf.read(), "tailored_resumes.zip", "application/zip",
use_container_width=True,
)
st.caption(f"📁 {run_folder}")
with dl3:
if sheets_ok:
from config import GOOGLE as _GC
sheet_url = f"https://docs.google.com/spreadsheets/d/{_GC['sheet_id']}/edit"
st.link_button("📊 Open Google Sheet", sheet_url, use_container_width=True)
st.divider()
col_f1, col_f2, col_f3 = st.columns(3)
with col_f1:
f_score = st.slider("Min score", 1, 10, 1, key="f_score")
with col_f2:
jobs_df = pd.DataFrame(results)
f_plat = st.multiselect("Platform",
options=jobs_df["platform"].unique().tolist() if "platform" in jobs_df else [],
key="f_plat")
with col_f3:
f_pri = st.multiselect("Priority", ["High", "Medium", "Low"], key="f_pri")
view_mode = st.radio("View", ["📋 Cards (top 10)", "📊 Full Table"],
horizontal=True, key="view_mode")
filtered = [j for j in results if j.get("relevance_score", 0) >= f_score]
if f_plat:
filtered = [j for j in filtered if j.get("platform") in f_plat]
if f_pri:
filtered = [j for j in filtered if j.get("application_priority") in f_pri]
st.caption(f"Showing **{len(filtered)}** of **{n_res}** jobs")
if view_mode.startswith("📋"):
for rank, job in enumerate(filtered[:10], 1):
st.html(_job_card_html(job, rank))
_render_card_actions(job, key=f"card{rank}")
if len(filtered) > 10:
st.caption(f"… and {len(filtered)-10} more. Switch to Table view to see all.")
else:
if not filtered:
st.info("No jobs match the current filters.")
else:
disp_cols = [
"relevance_score", "title", "company", "location", "platform",
"salary", "application_priority",
"ats_score_before", "ats_score_after", "ats_improvement",
"resume_generated", "matching_skills", "recommendation",
]
fdf = pd.DataFrame(filtered)
avail = [c for c in disp_cols if c in fdf.columns]
fdf = fdf[avail].copy()
fdf.insert(0, "#", range(1, len(fdf)+1))
def _fmt_score(s):
e = "🔴" if s >= 8 else ("🟡" if s >= 6 else "⚪")
return f"{e} {s}/10"
if "relevance_score" in fdf.columns:
fdf["relevance_score"] = fdf["relevance_score"].apply(_fmt_score)
for c in ("ats_score_before", "ats_score_after"):
if c in fdf.columns:
fdf[c] = fdf[c].apply(lambda x: f"{x}%" if x not in ("", None) else "—")
if "ats_improvement" in fdf.columns:
fdf["ats_improvement"] = fdf["ats_improvement"].apply(
lambda x: f"+{x}pp" if x and x > 0 else ("—" if not x else f"{x}pp")
)
fdf.rename(columns={
"relevance_score": "Score",
"title": "Job Title",
"company": "Company",
"location": "Location",
"platform": "Platform",
"salary": "Salary",
"application_priority":"Priority",
"ats_score_before": "ATS Before",
"ats_score_after": "ATS After",
"ats_improvement": "ATS Gain",
"resume_generated": "Resume",
"matching_skills": "Matching Skills",
"recommendation": "Notes",
}, inplace=True)
st.dataframe(fdf, use_container_width=True, height=520, hide_index=True,
column_config={
"#": st.column_config.NumberColumn("#", width="small"),
"Score": st.column_config.TextColumn("Score", width="small"),
"Job Title": st.column_config.TextColumn("Job Title", width="large"),
"ATS Before": st.column_config.TextColumn("ATS Before", width="small"),
"ATS After": st.column_config.TextColumn("ATS After", width="small"),
"ATS Gain": st.column_config.TextColumn("ATS Gain", width="small"),
"Notes": st.column_config.TextColumn("Notes", width="large"),
})
# ══════════════════════════════════════════════════════════════════════════
# TAB 2 — JOB DETAILS
# ══════════════════════════════════════════════════════════════════════════
with tab_details:
top_jobs = [j for j in results if j.get("relevance_score", 0) >= 6]
if not top_jobs:
st.warning("No jobs scored 6 or above. Lower the minimum score filter.")
else:
job_options = {
f"{_score_emoji(j['relevance_score'])} {j['relevance_score']}/10 — "
f"{j['title']} @ {j['company']}": j
for j in top_jobs
}
sel = st.selectbox("Select a job", list(job_options.keys()))
job = job_options[sel]
st.divider()
c1, c2, c3 = st.columns(3)
with c1:
st.markdown(f"**🏢 Company:** {job.get('company','')}")
st.markdown(f"**📍 Location:** {job.get('location','')}")
st.markdown(f"**💰 Salary:** {job.get('salary','Not specified')}")
st.markdown(f"**🖥 Platform:** {job.get('platform','')}")
with c2:
score = job.get("relevance_score", 0)
scolor = "green" if score >= 8 else ("orange" if score >= 6 else "red")
st.markdown(f"**⭐ Score:** :{scolor}[{score}/10]")
ats_b = job.get("ats_score_before"); ats_a = job.get("ats_score_after")
imp = job.get("ats_improvement", 0) or 0
if ats_b is not None:
st.markdown(f"**📄 ATS Before:** {ats_b}% → **After:** {ats_a}% (+{imp}pp)")
st.markdown(f"**🎯 Exp Match:** {job.get('experience_match','')}")
with c3:
prio = job.get("application_priority","")
pcolor = "red" if prio=="High" else ("orange" if prio=="Medium" else "gray")
st.markdown(f"**🚦 Priority:** :{pcolor}[{prio}]")
url = job.get("url","")
if url:
st.markdown(f"**🔗 [View Job Posting]({url})**")
st.divider()
cl, cr = st.columns(2)
with cl:
st.markdown("#### ✅ Matching Skills")
matching = [s.strip() for s in job.get("matching_skills","").split(",") if s.strip()]
if matching:
for s in matching: st.markdown(f"- :green[{s}]")
else:
st.caption("None identified")
st.markdown("#### 💡 Key Strengths")
for s in [s.strip() for s in job.get("key_strengths","").split(",") if s.strip()]:
st.markdown(f"- {s}")
with cr:
st.markdown("#### ❌ Missing / Gap Skills")
missing = [s.strip() for s in job.get("missing_skills","").split(",") if s.strip()]
if missing:
for s in missing: st.markdown(f"- :red[{s}]")
else:
st.markdown(":green[No major gaps!]")
st.markdown("#### 🔑 ATS Keywords")
kws = [k.strip() for k in job.get("ats_keywords","").split(",") if k.strip()]
if kws:
st.markdown(" ".join([f"`{k}`" for k in kws]))
rec = job.get("recommendation","")
if rec:
st.divider()
st.markdown("#### 📝 AI Recommendation")
st.info(rec)
desc = job.get("description","")
if desc:
with st.expander("📋 Full Job Description"):
st.text(desc[:3000])
resume_path = job.get("resume_path","")
pdf_path = job.get("resume_pdf_path","") or (
os.path.splitext(resume_path)[0] + ".pdf" if resume_path else ""
)
if resume_path and os.path.exists(resume_path):
st.divider()
st.markdown("#### 📄 Tailored Resume")
rc1, rc2 = st.columns(2)
if pdf_path and os.path.exists(pdf_path):
with rc1, open(pdf_path, "rb") as f:
st.download_button("⬇ Download PDF (apply with this)",
f.read(), os.path.basename(pdf_path),
"application/pdf", use_container_width=True)
with rc2, open(resume_path, "rb") as f:
st.download_button("⬇ Download DOCX (editable)",
f.read(), os.path.basename(resume_path),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
use_container_width=True)
else:
st.caption("Resume not generated for this job (score below threshold).")
# ══════════════════════════════════════════════════════════════════════════
# TAB 3 — DEEP RESEARCH
# ══════════════════════════════════════════════════════════════════════════
with tab_research:
st.markdown("### 🔬 Deep Research")
st.markdown(
"Iterative **Think → Search → Extract → Synthesize** loop. "
"Uses DuckDuckGo + GLM 5.1 to produce a cited Markdown report."
)
top_jobs_r = [j for j in results if j.get("relevance_score", 0) >= 7][:5]
if top_jobs_r:
st.markdown("**Quick research on top jobs:**")
preset_cols = st.columns(min(len(top_jobs_r), 5))
for i, job in enumerate(top_jobs_r):
with preset_cols[i]:
if st.button(job["company"], key=f"preset_{i}", use_container_width=True):
st.session_state["research_q"] = (
f"Research {job['company']} as an employer: "
f"culture, salary, work-life balance, Glassdoor reviews, "
f"recent news for the role of {job['title']}"
)
st.divider()
default_q = st.session_state.get("research_q", "")
question = st.text_area(
"Research question",
value=default_q,
placeholder=(
"e.g. What is the work culture and salary range for Product Managers at Swiggy in 2026?\n"
"e.g. Compare AI PM roles at Google vs Microsoft vs Flipkart\n"
"e.g. What skills do top EdTech Product Managers need in India?"
),
height=100, key="research_input",
)
cr1, cr2, cr3 = st.columns([1,1,2])
with cr1: max_rounds = st.slider("Rounds", 2, 6, 3, key="r_rounds")
with cr2: max_time = st.slider("Max time (s)", 60, 300, 180, key="r_time")
with cr3:
category = st.selectbox(
"Report format",
["Auto-detect","product","comparison","howto","factcheck"], key="r_cat",
)
run_research = st.button("🔬 Start Research", disabled=not question.strip())
prog_area = st.empty(); rep_area = st.empty()
if run_research and question.strip():
st.session_state["research_report"] = None
st.session_state["research_log"] = []
research_log = []
research_done = [False]
research_res = [None]
def _prog_cb(event: dict):
phase = event.get("phase","")
msgs = {
"planning": "📋 Planning research strategy...",
"searching": f"🔍 Round {event.get('round','')} — searching...",
"reading": f"📖 Reading: {(event.get('title') or event.get('url',''))[:60]}",
"analyzing": f"🧠 Synthesizing round {event.get('round','')}...",
"writing": "✍️ Writing final report...",
"warning": f"⚠ {event.get('message','')}",
"error": f"❌ {event.get('message','')}",
}
msg = msgs.get(phase, str(event))
if msg: research_log.append(msg)
def _run_research():
import asyncio
load_dotenv()
from src.research.deep_researcher import DeepResearcher
cat = None if category == "Auto-detect" else category
researcher = DeepResearcher(
llm_endpoint="https://integrate.api.nvidia.com/v1/chat/completions",
llm_model="z-ai/glm-5.1",
llm_api_key=os.getenv("NVIDIA_API_KEY"),
max_rounds=max_rounds, max_time=max_time,
category=cat, progress_callback=_prog_cb,
)
async def _go(): return await researcher.research(question)
research_res[0] = asyncio.run(_go())
research_done[0] = True
threading.Thread(target=_run_research, daemon=True).start()
phases = ["planning","searching","reading","analyzing","writing"]
pw = {p: (i+1)/len(phases) for i,p in enumerate(phases)}
with prog_area.container():
pb = st.progress(0, text="Starting research...")
ld = st.empty()
while not research_done[0]:
if research_log:
last = research_log[-1]
pct = max(10, next((int(w*90) for p,w in pw.items() if p in last.lower()), 10))
pb.progress(min(pct, 90), text=last)
with ld.expander("📋 Research log", expanded=True):
for e in research_log[-12:]:
if e.startswith(("❌","⚠")): st.markdown(f":orange[{e}]")
elif e.startswith("✍"): st.markdown(f":blue[{e}]")
else: st.markdown(e)
time.sleep(2)
st.rerun()
pb.progress(100, text="Research complete!")
st.session_state["research_report"] = research_res[0]
report = st.session_state.get("research_report")
if report:
st.divider()
st.markdown("### 📄 Research Report")
st.download_button("⬇ Download (.md)", data=report.encode("utf-8"),
file_name="research_report.md", mime="text/markdown")
st.markdown(report, unsafe_allow_html=False)
# ══════════════════════════════════════════════════════════════════════════
# TAB 4 — LOGS
# ══════════════════════════════════════════════════════════════════════════
with tab_logs:
st.markdown("### 📋 Run Logs")
st.caption("Full debug output — every scrape attempt, error, and traceback is captured.")
log_files = app_logger.list_log_files()
current = st.session_state.get("current_log_file","")
if not log_files and not current:
st.info("No log files yet. Run a search to generate logs.")
else:
display_names = []
path_map = {}
if current and os.path.exists(current):
lbl = f"▶ Current run — {os.path.basename(current)}"
display_names.append(lbl); path_map[lbl] = current
for p in log_files:
if p == current: continue
lbl = os.path.basename(p)
display_names.append(lbl); path_map[lbl] = p
sel_lbl = st.selectbox("Log file", display_names, index=0) if display_names else None
sel_path = path_map.get(sel_lbl,"") if sel_lbl else ""
lcol1, lcol2, lcol3 = st.columns([2,1,1])
with lcol1: tail_lines = st.slider("Lines", 50, 500, 200, step=50, key="log_tail")
with lcol2: show_debug = st.checkbox("Show DEBUG", False, key="log_debug")
with lcol3:
st.markdown("")
auto_refresh = st.checkbox("Auto-refresh (2s)", value=st.session_state.running, key="log_refresh")
if sel_path and os.path.exists(sel_path):
try:
with open(sel_path, "r", encoding="utf-8", errors="replace") as _f:
all_lines = _f.readlines()
except Exception as ex:
all_lines = [f"Could not read: {ex}\n"]
if not show_debug:
all_lines = [l for l in all_lines if "[DEBUG]" not in l]
tail = all_lines[-tail_lines:]
colored = ""
for line in tail:
safe = line.replace("&","&").replace("<","<").replace(">",">")
if any(x in line for x in ("[ERROR]","❌","FATAL","Traceback","Error:")):
colored += f'{safe}'
elif any(x in line for x in ("[WARNING]","⚠")):
colored += f'{safe}'
elif "[INFO]" in line and any(x in line for x in ("[DONE]","✅")):
colored += f'{safe}'
elif "[INFO]" in line:
colored += f'{safe}'
else:
colored += f'{safe}'
st.markdown(
f''
f'{colored}
',
unsafe_allow_html=True,
)
err_cnt = sum(1 for l in all_lines if "[ERROR]" in l or "Traceback" in l)
warn_cnt = sum(1 for l in all_lines if "[WARNING]" in l)
st.caption(
f"`{sel_path}` · {len(all_lines)} lines · "
f"{err_cnt} errors · {warn_cnt} warnings"
)
with open(sel_path, "rb") as _df:
st.download_button("⬇ Download Full Log", _df.read(),
os.path.basename(sel_path), "text/plain")
else:
st.info("Select a log file or run a search to generate one.")
_auto_refresh = locals().get("auto_refresh", False)
if _auto_refresh and st.session_state.running:
time.sleep(2)
st.rerun()
else:
# No results yet and not in config mode (shouldn't happen, but safety net)
if not show_config:
st.html("""
🤖
Ready to find your next PM role
Configure your search settings, then click Start AI Job Search.
The agent will scrape multiple platforms, assess every job with AI models,
and generate ATS-optimized resumes for all matches.
""")