Niketjain2002's picture
Fix: escape pipe chars in candidate names/titles to prevent markdown table breakage
53346fd verified
"""
Recruitment Intelligence — Batch Resume Ranker
Hugging Face Spaces (Gradio)
Upload 1 JD + up to 30 resumes -> get a prioritized candidate ranking.
Built for recruiters who need to quickly identify top candidates from a stack.
"""
import json
import os
import csv
import io
import time
import traceback
from pathlib import Path
import gradio as gr
from src.pipeline import RecruitmentIntelligencePipeline
from src.sourcing import CandidateSourcer
# ── Config ──────────────────────────────────
LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "google")
LLM_MODEL = os.environ.get("LLM_MODEL", None)
_pipeline = None
def get_pipeline():
global _pipeline
if _pipeline is None:
_pipeline = RecruitmentIntelligencePipeline(
mode="llm",
provider=LLM_PROVIDER,
model=LLM_MODEL,
)
return _pipeline
_sourcer = None
def get_sourcer():
global _sourcer
if _sourcer is None:
from src.feature_extractor import LLMClient
_sourcer = CandidateSourcer(llm_client=LLMClient(provider=LLM_PROVIDER, model=LLM_MODEL))
return _sourcer
# ── Resume Parsing ──────────────────────────
def extract_text_from_file(file_path: str) -> str:
"""Extract text from PDF, DOCX, or TXT file."""
path = Path(file_path)
suffix = path.suffix.lower()
if suffix == ".txt":
return path.read_text(encoding="utf-8", errors="replace")
elif suffix == ".pdf":
try:
import PyPDF2
text_parts = []
with open(file_path, "rb") as f:
reader = PyPDF2.PdfReader(f)
for page in reader.pages:
text_parts.append(page.extract_text() or "")
return "\n".join(text_parts)
except Exception as e:
return f"[PDF extraction failed: {e}]"
elif suffix in (".docx", ".doc"):
try:
from docx import Document
doc = Document(file_path)
return "\n".join(p.text for p in doc.paragraphs)
except Exception as e:
return f"[DOCX extraction failed: {e}]"
else:
# Try reading as plain text
try:
return path.read_text(encoding="utf-8", errors="replace")
except Exception:
return f"[Unsupported file format: {suffix}]"
def get_candidate_name_from_filename(file_path: str) -> str:
"""Extract a candidate name from the filename as fallback."""
name = Path(file_path).stem
# Clean up common patterns
for remove in ["resume", "cv", "Resume", "CV", "_", "-", ".", "(", ")", "copy"]:
name = name.replace(remove, " ")
return " ".join(name.split()).strip() or "Unknown"
# ── Tier Classification ─────────────────────
def classify_tier(overall_prob: float) -> str:
if overall_prob >= 55:
return "Priority 1 - Highly Likely"
elif overall_prob >= 40:
return "Priority 2 - Likely"
elif overall_prob >= 25:
return "Priority 3 - Possible"
else:
return "Priority 4 - Unlikely"
def tier_emoji(tier: str) -> str:
if "Priority 1" in tier:
return "[P1]"
elif "Priority 2" in tier:
return "[P2]"
elif "Priority 3" in tier:
return "[P3]"
return "[P4]"
# ── Batch Evaluation ────────────────────────
def run_batch_evaluation(
job_description, resume_files,
stage, industry, compensation, location, remote_type,
progress=gr.Progress(track_tqdm=False),
):
"""Process all resumes against the JD and return ranked results."""
# Validate
if not job_description or len(job_description.strip()) < 50:
raise gr.Error("Job description must be at least 50 characters.")
if not resume_files or len(resume_files) == 0:
raise gr.Error("Please upload at least one resume file.")
if len(resume_files) > 40:
raise gr.Error("Maximum 40 resumes per batch. Please reduce the number of files.")
company_context = {
"stage": stage,
"industry": industry,
"compensation_band": compensation,
"location": location,
"remote_type": remote_type,
}
try:
pipeline = get_pipeline()
except Exception as e:
raise gr.Error(f"Failed to initialize pipeline: {e}")
total = len(resume_files)
# Step 1: Extract role features ONCE (saves 1 LLM call per candidate)
progress(0, desc=f"Analyzing job description...")
try:
_, role_features = pipeline.extract_role_features_once(job_description, company_context)
except Exception as e:
raise gr.Error(f"Failed to analyze job description: {e}")
# Step 2: Evaluate each candidate
results = []
errors = []
for i, file in enumerate(resume_files):
# Gradio file handling: type="filepath" gives strings, objects have .name
if isinstance(file, str):
file_path = file
elif hasattr(file, 'name'):
file_path = file.name
else:
file_path = str(file)
filename = Path(file_path).name
progress((i + 1) / (total + 1), desc=f"Evaluating {i+1}/{total}: {filename}")
try:
resume_text = extract_text_from_file(file_path)
if len(resume_text.strip()) < 30:
errors.append(f"{filename}: Too short or empty after extraction")
continue
result = pipeline.evaluate_single_with_role_features(
job_description=job_description,
company_context=company_context,
resume_text=resume_text,
role_features=role_features,
)
candidate_name = result.evaluation_metadata.get("candidate_name", "Unknown")
if not candidate_name or candidate_name == "Unknown" or candidate_name == "null":
candidate_name = get_candidate_name_from_filename(file_path)
results.append({
"name": candidate_name,
"filename": filename,
"overall": result.overall_hire_probability,
"shortlist": result.shortlist_probability,
"offer_accept": result.offer_acceptance_probability,
"retention": result.retention_6m_probability,
"confidence": result.confidence_level,
"tier": classify_tier(result.overall_hire_probability),
"top_strength": result.positive_signals[0] if result.positive_signals else "N/A",
"top_risk": result.risk_signals[0] if result.risk_signals else "None",
"summary": result.reasoning_summary,
"positive_signals": result.positive_signals,
"risk_signals": result.risk_signals,
"recommendation": result.recommendation,
})
except Exception as e:
traceback.print_exc()
errors.append(f"{filename}: {str(e)[:100]}")
# Step 3: Rank by overall probability
results.sort(key=lambda x: x["overall"], reverse=True)
# Step 4: Build outputs
progress(1.0, desc="Building results...")
# Table data
table_data = []
for rank, r in enumerate(results, 1):
table_data.append([
rank,
r["name"],
r["tier"],
f'{r["overall"]:.1f}%',
f'{r["shortlist"]:.1f}%',
f'{r["offer_accept"]:.1f}%',
f'{r["retention"]:.1f}%',
r["confidence"].upper(),
r["top_strength"][:80] if r["top_strength"] != "N/A" else "N/A",
r["top_risk"][:80] if r["top_risk"] != "None" else "None identified",
])
# Summary stats
p1_count = sum(1 for r in results if "Priority 1" in r["tier"])
p2_count = sum(1 for r in results if "Priority 2" in r["tier"])
p3_count = sum(1 for r in results if "Priority 3" in r["tier"])
p4_count = sum(1 for r in results if "Priority 4" in r["tier"])
summary_md = f"""## Batch Results: {len(results)} candidates ranked
| Tier | Count | Action |
|------|-------|--------|
| **Priority 1 - Highly Likely** | {p1_count} | Call immediately |
| **Priority 2 - Likely** | {p2_count} | Schedule screening |
| **Priority 3 - Possible** | {p3_count} | Review if P1/P2 fall through |
| **Priority 4 - Unlikely** | {p4_count} | Skip |
"""
if p1_count > 0:
top_names = [r["name"] for r in results if "Priority 1" in r["tier"]]
summary_md += f'**Top candidates to call first:** {", ".join(top_names)}\n\n'
if errors:
summary_md += f"\n**{len(errors)} file(s) had issues:**\n"
for err in errors:
summary_md += f"- {err}\n"
# Detailed breakdown (markdown)
details_md = ""
for rank, r in enumerate(results, 1):
details_md += f"### #{rank}. {r['name']}{r['tier']}\n"
details_md += f"**Overall: {r['overall']:.1f}%** | "
details_md += f"Shortlist: {r['shortlist']:.1f}% | "
details_md += f"Offer Accept: {r['offer_accept']:.1f}% | "
details_md += f"Retention: {r['retention']:.1f}%\n\n"
details_md += f"> {r['summary']}\n\n"
if r["positive_signals"]:
details_md += "**Strengths:**\n"
for s in r["positive_signals"][:3]:
details_md += f"- {s}\n"
if r["risk_signals"]:
details_md += "\n**Risks:**\n"
for s in r["risk_signals"][:3]:
details_md += f"- {s}\n"
details_md += "\n---\n\n"
# CSV export
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer)
writer.writerow([
"Rank", "Candidate", "Tier", "Overall %", "Shortlist %",
"Offer Accept %", "Retention %", "Confidence",
"Top Strength", "Top Risk", "Summary"
])
for rank, r in enumerate(results, 1):
writer.writerow([
rank, r["name"], r["tier"],
f'{r["overall"]:.1f}', f'{r["shortlist"]:.1f}',
f'{r["offer_accept"]:.1f}', f'{r["retention"]:.1f}',
r["confidence"], r["top_strength"], r["top_risk"], r["summary"],
])
csv_path = "/tmp/candidate_rankings.csv"
with open(csv_path, "w", newline="", encoding="utf-8") as f:
f.write(csv_buffer.getvalue())
return table_data, summary_md, details_md, csv_path
# ── Single Evaluation ──────────────────────
def run_single_evaluation(
job_description, resume_text,
stage, industry, compensation, location, remote_type,
):
"""Single candidate evaluation (paste resume text)."""
if not job_description or len(job_description.strip()) < 50:
raise gr.Error("Job description must be at least 50 characters.")
if not resume_text or len(resume_text.strip()) < 50:
raise gr.Error("Resume must be at least 50 characters.")
company_context = {
"stage": stage,
"industry": industry,
"compensation_band": compensation,
"location": location,
"remote_type": remote_type,
}
pipeline = get_pipeline()
result = pipeline.evaluate(
job_description=job_description,
company_context=company_context,
resume_text=resume_text,
)
data = result.to_full_json()
overall = f"{data['overall_hire_probability']:.1f}%"
shortlist = f"{data['shortlist_probability']:.1f}%"
offer = f"{data['offer_acceptance_probability']:.1f}%"
retention = f"{data['retention_6m_probability']:.1f}%"
confidence = data["confidence_level"].upper()
tier = classify_tier(data["overall_hire_probability"])
signals_md = f"**Tier: {tier}**\n\n"
pos = data.get("positive_signals", [])
risk = data.get("risk_signals", [])
missing = data.get("missing_signals", [])
if pos:
signals_md += "### Positive Signals\n"
for s in pos:
signals_md += f"- {s}\n"
signals_md += "\n"
if risk:
signals_md += "### Risk Signals\n"
for s in risk:
signals_md += f"- {s}\n"
signals_md += "\n"
if missing:
signals_md += "### Missing Information\n"
for s in missing:
signals_md += f"- {s}\n"
return overall, shortlist, offer, retention, confidence, data.get("reasoning_summary", ""), signals_md
# ── Candidate Sourcing ─────────────────────
def run_candidate_sourcing(
job_description, location, industry, compensation, stage,
):
"""Generate Google X-ray search queries for candidate sourcing."""
if not job_description or len(job_description.strip()) < 50:
raise gr.Error("Job description must be at least 50 characters.")
try:
sourcer = get_sourcer()
except Exception as e:
raise gr.Error(f"Failed to initialize sourcer: {e}")
try:
result = sourcer.generate_queries(
job_description=job_description,
location=location or "Bangalore",
industry=industry,
compensation_band=compensation,
company_stage=stage,
)
except Exception as e:
raise gr.Error(f"Query generation failed: {e}")
# Format: Extracted Keywords
analysis = result.get("analysis", {})
keywords_md = "## Extracted Keywords\n\n"
if analysis.get("extracted_titles"):
keywords_md += "**Job Titles:** " + ", ".join(analysis["extracted_titles"]) + "\n\n"
if analysis.get("key_skills"):
keywords_md += "**Key Skills:** " + ", ".join(analysis["key_skills"]) + "\n\n"
if analysis.get("target_companies"):
keywords_md += "**Target Companies:** " + ", ".join(analysis["target_companies"]) + "\n\n"
if analysis.get("target_institutes"):
keywords_md += "**Target Institutes:** " + ", ".join(analysis["target_institutes"]) + "\n\n"
if analysis.get("certifications"):
keywords_md += "**Certifications:** " + ", ".join(analysis["certifications"]) + "\n\n"
if analysis.get("seniority_indicators"):
keywords_md += "**Seniority Indicators:** " + ", ".join(analysis["seniority_indicators"]) + "\n\n"
# Format: X-Ray Queries
queries_md = "## Google X-Ray Search Queries\n\n"
queries_md += "*Click the Google links to run each search. Use the copy button on code blocks to grab queries.*\n\n"
for i, q in enumerate(result.get("queries", []), 1):
queries_md += f"### Query {i}: {q.get('name', 'Search query')}\n"
queries_md += f"**Strategy:** {q.get('strategy', '')}\n\n"
queries_md += f"```\n{q.get('query', '')}\n```\n\n"
if q.get("google_url"):
queries_md += f"[Open in Google]({q['google_url']})\n\n"
if q.get("expected_results"):
queries_md += f"*Expected:* {q['expected_results']}\n\n"
queries_md += "---\n\n"
# Format: Boolean Strings
boolean_md = "## LinkedIn Boolean Strings\n\n"
boolean_md += "*Paste these directly into LinkedIn's search bar.*\n\n"
for i, b in enumerate(result.get("boolean_strings", []), 1):
boolean_md += f"### {i}. {b.get('name', 'Boolean string')}\n"
boolean_md += f"**Purpose:** {b.get('purpose', '')}\n\n"
boolean_md += f"```\n{b.get('string', '')}\n```\n\n"
# Format: Sourcing Tips
tips_md = "## Sourcing Tips\n\n"
for tip in result.get("sourcing_tips", []):
tips_md += f"- {tip}\n"
return keywords_md, queries_md, boolean_md, tips_md
# ── Candidate Sourcing: Live Search ────────
def run_find_candidates(
job_description, location, industry, compensation, stage,
):
"""Generate queries AND run live Serper.dev search to find candidate profiles."""
if not job_description or len(job_description.strip()) < 50:
raise gr.Error("Job description must be at least 50 characters.")
try:
sourcer = get_sourcer()
except Exception as e:
raise gr.Error(f"Failed to initialize sourcer: {e}")
if not sourcer.searcher.is_configured:
raise gr.Error(
"SERPER_API_KEY is not set. To use Find Candidates:\n"
"1. Get a free API key at https://serper.dev (2,500 searches, no credit card)\n"
"2. Add it as a HF Spaces secret: Settings -> Repository Secrets -> SERPER_API_KEY\n\n"
"You can still use 'Generate Search Queries' without an API key."
)
try:
result = sourcer.find_candidates(
job_description=job_description,
location=location or "Bangalore",
industry=industry,
compensation_band=compensation,
company_stage=stage,
)
except Exception as e:
raise gr.Error(f"Candidate search failed: {e}")
candidates = result.get("candidates", [])
# Summary + candidate table as Markdown (Dataframe has rendering bugs)
n = len(candidates)
queries_used = min(3, len(result.get("queries", [])))
summary_md = f"## Found {n} Candidate{'s' if n != 1 else ''}\n\n"
summary_md += f"*Searched {queries_used} Google X-ray quer{'ies' if queries_used != 1 else 'y'} via Serper.dev*\n\n"
if n == 0:
summary_md += (
"No LinkedIn profiles found. Try broadening the job description "
"or adjusting the location/industry filters."
)
else:
summary_md += "| # | Name | Current Role | Company | Profile | Queries Matched |\n"
summary_md += "|---|------|-------------|---------|---------|----------------|\n"
for i, c in enumerate(candidates, 1):
# Escape pipe chars so they don't break the markdown table
name = c["name"].replace("|", "/")
title = (c["title"] or "—").replace("|", "/")
company = (c["company"] or "—").replace("|", "/")
url = c["linkedin_url"]
matched = c["matched_queries"]
summary_md += f"| {i} | **{name}** | {title} | {company} | [LinkedIn]({url}) | {matched} |\n"
# Reuse existing formatting for keywords, queries, boolean, tips
analysis = result.get("analysis", {})
keywords_md = "## Extracted Keywords\n\n"
if analysis.get("extracted_titles"):
keywords_md += "**Job Titles:** " + ", ".join(analysis["extracted_titles"]) + "\n\n"
if analysis.get("key_skills"):
keywords_md += "**Key Skills:** " + ", ".join(analysis["key_skills"]) + "\n\n"
if analysis.get("target_companies"):
keywords_md += "**Target Companies:** " + ", ".join(analysis["target_companies"]) + "\n\n"
if analysis.get("target_institutes"):
keywords_md += "**Target Institutes:** " + ", ".join(analysis["target_institutes"]) + "\n\n"
if analysis.get("certifications"):
keywords_md += "**Certifications:** " + ", ".join(analysis["certifications"]) + "\n\n"
if analysis.get("seniority_indicators"):
keywords_md += "**Seniority Indicators:** " + ", ".join(analysis["seniority_indicators"]) + "\n\n"
queries_md = "## Google X-Ray Search Queries\n\n"
for i, q in enumerate(result.get("queries", []), 1):
queries_md += f"### Query {i}: {q.get('name', 'Search query')}\n"
queries_md += f"**Strategy:** {q.get('strategy', '')}\n\n"
queries_md += f"```\n{q.get('query', '')}\n```\n\n"
if q.get("google_url"):
queries_md += f"[Open in Google]({q['google_url']})\n\n"
queries_md += "---\n\n"
boolean_md = "## LinkedIn Boolean Strings\n\n"
for i, b in enumerate(result.get("boolean_strings", []), 1):
boolean_md += f"### {i}. {b.get('name', 'Boolean string')}\n"
boolean_md += f"**Purpose:** {b.get('purpose', '')}\n\n"
boolean_md += f"```\n{b.get('string', '')}\n```\n\n"
tips_md = "## Sourcing Tips\n\n"
for tip in result.get("sourcing_tips", []):
tips_md += f"- {tip}\n"
return summary_md, keywords_md, queries_md, boolean_md, tips_md
# ── Example Data ────────────────────────────
EXAMPLE_JD = """Senior Consultant — Technology Consulting
About the Role:
We are hiring a Senior Consultant for our Technology Consulting practice in Bangalore. You will work with leading Indian enterprises on digital transformation, cloud migration, and IT strategy engagements.
Responsibilities:
- Lead workstreams on technology consulting engagements for BFSI, telecom, and manufacturing clients
- Develop IT strategy roadmaps and digital transformation blueprints
- Conduct current-state assessments and define target-state architectures
- Manage client stakeholder relationships and present to CXO-level audiences
- Mentor Associates and Analysts on the team
Requirements:
- 5-8 years of experience in technology consulting or IT advisory
- Strong understanding of cloud platforms (AWS, Azure, or GCP)
- Experience with enterprise architecture frameworks (TOGAF, Zachman)
- Exposure to ERP systems (SAP, Oracle) or CRM platforms (Salesforce)
- Excellent communication and presentation skills
- MBA or B.Tech/B.E. from a reputed institute
Preferred:
- Prior Big 4 or top-tier consulting experience
- Industry certifications (AWS Solutions Architect, TOGAF, PMP)
- Experience with Indian regulatory frameworks (RBI, SEBI, TRAI)
- Client-facing experience with Fortune 500 / Nifty 50 companies
CTC: 25-40 LPA
Location: Bangalore (hybrid - 3 days in office)"""
# ── Gradio UI ───────────────────────────────
THEME = gr.themes.Base(
primary_hue=gr.themes.colors.indigo,
secondary_hue=gr.themes.colors.slate,
neutral_hue=gr.themes.colors.slate,
font=gr.themes.GoogleFont("Inter"),
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
)
CSS = """
footer { display: none !important; }
.tier-p1 { color: #22c55e; font-weight: 700; }
.tier-p2 { color: #eab308; font-weight: 600; }
.tier-p3 { color: #f97316; }
.tier-p4 { color: #ef4444; opacity: 0.7; }
"""
with gr.Blocks(title="Recruitment Intelligence") as demo:
gr.Markdown("""
# Recruitment Intelligence
**Batch Resume Ranker** — Upload resumes, get a prioritized candidate list.
Enter the job description once, upload up to 30 resumes (PDF, DOCX, or TXT),
and get every candidate ranked by hire probability with evidence-backed reasoning.
*Built for recruiters who need to quickly find the top 5-10 from a stack of 30.*
""")
with gr.Tabs() as tabs:
# ─── TAB 1: BATCH RANKING ───────────────
with gr.Tab("Batch Ranking", id="batch"):
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### Job Description")
jd_batch = gr.Textbox(
label="Paste the full JD",
placeholder="Paste the complete job description here...",
lines=14,
max_lines=30,
)
gr.Markdown("### Company Context")
with gr.Row():
stage_batch = gr.Dropdown(
label="Stage",
choices=["seed", "series_a", "series_b", "series_c", "growth", "public", "enterprise"],
value="enterprise",
)
industry_batch = gr.Textbox(label="Industry", value="consulting", max_lines=1)
with gr.Row():
comp_batch = gr.Textbox(label="Comp Band (LPA)", value="unknown", max_lines=1, placeholder="e.g. 25-40 LPA")
location_batch = gr.Textbox(label="Location", value="Bangalore", max_lines=1)
remote_batch = gr.Dropdown(
label="Remote Policy",
choices=["onsite", "hybrid", "remote", "flexible"],
value="hybrid",
)
with gr.Column(scale=1):
gr.Markdown("### Upload Resumes")
resume_files = gr.File(
label="Upload resume files (PDF, DOCX, or TXT)",
file_count="multiple",
file_types=[".pdf", ".docx", ".doc", ".txt"],
type="filepath",
)
gr.Markdown("""
*Supports PDF, DOCX, and TXT. Upload up to 30 files.*
Each candidate is evaluated independently against the JD.
Processing takes ~15-25 seconds per resume.
""")
batch_btn = gr.Button("Rank All Candidates", variant="primary", size="lg")
# Results
gr.Markdown("---")
batch_summary = gr.Markdown(label="Summary", value="*Upload resumes and click 'Rank All Candidates' to see results.*")
batch_table = gr.Dataframe(
label="Candidate Rankings",
headers=["Rank", "Candidate", "Tier", "Overall", "Shortlist", "Offer Accept", "Retention", "Confidence", "Top Strength", "Top Risk"],
datatype=["number", "str", "str", "str", "str", "str", "str", "str", "str", "str"],
interactive=False,
wrap=True,
)
csv_download = gr.File(label="Download CSV", visible=True)
with gr.Accordion("Detailed Candidate Breakdowns", open=False):
batch_details = gr.Markdown()
# Wire batch
batch_btn.click(
fn=run_batch_evaluation,
inputs=[jd_batch, resume_files, stage_batch, industry_batch, comp_batch, location_batch, remote_batch],
outputs=[batch_table, batch_summary, batch_details, csv_download],
)
# ─── TAB 2: SINGLE EVALUATION ───────────
with gr.Tab("Single Evaluation", id="single"):
gr.Markdown("### Evaluate one candidate by pasting their resume text")
with gr.Row():
with gr.Column(scale=1):
jd_single = gr.Textbox(label="Job Description", lines=10, max_lines=25)
with gr.Row():
stage_single = gr.Dropdown(
label="Stage",
choices=["seed", "series_a", "series_b", "series_c", "growth", "public", "enterprise"],
value="enterprise",
)
industry_single = gr.Textbox(label="Industry", value="consulting", max_lines=1)
with gr.Row():
comp_single = gr.Textbox(label="Comp Band (LPA)", value="unknown", max_lines=1, placeholder="e.g. 25-40 LPA")
location_single = gr.Textbox(label="Location", value="Bangalore", max_lines=1)
remote_single = gr.Dropdown(
label="Remote Policy",
choices=["onsite", "hybrid", "remote", "flexible"],
value="hybrid",
)
with gr.Column(scale=1):
resume_single = gr.Textbox(label="Resume (paste text)", lines=18, max_lines=40)
single_btn = gr.Button("Evaluate Candidate", variant="primary", size="lg")
gr.Markdown("---")
gr.Markdown("### Results")
with gr.Row():
s_overall = gr.Textbox(label="Overall Hire %", interactive=False, max_lines=1)
s_shortlist = gr.Textbox(label="Shortlist %", interactive=False, max_lines=1)
s_offer = gr.Textbox(label="Offer Accept %", interactive=False, max_lines=1)
s_retention = gr.Textbox(label="Retention %", interactive=False, max_lines=1)
s_confidence = gr.Textbox(label="Confidence", interactive=False, max_lines=1)
s_summary = gr.Textbox(label="Reasoning", interactive=False, lines=3)
s_signals = gr.Markdown()
single_btn.click(
fn=run_single_evaluation,
inputs=[jd_single, resume_single, stage_single, industry_single, comp_single, location_single, remote_single],
outputs=[s_overall, s_shortlist, s_offer, s_retention, s_confidence, s_summary, s_signals],
)
# ─── TAB 3: CANDIDATE SOURCING ─────────────
with gr.Tab("Candidate Sourcing", id="sourcing"):
gr.Markdown("""### Find candidates with Google X-Ray LinkedIn Search
Paste a job description and get optimized Google search queries that find matching LinkedIn profiles.
No LinkedIn Recruiter license needed — uses free Google X-ray search (`site:linkedin.com/in`).
""")
with gr.Row():
with gr.Column(scale=2):
jd_sourcing = gr.Textbox(
label="Job Description",
placeholder="Paste the complete job description here...",
lines=14,
max_lines=30,
value=EXAMPLE_JD,
)
with gr.Column(scale=1):
gr.Markdown("### Search Context")
location_sourcing = gr.Textbox(
label="Location",
value="Bangalore",
max_lines=1,
placeholder="e.g. Bangalore, Mumbai, Hyderabad",
)
industry_sourcing = gr.Textbox(
label="Industry",
value="consulting",
max_lines=1,
placeholder="e.g. consulting, fintech, SaaS",
)
comp_sourcing = gr.Textbox(
label="Comp Band (LPA)",
value="25-40 LPA",
max_lines=1,
placeholder="e.g. 25-40 LPA",
)
stage_sourcing = gr.Dropdown(
label="Company Stage",
choices=["seed", "series_a", "series_b", "series_c", "growth", "public", "enterprise"],
value="enterprise",
)
with gr.Row():
sourcing_btn = gr.Button("Generate Search Queries", variant="secondary", size="lg")
find_btn = gr.Button("Find Candidates", variant="primary", size="lg")
gr.Markdown("---")
# Candidate results (shown by Find Candidates)
candidate_summary = gr.Markdown(
value="*Click 'Find Candidates' to search Google for matching LinkedIn profiles, "
"or 'Generate Search Queries' for queries only.*",
)
sourcing_keywords = gr.Markdown()
with gr.Accordion("Google X-Ray Queries", open=True):
sourcing_queries = gr.Markdown()
with gr.Accordion("LinkedIn Boolean Strings", open=True):
sourcing_boolean = gr.Markdown()
with gr.Accordion("Sourcing Tips", open=False):
sourcing_tips = gr.Markdown()
# "Generate Search Queries" — queries only (no API key needed)
sourcing_btn.click(
fn=run_candidate_sourcing,
inputs=[jd_sourcing, location_sourcing, industry_sourcing, comp_sourcing, stage_sourcing],
outputs=[sourcing_keywords, sourcing_queries, sourcing_boolean, sourcing_tips],
)
# "Find Candidates" — queries + live Serper search
find_btn.click(
fn=run_find_candidates,
inputs=[jd_sourcing, location_sourcing, industry_sourcing, comp_sourcing, stage_sourcing],
outputs=[candidate_summary, sourcing_keywords, sourcing_queries, sourcing_boolean, sourcing_tips],
)
gr.Markdown("""
---
**Recruitment Intelligence v1.0** — Probabilistic scoring, not keyword matching.
Scores are conservative: average candidates land 35-55%, only exceptional candidates exceed 75%.
""")
# ── Launch ──────────────────────────────────
if __name__ == "__main__":
demo.launch(theme=THEME, css=CSS, show_error=True)