Spaces:
Running
Running
feat(phase-10): V2 natural sentence resume mode — Kimi LLM generates sentences, same V1 waterfall
f057ca2 | """ | |
| 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 (regular + download + link) ── */ | |
| .stButton > button[kind="primary"], .stButton > button, | |
| .stDownloadButton > button, | |
| .stLinkButton > a, .stLinkButton > a[data-testid] { | |
| background: linear-gradient(135deg, #2563EB 0%, #7C3AED 100%) !important; | |
| color: #ffffff !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; | |
| text-decoration: none !important; | |
| } | |
| /* Force white text on every nested element inside these buttons | |
| (Streamlit wraps the label in <p>/<span>/<div> that inherit dark text) */ | |
| .stButton > button *, .stDownloadButton > button *, .stLinkButton > a * { | |
| color: #ffffff !important; | |
| fill: #ffffff !important; | |
| } | |
| .stButton > button:hover, .stDownloadButton > button:hover, .stLinkButton > a:hover { | |
| transform: translateY(-1px) !important; | |
| box-shadow: 0 4px 16px rgba(37,99,235,0.35) !important; | |
| color: #ffffff !important; | |
| } | |
| .stButton > button:disabled, .stDownloadButton > button:disabled { | |
| opacity: 0.5 !important; | |
| transform: none !important; | |
| box-shadow: none !important; | |
| } | |
| /* Secondary / ghost buttons — white bg, blue text (override the white-children rule) */ | |
| .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 *, | |
| .secondary-btn .stButton > button:hover * { | |
| color: #2563EB !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 ── */ | |
| /* Tag-agnostic: recent Streamlit renders the dropzone as <section>, so the old | |
| `div[...]` selector missed it and the default dark theme showed (black-on-black). */ | |
| [data-testid="stFileUploaderDropzone"] { | |
| background: #FFFFFF !important; | |
| border: 2px dashed #CBD5E1 !important; | |
| border-radius: 12px !important; | |
| transition: all 0.2s ease; | |
| } | |
| [data-testid="stFileUploaderDropzone"]:hover { | |
| border-color: #2563EB !important; | |
| background: #F0F4FF !important; | |
| } | |
| /* Dropzone instruction text + icon (were dark-on-dark / invisible) */ | |
| [data-testid="stFileUploaderDropzone"] *, | |
| [data-testid="stFileUploaderDropzoneInstructions"], | |
| [data-testid="stFileUploaderDropzoneInstructions"] * { | |
| color: #334155 !important; | |
| } | |
| [data-testid="stFileUploaderDropzone"] svg, | |
| [data-testid="stFileUploaderDropzoneInstructions"] svg { | |
| fill: #2563EB !important; | |
| color: #2563EB !important; | |
| } | |
| /* "Browse files" button inside the dropzone (was black background on black) */ | |
| [data-testid="stFileUploaderDropzone"] button { | |
| background: #2563EB !important; | |
| color: #FFFFFF !important; | |
| border: 1px solid #2563EB !important; | |
| border-radius: 8px !important; | |
| font-weight: 600 !important; | |
| } | |
| [data-testid="stFileUploaderDropzone"] button:hover { | |
| background: #1D4ED8 !important; | |
| border-color: #1D4ED8 !important; | |
| color: #FFFFFF !important; | |
| } | |
| /* Uploaded-file chip row */ | |
| [data-testid="stFileUploaderFile"], | |
| [data-testid="stFileUploaderFile"] * { | |
| color: #334155 !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; | |
| } | |
| /* ── Baseweb (Streamlit's underlying widget library) overrides ── */ | |
| /* Multiselect / select underlying control — was rendering dark on dark */ | |
| [data-baseweb="select"] > div, | |
| [data-baseweb="select"] > div > div, | |
| [data-baseweb="select"] input { | |
| background: #FFFFFF !important; | |
| color: #0F172A !important; | |
| border-color: #E2E8F0 !important; | |
| } | |
| [data-baseweb="select"] [aria-selected="true"], | |
| [data-baseweb="select"] [role="option"] { | |
| background: #FFFFFF !important; | |
| color: #0F172A !important; | |
| } | |
| /* Dropdown menu list — also was dark */ | |
| [data-baseweb="popover"] [role="listbox"], | |
| [data-baseweb="popover"] ul, | |
| [data-baseweb="popover"] li { | |
| background: #FFFFFF !important; | |
| color: #0F172A !important; | |
| } | |
| [data-baseweb="popover"] li:hover { | |
| background: #F1F5F9 !important; | |
| color: #0F172A !important; | |
| } | |
| /* Selected chips inside multiselect */ | |
| [data-baseweb="tag"] { | |
| background: #EFF6FF !important; | |
| color: #1E40AF !important; | |
| border-color: #BFDBFE !important; | |
| } | |
| [data-baseweb="tag"] span { | |
| color: #1E40AF !important; | |
| } | |
| /* Placeholder ("Choose options") text */ | |
| [data-baseweb="select"] [aria-label], | |
| .stMultiSelect [data-baseweb="select"] div[class*="placeholder"] { | |
| color: #64748B !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) ────────── | |
| # 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. | |
| 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 = '<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("<","<").replace(">",">") | |
| 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("<","<").replace(">",">") | |
| 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 _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"<div style='margin:6px 0'>" | |
| f"<span style='font-weight:600;color:#0F172A'>{esc(role.title)}</span><br>" | |
| f"<span style='font-size:0.82rem;color:#64748B'>{meta} · {n_b} bullets</span>" | |
| f"</div>" | |
| ) | |
| edu_html = "" | |
| for e in r.education[:4]: | |
| meta = " · ".join(filter(None, [esc(e.institution), esc(e.dates)])) | |
| edu_html += f"<div style='font-size:0.85rem;margin:2px 0'><b>{esc(e.degree)}</b> — <span style='color:#64748B'>{meta}</span></div>" | |
| summary = esc(r.summary)[:400] + ("…" if len(r.summary) > 400 else "") | |
| contact = esc(r.contact.render_line()) | |
| st.html(f""" | |
| <div style="background:#FFFFFF;border:1px solid #E2E8F0;border-radius:14px; | |
| padding:18px 20px;margin-top:12px;box-shadow:0 1px 3px rgba(0,0,0,0.04)"> | |
| <p style="font-size:0.78rem;font-weight:700;letter-spacing:.04em;color:#2563EB;margin:0 0 8px"> | |
| PROFILE WE BUILT FROM YOUR RESUME | |
| </p> | |
| <p style="font-size:1.15rem;font-weight:700;color:#0F172A;margin:0">{esc(r.name)}</p> | |
| <p style="font-size:0.85rem;color:#64748B;margin:2px 0 10px">{contact}</p> | |
| <p style="font-size:0.85rem;color:#334155;margin:0 0 12px;line-height:1.5">{summary}</p> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"> | |
| <div> | |
| <p style="font-size:0.78rem;font-weight:700;color:#0F172A;margin:0 0 4px"> | |
| Experience ({len(r.roles)} roles)</p> | |
| {roles_html or '<span style="color:#94A3B8;font-size:0.82rem">No roles parsed</span>'} | |
| </div> | |
| <div> | |
| <p style="font-size:0.78rem;font-weight:700;color:#0F172A;margin:0 0 4px"> | |
| Education</p> | |
| {edu_html or '<span style="color:#94A3B8;font-size:0.82rem">No education parsed</span>'} | |
| </div> | |
| </div> | |
| </div>""") | |
| 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'<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("<","<").replace(">",">") | |
| co = company.replace("<","<").replace(">",">") | |
| lo = location.replace("<","<").replace(">",">") | |
| 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 _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""" | |
| <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">Est. ATS (verify on Jobalytics)</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) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 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(""" | |
| <div style="text-align:center;padding:48px 0 28px"> | |
| <div style="font-size:2.8rem;line-height:1">🤖</div> | |
| <h2 style="font-size:1.35rem;font-weight:700;color:#0F172A;margin:10px 0 4px">JAA · ATS Tool</h2> | |
| <p style="font-size:0.84rem;color:#64748B;margin:0">Sign in to continue</p> | |
| </div>""", 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 | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| 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""" | |
| <div class="jaa-header" style="padding-bottom: 12px;"> | |
| <div class="jaa-header-left"></div> | |
| <span class="jaa-header-badge">{"⏳" if st.session_state.running else "✨"} {status_text}</span> | |
| </div> | |
| <div style="font-size:0.78rem; color:#64748B; margin:-4px 0 4px; font-weight:500;"> | |
| 🟢 Last code update (deployed): {_deploy_timestamp_ist()} | |
| </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() | |
| _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('<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 | |
| # ── 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 = '<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">Paste your LaTeX source for the best ATS results, or upload a PDF below.</p> | |
| </div>""") | |
| # ── PRIMARY: LaTeX paste ────────────────────────────────────────── | |
| st.html(""" | |
| <div style="background:#EEF2FF;border:2px solid #6366F1;border-radius:12px; | |
| padding:14px 16px;margin:12px 0 8px"> | |
| <div style="font-size:1rem;font-weight:700;color:#4338CA">📄 LaTeX source</div> | |
| <div style="font-size:0.78rem;color:#6366F1;margin-top:2px"> | |
| Recommended — paste your full .tex code below for highest ATS accuracy | |
| </div> | |
| </div>""") | |
| # 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(""" | |
| <div style="display:flex;align-items:center;gap:10px;margin:18px 0 10px"> | |
| <div style="flex:1;height:1px;background:#E2E8F0"></div> | |
| <span style="font-size:0.8rem;color:#94A3B8;white-space:nowrap">or upload a PDF</span> | |
| <div style="flex:1;height:1px;background:#E2E8F0"></div> | |
| </div>""") | |
| 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""" | |
| <div class="micro-success"> | |
| ✅ <strong>{_src_label}</strong> → resume.pdf ({fsize} KB) — Ready for AI matching | |
| </div>""") | |
| _render_uploaded_profile() | |
| elif _has_tex: | |
| _tex_len = len(st.session_state.get("_resume_tex", "") or "") | |
| st.html(f""" | |
| <div class="micro-success"> | |
| ✅ <strong>LaTeX saved</strong> ({_tex_len} chars) — Will compile when you start the job search | |
| </div>""") | |
| else: | |
| st.caption("Paste your LaTeX or upload a PDF 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: | |
| _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'<div class="micro-success">🎯 Great focus — {len(roles)} target role{"s" if len(roles)!=1 else ""} selected</div>') | |
| 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(""" | |
| <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_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""" | |
| <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 " + 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")}</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 | |
| _save_prefs() | |
| 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 | |
| _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""" | |
| <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>""") | |
| # 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('<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: | |
| # 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( | |
| '<p style="font-weight:700;color:#0F172A;margin:0 0 4px 0">Live Log</p>' + | |
| _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"<span style='color:{_color};font-size:0.8rem;font-weight:600'>{_label}</span>" | |
| f"<span style='color:#64748B;font-size:0.85rem'> · {_scoreline}</span>", | |
| 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'<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>""") | |