import asyncio import atexit import json from html import escape from pathlib import Path from typing import Any, List, Tuple # Suppress Python 3.13 asyncio "Invalid file descriptor: -1" noise at GC/shutdown. # CPython 3.13 prints these via the "Exception ignored in: <__del__>" path which # bypasses the warnings system entirely — the only reliable fix is to monkeypatch # BaseEventLoop.__del__ so the ValueError is swallowed before CPython can print it. try: import asyncio.base_events as _abe _orig_loop_del = _abe.BaseEventLoop.__del__ def _safe_loop_del(self) -> None: try: _orig_loop_del(self) except Exception: pass _abe.BaseEventLoop.__del__ = _safe_loop_del del _abe, _safe_loop_del except Exception: pass def _close_asyncio_loop() -> None: """Close any leftover asyncio event loop at process exit.""" try: loop = asyncio.get_event_loop_policy().get_event_loop() if loop and not loop.is_closed(): loop.close() except Exception: pass atexit.register(_close_asyncio_loop) import gradio as gr from dotenv import load_dotenv from src.jobs.ats_detector import detect_ats from src.jobs.company_loader import load_companies from src.jobs.debug_utils import log_debug_header, log_debug_line, save_debug_html from src.jobs.extractor import extract_jobs_with_diagnostics from src.jobs.fetcher import fetch_jobs_from_ats_api, resolve_real_jobs_page from src.models import JobPosting from src.output.generator import build_talking_points, resume_profile_to_json from src.resume.pdf_extract import extract_resume_text from src.resume.profile_builder import build_resume_profile from src.scoring.matcher import rank_companies, score_job_match BASE_DIR = Path(__file__).resolve().parent load_dotenv(BASE_DIR / ".env") DEFAULT_COMPANY_CANDIDATES = [ BASE_DIR / "NSBE 2026 Baltimore Company_ Schools - Companies.csv", BASE_DIR / "data" / "NSBE 2026 Baltimore Company_ Schools - Companies (1).csv", ] DEBUG_HTML_DIR = BASE_DIR / "debug_html" APP_THEME = gr.themes.Base( primary_hue="cyan", secondary_hue="indigo", neutral_hue="slate", font=["Manrope", "ui-sans-serif", "sans-serif"], ) CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap'); :root { --bg: #f5f7fb; --surface: #ffffff; --surface-muted: #f8fafc; --surface-soft: #f1f5f9; --border: #e5eaf2; --border-strong: #d7dfeb; --text: #102033; --text-muted: #5e7086; --text-soft: #7d8ea4; --accent: #3366ff; --accent-soft: #eef3ff; --accent-hover: #2856df; --success: #1f9d73; --danger: #d94f45; --shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.08); --shadow-md: 0 10px 24px rgba(15, 23, 42, 0.06); } html, body, .gradio-container { min-height: 100%; } body, .gradio-container { background: linear-gradient(180deg, #f7f9fc 0%, #f3f6fb 100%); color: var(--text); font-family: 'Inter', sans-serif; } .gradio-container { max-width: 1260px !important; padding: 20px 20px 30px !important; } .gradio-container * { box-sizing: border-box; } .app-shell { gap: 18px; } .app-hero { padding: 20px 22px; border-radius: 18px; border: 1px solid var(--border); background: var(--surface); box-shadow: var(--shadow-md); } .eyebrow { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 0.74rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; } .hero-title { margin: 12px 0 6px; color: var(--text); font-family: 'Space Grotesk', sans-serif; font-size: clamp(1.8rem, 2.5vw, 2.5rem); letter-spacing: -0.04em; line-height: 1.02; } .hero-copy { margin: 0; max-width: 760px; color: var(--text-muted); font-size: 0.98rem; line-height: 1.55; } .hero-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 16px; } .hero-pill { padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border); background: var(--surface-muted); color: var(--text-muted); font-size: 0.88rem; line-height: 1.45; } .hero-pill strong { display: block; margin-bottom: 4px; color: var(--text); font-size: 0.92rem; } .panel { border-radius: 18px; border: 1px solid var(--border); background: var(--surface); box-shadow: var(--shadow-lg); } .control-panel, .results-panel { padding: 18px; } .section-title { margin-bottom: 14px; } .section-title h3 { margin: 0 0 6px; color: var(--text); font-size: 1.05rem; font-weight: 700; letter-spacing: -0.02em; } .section-title p { margin: 0; color: var(--text-muted); line-height: 1.5; font-size: 0.92rem; } .chip-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .chip { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; background: var(--surface-soft); border: 1px solid var(--border); color: var(--text-muted); font-size: 0.8rem; font-weight: 600; } .subcard { padding: 14px; margin-bottom: 12px; border-radius: 14px; border: 1px solid var(--border); background: var(--surface); } .subcard:last-child { margin-bottom: 0; } .subcard-title { margin: 0 0 10px; color: var(--text); font-size: 0.92rem; font-weight: 700; } .results-note { margin-top: 12px; color: var(--text-soft); font-size: 0.86rem; line-height: 1.5; } .gr-box, .gr-group, .gr-form, .gr-panel, .gradio-container .block, .gradio-container .gr-block { background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important; } .gradio-container .gr-column { gap: 0 !important; } .gradio-container .gr-form, .gradio-container .gr-group { gap: 12px !important; } .gradio-container label, .gradio-container .wrap label, .gradio-container .prose, .gradio-container .prose p, .gradio-container .prose strong { color: var(--text) !important; } .gradio-container .gr-markdown p { color: var(--text-muted) !important; } .gradio-container .wrap label span, .gradio-container .label-wrap span, .gradio-container .gr-form label, .gradio-container .gr-checkbox label { color: var(--text) !important; font-weight: 600 !important; } .gradio-container input, .gradio-container textarea, .gradio-container select, .gradio-container .gr-textbox, .gradio-container .cm-editor, .gradio-container .gr-code, .gradio-container .gr-dataframe { border-radius: 10px !important; border: 1px solid var(--border-strong) !important; background: #ffffff !important; color: var(--text) !important; box-shadow: none !important; } .gradio-container input::placeholder, .gradio-container textarea::placeholder { color: var(--text-soft) !important; } .gradio-container .gr-file, .gradio-container .upload-card { min-height: 84px !important; border-radius: 12px !important; border: 1px dashed #c7d3e3 !important; background: var(--surface-muted) !important; transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; overflow: hidden !important; } .gradio-container .gr-file > div, .gradio-container .upload-card > div { min-height: 84px !important; } .gradio-container .gr-file:hover, .gradio-container .upload-card:hover { border-color: #9fb8ff !important; background: #f7faff !important; box-shadow: 0 0 0 4px rgba(51, 102, 255, 0.05) !important; } .gradio-container .gr-file .wrap, .gradio-container .gr-file .or, .gradio-container .gr-file .hint { color: var(--text-muted) !important; } .gradio-container .gr-button-primary { min-height: 44px; border-radius: 10px !important; border: none !important; background: var(--accent) !important; color: #ffffff !important; font-weight: 700 !important; letter-spacing: 0.01em; box-shadow: 0 8px 18px rgba(51, 102, 255, 0.18) !important; transition: transform 140ms ease, background 140ms ease, box-shadow 140ms ease !important; } .gradio-container .gr-button-primary:hover { background: var(--accent-hover) !important; transform: translateY(-1px); box-shadow: 0 10px 20px rgba(51, 102, 255, 0.22) !important; } .gradio-container button:disabled, .gradio-container .gr-button-primary[disabled] { opacity: 0.6 !important; cursor: not-allowed !important; } .gradio-container .gr-slider, .gradio-container .gr-slider .wrap, .gradio-container .gr-slider input { color: var(--text) !important; } .gradio-container input[type='checkbox'] { accent-color: var(--accent); } .gradio-container .tab-nav { margin-bottom: 12px; padding: 4px !important; border-radius: 12px !important; background: var(--surface-soft) !important; border: 1px solid var(--border) !important; } .gradio-container .tab-nav button { min-height: 38px; border-radius: 9px !important; color: var(--text-muted) !important; font-weight: 700 !important; transition: background 120ms ease, color 120ms ease; } .gradio-container .tab-nav button.selected { background: #ffffff !important; color: var(--text) !important; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } .tab-panel { padding-top: 4px; } .gradio-container .gr-dataframe { overflow: hidden !important; } .gradio-container table { border-collapse: collapse !important; } .gradio-container thead th { padding: 12px 14px !important; background: var(--surface-muted) !important; color: var(--text-muted) !important; font-size: 0.8rem !important; font-weight: 700 !important; border-bottom: 1px solid var(--border) !important; } .gradio-container tbody tr:hover { background: #f8fbff !important; } .gradio-container td { padding: 12px 14px !important; color: var(--text) !important; border-bottom: 1px solid #edf2f7 !important; } .status-card, .summary-shell, .empty-state { border-radius: 14px; border: 1px solid var(--border); background: var(--surface); } .status-card { padding: 14px 16px; margin-bottom: 12px; } .status-card strong { display: block; margin-bottom: 4px; color: var(--text); font-size: 0.94rem; } .status-card p { margin: 0; color: var(--text-muted); line-height: 1.5; } .status-card.info { border-color: #dbe6ff; background: #f8fbff; } .status-card.success { border-color: #d7f0e6; background: #f7fcf9; } .status-card.error { border-color: #f1d9d7; background: #fff8f7; } .summary-shell { padding: 12px; margin-bottom: 12px; background: var(--surface-muted); } .summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } .summary-card { padding: 14px; border-radius: 12px; border: 1px solid var(--border); background: var(--surface); } .summary-card span { display: block; margin-bottom: 6px; color: var(--text-soft); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; } .summary-card strong { display: block; color: var(--text); font-size: 1.25rem; font-weight: 800; letter-spacing: -0.03em; } .summary-card small { display: block; margin-top: 8px; color: var(--text-muted); line-height: 1.45; } .empty-state { padding: 22px 18px; text-align: center; } .empty-state strong { display: block; margin-bottom: 8px; color: var(--text); font-size: 1rem; } .empty-state p { max-width: 520px; margin: 0 auto; color: var(--text-muted); line-height: 1.5; } @media (max-width: 980px) { .hero-meta, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gradio-container { padding: 14px 14px 22px !important; } } @media (max-width: 720px) { .hero-meta, .summary-grid { grid-template-columns: 1fr; } } """ def _resolve_file_path(file_obj: Any) -> str: if file_obj is None: return "" if isinstance(file_obj, str): return file_obj if hasattr(file_obj, "name"): return str(file_obj.name) if isinstance(file_obj, dict): return str(file_obj.get("name", "")) return "" def _default_companies_path() -> str: for path in DEFAULT_COMPANY_CANDIDATES: if path.exists(): return str(path) raise FileNotFoundError("No default company CSV file is available.") def _fallback_job(company_name: str, careers_url: str, ats: str) -> JobPosting: return JobPosting( company=company_name, title="General Opportunities", location="", url=careers_url, department="", description="Careers page discovered but no structured roles were parsed.", ats=ats, ) def _build_status_html(title: str, body: str, tone: str = "info") -> str: return ( f'
{escape(body)}
' f'Upload a resume, run the matcher, and this panel will summarize the strongest companies, match volume, and best-fit roles.
Upload a resume, analyze a built-in or custom company list, and get ranked companies, matching jobs, and recruiter talking points in a clean workflow.
Load your resume, choose the company source, and tune how broad the analysis should be.
Start with the summary, then inspect ranked companies, matching jobs, resume profile fields, and recruiter talking points.