""" 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)