nsbecf / app.py
acarey5
small fix
70f34e2
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'<div class="status-card {escape(tone)}">'
f'<strong>{escape(title)}</strong>'
f'<p>{escape(body)}</p>'
f'</div>'
)
def _build_summary_html(ranked_rows: List[List[Any]], match_rows: List[List[Any]]) -> str:
if not ranked_rows:
return """
<div class="empty-state">
<strong>No ranking data yet</strong>
<p>Upload a resume, run the matcher, and this panel will summarize the strongest companies, match volume, and best-fit roles.</p>
</div>
"""
top_company = str(ranked_rows[0][0]) if ranked_rows else "-"
top_score = f"{float(ranked_rows[0][1]):.1f}" if ranked_rows and ranked_rows[0][1] not in (None, "") else "-"
total_companies = len(ranked_rows)
total_jobs = len(match_rows)
avg_score = "-"
if ranked_rows:
scores = [float(row[1]) for row in ranked_rows if row[1] not in (None, "")]
if scores:
avg_score = f"{sum(scores) / len(scores):.1f}"
best_role = str(ranked_rows[0][3]) if ranked_rows and len(ranked_rows[0]) > 3 else "-"
return f"""
<div class="summary-shell">
<div class="summary-grid">
<div class="summary-card">
<span>Top Company</span>
<strong>{escape(top_company)}</strong>
<small>Best-fit company based on resolved job boards and resume alignment.</small>
</div>
<div class="summary-card">
<span>Top Score</span>
<strong>{escape(top_score)}</strong>
<small>Highest company fit score in the current analysis.</small>
</div>
<div class="summary-card">
<span>Companies / Jobs</span>
<strong>{total_companies} / {total_jobs}</strong>
<small>Ranked companies and extracted job matches returned in this run.</small>
</div>
<div class="summary-card">
<span>Average Fit / Best Role</span>
<strong>{escape(avg_score)}</strong>
<small>{escape(best_role)}</small>
</div>
</div>
</div>
"""
def _save_company_debug_html(company_name: str, resolved_page_html: str, snapshots: dict[str, str], failure_type: str) -> None:
for stage, html in snapshots.items():
save_debug_html(company_name, html, stage, DEBUG_HTML_DIR)
save_debug_html(company_name, resolved_page_html, "resolved", DEBUG_HTML_DIR)
if failure_type and failure_type not in {"SUCCESS", "UNKNOWN"}:
save_debug_html(company_name, resolved_page_html, failure_type.lower(), DEBUG_HTML_DIR)
def _log_company_diagnostics(
company_name: str,
original_url: str,
resolved_page_url: str,
fetch_method: str,
final_url: str,
html: str,
ats: str,
api_jobs: List[JobPosting],
diagnostics: Any,
resolution_steps: List[str],
) -> None:
log_debug_header(company_name)
log_debug_line("ORIGINAL URL", original_url)
log_debug_line("RESOLVED URL", resolved_page_url)
log_debug_line("FETCH METHOD", fetch_method)
log_debug_line("FINAL URL", final_url)
log_debug_line("RESOLUTION STEPS", resolution_steps)
log_debug_line("HTML LENGTH", len(html))
log_debug_line("ATS", ats)
if ats not in {"greenhouse", "lever"}:
log_debug_line("ATS NOTE", "No ATS API match detected; using generic HTML/script parsing")
log_debug_line("TOTAL ELEMENTS SCANNED", diagnostics.total_elements_scanned)
log_debug_line("RAW TEXT SAMPLE", diagnostics.raw_text_sample[:20])
log_debug_line("CANDIDATES FOUND", diagnostics.candidates_found)
log_debug_line("TITLE FILTER PASSES", diagnostics.title_filtered_count)
log_debug_line("SCRIPT MATCHES", diagnostics.script_matches)
log_debug_line("SCRIPT JOBS", diagnostics.script_jobs_extracted)
log_debug_line("API JOBS", len(api_jobs))
log_debug_line("VALID JOBS", diagnostics.valid_jobs + len(api_jobs))
log_debug_line("SAMPLE TITLES", diagnostics.sample_titles)
log_debug_line("FAILURE TYPE", diagnostics.failure_type)
log_debug_line("SUCCESS", diagnostics.failure_type == "SUCCESS" or len(api_jobs) + diagnostics.valid_jobs > 0)
def analyze_resume(
resume_pdf: Any,
company_source: str,
optional_company_csv: Any,
max_companies: int,
use_ai_parser: bool,
progress: gr.Progress = gr.Progress(),
) -> Tuple[List[List[Any]], List[List[Any]], str, str, str, str]:
resume_path = _resolve_file_path(resume_pdf)
csv_path = _resolve_file_path(optional_company_csv) if company_source == "Custom CSV" else ""
empty_summary = _build_summary_html([], [])
if not resume_path:
return (
[],
[],
json.dumps({"error": "Please upload a resume PDF."}, indent=2),
"",
_build_status_html("Resume required", "Upload a PDF resume to start the analysis.", "error"),
empty_summary,
)
try:
# --- Debug: log pipeline inputs before anything runs ---
import os
print("[analyze] company_source:", company_source)
print("[analyze] csv_path (resolved):", repr(csv_path))
print("[analyze] resume_path:", repr(resume_path))
print("[analyze] cwd:", os.getcwd())
progress(0.05, desc="Extracting resume text")
resume_text = extract_resume_text(resume_path)
progress(0.12, desc="Building resume profile")
profile = build_resume_profile(resume_text, use_ai=use_ai_parser)
# Try to resolve the default CSV path and log clearly if it's missing.
try:
default_csv_path = _default_companies_path()
print("[analyze] default_csv_path:", default_csv_path)
except FileNotFoundError as fnf:
print("[analyze] CRITICAL: default CSV not found:", fnf)
return (
[],
[],
json.dumps({"error": str(fnf)}, indent=2),
"",
_build_status_html("Company list not found", str(fnf), "error"),
empty_summary,
)
companies = load_companies(default_csv_path, csv_path if csv_path else None)
total_loaded = len(companies)
with_url = sum(1 for c in companies if c.careers_url)
print(f"[analyze] Loaded {total_loaded} companies, {with_url} have careers_url")
# Hard-stop early so the user sees a clear reason rather than "0 companies processed".
if total_loaded == 0:
msg = (
"No companies were loaded. "
"Check that the CSV has a company-name column and at least one data row."
)
return (
[],
[],
json.dumps({"error": msg}, indent=2),
"",
_build_status_html("No companies loaded", msg, "error"),
empty_summary,
)
if with_url == 0:
# All companies exist but every careers_url is empty — display which columns exist.
col_sample = list((companies[0].meta or {}).keys())[:12] if companies else []
msg = (
f"Loaded {total_loaded} companies but none have a usable careers URL. "
f"CSV columns detected: {col_sample}. "
"This app now reads only the opening page column (col 4 / 'Direct links to company career/job openings page'). "
"Add valid https URLs in that column."
)
print("[analyze] WARNING:", msg)
return (
[],
[],
json.dumps({"error": msg, "csv_columns": col_sample}, indent=2),
"",
_build_status_html("No careers URLs found", msg, "error"),
empty_summary,
)
companies = companies[: int(max_companies)]
print(f"[analyze] After max_companies cap: {len(companies)} companies to analyze")
progress(0.18, desc=f"Analyzing {len(companies)} companies")
discovered_jobs: List[JobPosting] = []
processed_companies = 0
for index, company in enumerate(companies, start=1):
if not company.careers_url:
continue
try:
progress(0.18 + (0.62 * index / max(1, len(companies))), desc=f"Resolving {company.company}")
resolved_page = resolve_real_jobs_page(company.careers_url)
resolved_url = resolved_page.url or company.careers_url
resolved_html = resolved_page.html
ats = detect_ats(resolved_url, resolved_html)
if resolved_page.fallback_used:
print(f"[scraper] playwright fallback triggered: {resolved_page.fallback_reason or 'fallback_used'}")
api_jobs = fetch_jobs_from_ats_api(company, ats, source_url=resolved_url)
html_jobs, diagnostics = extract_jobs_with_diagnostics(
company,
resolved_html,
ats,
base_url=resolved_url,
)
if diagnostics.valid_jobs == 0 and company.careers_url == resolved_url and diagnostics.failure_type == "UNKNOWN":
diagnostics.failure_type = "SHELL_PAGE"
_save_company_debug_html(
company.company,
resolved_html,
resolved_page.html_snapshots,
diagnostics.failure_type if not api_jobs else "SUCCESS",
)
_log_company_diagnostics(
company.company,
company.careers_url,
resolved_url,
resolved_page.fetch_method,
resolved_page.final_url or resolved_url,
resolved_html,
ats,
api_jobs,
diagnostics,
resolved_page.resolution_steps,
)
jobs = api_jobs[:]
if len(jobs) < 3:
jobs.extend(html_jobs)
if not jobs:
print(f"[scraper] {company.company} failed at parsing step with failure type: {diagnostics.failure_type}")
jobs = [_fallback_job(company.company, resolved_url, ats)]
discovered_jobs.extend(jobs)
processed_companies += 1
except Exception as company_exc:
print("=" * 60)
print(f"COMPANY: {company.company}")
print(f"FAILURE TYPE: PARSING_ERROR")
print(f"SUCCESS: False")
print(f"STEP BROKE: analyze_resume loop")
print(f"ERROR: {company_exc}")
continue
progress(0.86, desc="Scoring matches")
matches = [score_job_match(job, profile) for job in discovered_jobs]
matches = sorted(matches, key=lambda item: item.score, reverse=True)
rankings = rank_companies(matches)
ranked_rows = [
[r.company, r.company_score, r.match_count, r.best_role, r.ats, r.explanation]
for r in rankings[:50]
]
match_rows = [
[m.company, m.title, m.location, m.score, m.ats, m.url, m.explanation]
for m in matches[:250]
]
profile_json = json.dumps(resume_profile_to_json(profile), indent=2)
talking_points = build_talking_points(rankings, matches)
status_html = _build_status_html(
"Analysis complete",
f"Processed {processed_companies} companies, extracted {len(match_rows)} job matches, and ranked {len(ranked_rows)} companies.",
"success",
)
summary_html = _build_summary_html(ranked_rows, match_rows)
progress(1.0, desc="Done")
return ranked_rows, match_rows, profile_json, talking_points, status_html, summary_html
except Exception as exc:
return (
[],
[],
json.dumps({"error": str(exc)}, indent=2),
"",
_build_status_html("Analysis failed", str(exc), "error"),
empty_summary,
)
with gr.Blocks(title="AI Career Fair Matcher") as demo:
with gr.Column(elem_classes=["app-shell"]):
gr.HTML(
"""
<section class="app-hero">
<div class="eyebrow">AI Career Fair Matcher</div>
<h1 class="hero-title">Prioritize the right companies before you ever walk into the fair.</h1>
<p class="hero-copy">
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.
</p>
<div class="hero-meta">
<div class="hero-pill"><strong>Resume Parsing</strong>Uses AI to extract structured information from your resume.</div>
<div class="hero-pill"><strong>Job Discovery</strong>Resolves real jobs pages behind career search shells.</div>
<div class="hero-pill"><strong>Actionable Output</strong>Ranked targets, matching roles, and talking points.</div>
</div>
</section>
"""
)
with gr.Row(equal_height=False):
with gr.Column(scale=5, min_width=360, elem_classes=["panel", "control-panel"]):
gr.Markdown(
"""
<div class="section-title">
<h3>Workspace</h3>
<p>Load your resume, choose the company source, and tune how broad the analysis should be.</p>
</div>
<div class="chip-row">
<span class="chip">Dark Mode Default</span>
<span class="chip">AI Resume Parsing</span>
<span class="chip">Built-In NSBE Dataset</span>
</div>
""",
elem_classes=["section-title"],
)
with gr.Group(elem_classes=["subcard"]):
gr.Markdown("<div class='subcard-title'>Resume Upload</div>")
resume_input = gr.File(label="Upload resume PDF", file_types=[".pdf"], elem_classes=["upload-card"])
with gr.Group(elem_classes=["subcard"]):
gr.Markdown("<div class='subcard-title'>Company Source</div>")
company_source_input = gr.Radio(
choices=["Built-in NSBE List", "Custom CSV"],
value="Built-in NSBE List",
label="Choose company source",
)
company_csv_input = gr.File(label="Optional custom company CSV", file_types=[".csv"], elem_classes=["upload-card"])
with gr.Group(elem_classes=["subcard"]):
gr.Markdown("<div class='subcard-title'>Analysis Settings</div>")
use_ai_parser_input = gr.Checkbox(
value=True,
label="Use AI Resume Parser",
)
gr.Markdown(
"<div class='results-note'>Uses AI to extract structured information from your resume.</div>"
)
max_companies_input = gr.Slider(
minimum=5,
maximum=100,
step=1,
value=30,
label="Max companies to analyze",
)
analyze_button = gr.Button("Analyze Career Fair Fit", variant="primary")
gr.Markdown(
"""
<div class="results-note">
Designed for quick scanning: inputs stay compact on the left while results, summaries, and tabs stay dense and readable on the right.
</div>
"""
)
with gr.Column(scale=7, min_width=420, elem_classes=["panel", "results-panel"]):
gr.Markdown(
"""
<div class="section-title">
<h3>Results</h3>
<p>Start with the summary, then inspect ranked companies, matching jobs, resume profile fields, and recruiter talking points.</p>
</div>
""",
elem_classes=["section-title"],
)
status_output = gr.HTML(
value=_build_status_html(
"Ready to analyze",
"Upload a resume, optionally add a custom CSV, and launch the matcher.",
"info",
)
)
summary_output = gr.HTML(value=_build_summary_html([], []))
with gr.Group(elem_classes=["subcard"]):
with gr.Tabs():
with gr.TabItem("Ranked Companies", elem_classes=["tab-panel"]):
ranked_output = gr.Dataframe(
headers=["Company", "Score", "Matches", "Best Role", "ATS", "Explanation"],
label="Ranked Companies",
wrap=True,
)
with gr.TabItem("Matching Jobs", elem_classes=["tab-panel"]):
jobs_output = gr.Dataframe(
headers=["Company", "Job Title", "Location", "Score", "ATS", "URL", "Why It Matches"],
label="Matching Jobs",
wrap=True,
)
with gr.TabItem("Resume Profile", elem_classes=["tab-panel"]):
profile_output = gr.Code(label="Resume Profile JSON", language="json")
with gr.TabItem("Talking Points", elem_classes=["tab-panel"]):
talking_points_output = gr.Markdown(label="Talking Points")
analyze_button.click(
fn=analyze_resume,
inputs=[resume_input, company_source_input, company_csv_input, max_companies_input, use_ai_parser_input],
outputs=[ranked_output, jobs_output, profile_output, talking_points_output, status_output, summary_output],
)
if __name__ == "__main__":
demo.queue().launch(theme=APP_THEME, css=CUSTOM_CSS, ssr_mode=False)