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