linkedin_demo / app.py
pushkar24's picture
Upload 3 files
924d10c verified
Raw
History Blame Contribute Delete
35.2 kB
"""
LinkedIn Profile Finder & Verifier - Hugging Face Spaces Optimized Version
Features:
- Two AI agents using GPT-4o-mini for cost/speed optimization
- Batch processing: collects ALL candidates before AI analysis (reduces LLM calls)
- Pydantic structured outputs for reliable parsing
- Proper timeout and error handling for SerpAPI
- Rich logging with emojis for better UX
- Duplicate detection across queries
Agents:
1. FINDER AGENT: Analyzes ALL candidates from ALL queries in one LLM call
2. VERIFIER AGENT: Comprehensive verification with detailed reasoning
Setup:
pip install gradio requests python-dotenv openai pydantic
export SERPAPI_API_KEY=your_serpapi_key
export OPENAI_API_KEY=your_openai_key
python app_hf.py
"""
import os
import re
import json
import time
import random
import gradio as gr
import requests
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, Field
from concurrent.futures import ThreadPoolExecutor, as_completed
# Load environment variables
load_dotenv()
# ===== CONFIGURATION CONSTANTS =====
# App version for tracking builds
APP_VERSION = "v2.6.5-hf"
BUILD_TIME = "2025-01-01 11:15 UTC"
# API Configuration
AI_MODEL = "gpt-4o-mini" # AI model for both finder and verifier agents
MAX_QUERIES = int(os.getenv("MAX_QUERIES", 3)) # fewer queries = faster
SERP_NUM = int(os.getenv("SERP_NUM", 5)) # fewer results per query
SERP_TIMEOUT = float(os.getenv("SERP_TIMEOUT", 6))
# HF Spaces specific - calm the logs
VERBOSE_LOGGING = os.getenv("VERBOSE_LOGGING", "false").lower() == "true"
# Wealth-domain keyword hints used for light scoring
WEALTH_MARKERS = [
"wealth", "wealth management", "advisor", "advisors", "ria", "broker-dealer",
"private wealth", "financial planning", "asset management", "investment management",
"fiduciary", "family office", "portfolio", "aum", "cfa", "cfp"
]
# Role to title mappings for US market
ROLE_TITLES = {
"engineering": ["CTO", "Chief Technology Officer", "VP Engineering", "Head of Engineering", "Director of Engineering", "Head of Technology"],
"product": ["CPO", "Chief Product Officer", "VP Product", "Head of Product", "Director of Product"],
"sales": ["CRO", "Chief Revenue Officer", "VP Sales", "Head of Sales", "Sales Director", "VP Business Development", "Head of Partnerships"],
"finance": ["CFO", "Chief Financial Officer", "VP Finance", "Head of Finance", "Finance Director", "Financial Controller", "Head of FP&A"],
"legal": ["General Counsel", "Chief Legal Officer", "Head of Legal", "Legal Director", "Corporate Counsel", "Head of Compliance"]
}
# US states for bias detection
US_STATES = ["Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware",
"Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky",
"Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi",
"Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico",
"New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania",
"Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
"Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming"]
# Initialize OpenAI client with minimal logging for HF Spaces
import logging
if VERBOSE_LOGGING:
# Set up OpenAI logging only if verbose mode is enabled
logging.basicConfig(level=logging.DEBUG)
openai_logger = logging.getLogger("openai")
openai_logger.setLevel(logging.DEBUG)
else:
# Minimal logging for HF Spaces
logging.basicConfig(level=logging.WARNING)
client = OpenAI(
api_key=os.getenv('OPENAI_API_KEY'),
# Enable request logging for debugging
default_headers={"User-Agent": f"LinkedIn-Finder-Verifier/{APP_VERSION}"}
)
# Pydantic response schemas for structured LLM outputs
class FinderResponse(BaseModel):
"""Schema for Finder Agent response with proper validation and documentation"""
selected_index: int = Field(
description="0-based index of the selected candidate from the provided list",
ge=0
)
confidence_score: float = Field(
description="Confidence score between 0.0 and 1.0 for the selection",
ge=0.0,
le=1.0
)
reasoning: str = Field(
description="Detailed explanation of why this candidate was selected",
min_length=10
)
class VerifierResponse(BaseModel):
"""Schema for Verifier Agent response with proper validation and documentation"""
verified: bool = Field(
description="True if candidate is verified as a legitimate match, False otherwise"
)
confidence: float = Field(
description="Confidence score between 0.0 and 1.0 for the verification decision",
ge=0.0,
le=1.0
)
reasoning: str = Field(
description="Detailed explanation of the verification decision",
min_length=10
)
red_flags: list[str] = Field(
description="List of concerns or issues identified with the candidate",
default_factory=list
)
strengths: list[str] = Field(
description="List of positive indicators or strengths of the candidate",
default_factory=list
)
def log_openai_request(agent_name, model, messages, response_format=None):
"""Log OpenAI request details - calmed for HF Spaces"""
if VERBOSE_LOGGING:
print(f"\nπŸ€– === {agent_name.upper()} OPENAI REQUEST ===")
print(f"πŸ“‘ Model: {model}")
print(f"πŸ“ Messages ({len(messages)} total):")
for i, msg in enumerate(messages):
role = msg['role']
content = msg['content'][:200] + "..." if len(msg['content']) > 200 else msg['content']
print(f" {i+1}. {role}: {content}")
if response_format:
print(f"🎯 Response Format: {response_format}")
print("=" * 60)
else:
print(f"πŸ€– {agent_name} processing...")
def log_openai_response(agent_name, response, response_time=None):
"""Log OpenAI response details - calmed for HF Spaces"""
if VERBOSE_LOGGING:
print(f"\nπŸ€– === {agent_name.upper()} OPENAI RESPONSE ===")
if hasattr(response, 'usage'):
usage = response.usage
print(f"πŸ’° Token Usage:")
print(f" Prompt tokens: {usage.prompt_tokens}")
print(f" Completion tokens: {usage.completion_tokens}")
print(f" Total tokens: {usage.total_tokens}")
if hasattr(response, 'model'):
print(f"πŸ€– Model used: {response.model}")
if response_time:
print(f"⏱️ Response time: {response_time:.2f}s")
content = response.choices[0].message.content
print(f"πŸ“„ Response content:")
print(f" {content}")
print("=" * 60)
else:
if response_time:
print(f"βœ… {agent_name} completed in {response_time:.2f}s")
def normalize_company_name(company_name):
"""Normalize company name by removing suffixes and role-related words"""
# Remove common company suffixes
suffixes = r'\b(Inc\.?|LLC\.?|Corp\.?|Co\.?|Ltd\.?|LLP\.?|LP\.?|PLC\.?)\b'
normalized = re.sub(suffixes, '', company_name, flags=re.IGNORECASE)
# Remove common role-related words that might be mistakenly included in company names
role_words = [
r'\badvisors?\b', r'\badvisory\b', r'\bconsulting\b', r'\bconsultants?\b',
r'\bfinancial\b', r'\bwealth\b', r'\bmanagement\b', r'\bservices?\b',
r'\bsolutions?\b', r'\bgroup\b', r'\bholdings?\b', r'\bpartners?\b',
r'\bcapital\b', r'\binvestments?\b', r'\basset\b', r'\bfirm\b'
]
# Try removing role words and see if we get a cleaner company name
original_normalized = normalized
for role_word in role_words:
test_normalized = re.sub(role_word, '', normalized, flags=re.IGNORECASE).strip()
test_normalized = re.sub(r'\s+', ' ', test_normalized).strip()
# Only use the cleaned version if it leaves us with a substantial company name
if test_normalized and len(test_normalized) >= 3 and len(test_normalized.split()) >= 1:
normalized = test_normalized
if VERBOSE_LOGGING:
print(f"🧹 Cleaned company name: '{original_normalized}' β†’ '{normalized}'")
break
# Normalize punctuation and spaces
normalized = re.sub(r'[^\w\s]', ' ', normalized)
normalized = re.sub(r'\s+', ' ', normalized).strip()
return normalized
def extract_domain_from_website(website):
"""Extract domain from website URL"""
if not website:
return None
# Remove protocol and www
domain = re.sub(r'^https?://', '', website)
domain = re.sub(r'^www\.', '', domain)
domain = domain.split('/')[0] # Remove path
return domain
def quick_score(candidate, core_tokens, role):
"""Quick scoring function for ranking candidates before LLM analysis.
Penalizes social-only mentions (follows/interests) without employment cues.
"""
title = candidate.get('title', '').lower()
snippet = candidate.get('snippet', '').lower()
text = f"{title} {snippet}"
# Title matching score
role_titles = ROLE_TITLES.get(role, [])
title_score = 0.0
for expected_title in role_titles:
if expected_title.lower() in title:
title_score = 1.0
break
if title_score == 0.0:
role_keywords = {
'engineering': ['engineer', 'technology', 'tech', 'cto'],
'product': ['product', 'cpo'],
'sales': ['sales', 'revenue', 'cro', 'business development'],
'finance': ['finance', 'financial', 'cfo', 'fp&a'],
'legal': ['legal', 'counsel', 'compliance']
}
for keyword in role_keywords.get(role, []):
if keyword in title:
title_score = 0.4
break
# Company matching score (stricter, no regex)
company_score = 0.2 # start conservative
toks = [tok.lower() for tok in core_tokens if tok]
if toks:
has_token = any(tok in text for tok in toks)
seps = [" - ", " β€” ", " – ", " β€’ "]
pos_at = any((f"at {tok}" in text) or (f"@ {tok}" in text) for tok in toks)
pos_headline = any(((sep + tok) in text) or ((tok + sep) in text) for sep in seps for tok in toks)
neg_social = any(w in text for w in ["interest", "interests", "follows", "following", "follower", "likes", "liked"])
if pos_at or pos_headline:
company_score = 1.0
elif has_token and not neg_social:
company_score = 0.5
else:
company_score = 0.2
# Seniority score
seniority_score = 0.4
if any(word in title for word in ['chief', 'ceo', 'cto', 'cpo', 'cro', 'cfo']):
seniority_score = 1.0
elif any(word in title for word in ['vp', 'vice president']):
seniority_score = 0.8
elif any(word in title for word in ['head of', 'head']):
seniority_score = 0.7
elif 'director' in title:
seniority_score = 0.6
# US bias
us_bias = 0.0
if any(w in text for w in ['united states', 'u.s.a.', 'usa', 'u.s.', 'united states of america']):
us_bias = 0.1
elif any(w in text for w in ['philippines', 'india', 'pakistan', 'nigeria', 'canada', 'australia', 'united kingdom', 'uk']):
us_bias = -0.25
final_score = (0.5 * title_score + 0.3 * company_score + 0.2 * seniority_score) + us_bias
return max(0.0, min(1.0, final_score)) # Clamp between 0 and 1
def build_search_queries(company_name, company_website, role):
"""Build search queries for SerpAPI with aliasing support and robust tokenization"""
core_company = normalize_company_name(company_name)
# robust tokenization on punctuation & whitespace
core_tokens = [t for t in re.split(r"[&\-\.\s]+", core_company.lower()) if t]
titles = ROLE_TITLES.get(role, [])
or_joined_titles = " OR ".join([f'"{title}"' for title in titles])
queries = []
# Primary company name search
queries.append(f'site:linkedin.com/in ({or_joined_titles}) "{core_company}"')
# Company name with US location
queries.append(f'site:linkedin.com/in ({or_joined_titles}) "{core_company}" "United States"')
# Domain-based search (if website provided)
if company_website:
domain = extract_domain_from_website(company_website)
if domain:
queries.append(f'site:linkedin.com/in ({or_joined_titles}) "{domain}"')
return queries, core_company, core_tokens
def build_search_queries_wealth(company_name, company_website, role):
"""Wealth-aware version that also returns alias tokens for soft matching."""
queries, core_company, core_tokens = build_search_queries(company_name, company_website, role)
# Derive a short root name by trimming common wealth suffixes
trailing = [
"Wealth Management", "Financial Advisors", "Wealth", "Advisors", "Advisory",
"Asset Management", "Investment Management", "Securities", "Financial", "Capital",
"Group", "Holdings", "Partners"
]
root = core_company
for tw in trailing:
if core_company.lower().endswith(tw.lower()):
root = core_company[: -len(tw)].strip()
break
aliases = {core_company, f"{root} Advisors", f"{root} Wealth Management"}
# Add domain-derived aliases
if company_website:
domain = extract_domain_from_website(company_website)
if domain and "." in domain:
dom_root = domain.split(".")[0]
aliases.add(dom_root)
tokenized = []
for a in aliases:
tokenized.extend(a.replace('-', ' ').replace('.', ' ').replace('&',' ').split())
alias_tokens = sorted({t.lower() for t in tokenized if t})
return queries, core_company, alias_tokens
def ai_finder_agent(all_candidates, company_name, role, core_tokens):
"""AI Finder Agent: One LLM call to choose best among pre-ranked top candidates.
Uses response_format=json_object and validates with Pydantic. Falls back to quick_score.
"""
if not all_candidates:
return None, 0
candidate_info = [{
"index": i,
"title": c.get('title', ''),
"link": c.get('link', ''),
"snippet": c.get('snippet', '')
} for i, c in enumerate(all_candidates)]
prompt = f"""You are an expert LinkedIn profile finder. Select the BEST candidate for a {role} role at {company_name}.
Company: {company_name}
Role: {role}
Expected titles: {', '.join(ROLE_TITLES.get(role, []))}
Candidates ({len(candidate_info)} total):
{json.dumps(candidate_info, indent=2)}
Return ONLY JSON with fields:
{{
"selected_index": <0-based number>,
"confidence_score": <0..1>,
"reasoning": "<short explanation>"
}}"""
try:
if not VERBOSE_LOGGING:
print(f"πŸ€– AI Finder Agent analyzing {len(all_candidates)} candidates...")
model = AI_MODEL
messages = [
{"role": "system", "content": "You are an expert LinkedIn profile evaluator. Return strictly valid JSON only."},
{"role": "user", "content": prompt},
]
log_openai_request("Finder Agent", model, messages, {"type": "json_object"})
start_time = time.time()
resp = client.with_options(timeout=30).chat.completions.create(
model=model,
messages=messages,
response_format={"type": "json_object"},
temperature=0.1,
)
response_time = time.time() - start_time
log_openai_response("Finder Agent", resp, response_time)
parsed = FinderResponse.model_validate_json(resp.choices[0].message.content or "{}")
idx = int(parsed.selected_index)
conf = float(parsed.confidence_score)
if 0 <= idx < len(all_candidates):
if VERBOSE_LOGGING:
print(f"βœ… AI Finder Agent selected candidate {idx} with confidence {conf:.2f}")
return all_candidates[idx], conf
else:
print("⚠️ Invalid index from AI, falling back to best quick_score candidate")
except Exception as e:
print(f"❌ AI Finder Agent error, falling back to rule-based: {e}")
# Fallback: pick highest quick_score
return _fallback_best_candidate(all_candidates, core_tokens, role)
def _fallback_best_candidate(all_candidates, core_tokens, role):
"""Fallback function to select best candidate using quick_score"""
if not all_candidates:
return None, 0.0
# Score all candidates and pick the best one
scored_candidates = []
for candidate in all_candidates:
score = quick_score(candidate, core_tokens, role)
scored_candidates.append((candidate, score))
# Sort by score (highest first) and return the best
scored_candidates.sort(key=lambda x: x[1], reverse=True)
best_candidate, best_score = scored_candidates[0]
if VERBOSE_LOGGING:
print(f"βœ… Fallback selected candidate with score {best_score:.2f}")
return best_candidate, best_score
def _canonical_li(url: str) -> str:
"""Normalize locale subdomains to www."""
return re.sub(r"^https?://[a-z]{2}\.linkedin\.com/in/", "https://www.linkedin.com/in/", url.rstrip("/"))
def _company_match(text: str, tokens: list[str], domain: str | None) -> bool:
"""More lenient company matching with fallback logic.
First tries strict employment patterns, then falls back to basic token presence.
Only rejects if company appears ONLY in social context (interests/follows).
"""
t = text.lower()
if domain and domain.lower() in t:
return True
toks = [tok.lower() for tok in tokens if tok and len(tok) > 2] # Skip very short tokens
if not toks:
return False
neg_social_words = ["interest", "interests", "follows", "following", "follower", "likes", "liked"]
has_neg = any(w in t for w in neg_social_words)
seps = [" - ", " β€” ", " – ", " β€’ "]
# 1. Try strict employment cues first
for tok in toks:
if f"at {tok}" in t or f"@ {tok}" in t:
return True
for sep in seps:
if (sep + tok) in t or (tok + sep) in t:
return True
# 2. Fallback: Check if any company token appears in title/snippet
# (LinkedIn search results should naturally filter for relevant matches)
has_company_token = any(tok in t for tok in toks)
# 3. Only reject if company appears ONLY in social context
if has_company_token and has_neg:
# Check if company appears outside social context
for tok in toks:
# Look for company token NOT near social words
tok_positions = []
start = 0
while True:
pos = t.find(tok, start)
if pos == -1:
break
tok_positions.append(pos)
start = pos + 1
for pos in tok_positions:
# Check 50 chars around token for social words
context_start = max(0, pos - 50)
context_end = min(len(t), pos + len(tok) + 50)
context = t[context_start:context_end]
if not any(w in context for w in neg_social_words):
return True # Found company token outside social context
return False # All company mentions are in social context
# 4. If we found company tokens and no negative social context, accept
return has_company_token
def fast_employment_hit(candidate_text: str, company_aliases: list[str]) -> bool:
t = candidate_text.lower()
for a in company_aliases:
a = a.lower()
# common "employment" patterns in LI headlines/snippets
if f" at {a}" in t or f"{a} Β·" in t or f"{a} β€”" in t or f"{a} –" in t:
return True
return False
def find_candidates(company_name: str, company_website: str, role: str) -> list[dict]:
"""Fetch LinkedIn candidates quickly via SerpAPI (parallel), dedupe, and lightly rank."""
api_key = os.getenv("SERPAPI_API_KEY")
if not api_key:
print("❌ SERPAPI_API_KEY missing")
return []
# Wealth-aware queries + alias tokens for soft company matching
queries, core_company, alias_tokens = build_search_queries_wealth(company_name, company_website, role)
queries = queries[:MAX_QUERIES]
print(f"πŸ” Searching with {len(queries)} queries for '{core_company}'...")
session = requests.Session()
all_candidates: list[dict] = []
for i, query in enumerate(queries, 1):
if VERBOSE_LOGGING:
print(f"Query {i}: {query}")
else:
print(f"πŸ” Query {i}/{len(queries)}")
params = {
"engine": "google",
"q": query,
"api_key": api_key,
"num": SERP_NUM,
"gl": "us",
"hl": "en"
}
try:
r = session.get("https://serpapi.com/search", params=params, timeout=SERP_TIMEOUT)
data = r.json()
if r.status_code != 200 or "error" in data:
print(f"❌ SerpAPI error: status={r.status_code}, error={data.get('error')}")
time.sleep(0.7 + random.random()*0.6)
continue
organic = data.get("organic_results", [])
for res in organic:
link = res.get("link", "")
if "linkedin.com/in" not in link:
continue
# capture locale penalty BEFORE canonicalizing
is_locale = bool(re.match(r"^https?://[a-z]{2}\.linkedin\.com/in/", link))
canon = _canonical_li(link)
cand = {
"title": res.get("title", ""),
"link": canon,
"snippet": res.get("snippet", ""),
"_subdomain_penalty": 0.25 if is_locale else 0.0,
}
all_candidates.append(cand)
except Exception as e:
print(f"❌ SerpAPI request failed: {e}")
time.sleep(0.7 + random.random()*0.6)
continue
# small polite delay
time.sleep(0.3 + random.random()*0.2) # Reduced delay for HF Spaces
if not all_candidates:
print("❌ No LinkedIn candidates found across queries")
return []
# Canonicalize + de-dup
dedup = []
seen = set()
for c in all_candidates:
c["link"] = _canonical_li(c["link"])
if c["link"] in seen:
continue
seen.add(c["link"])
dedup.append(c)
print(f"πŸ“Š Found {len(dedup)} unique candidates")
# Quick score and rank
def quick_score_with_wealth(c):
text = f"{c.get('title','')} {c.get('snippet','')}".lower()
# role title match
role_titles = ROLE_TITLES.get(role, [])
title_score = 1 if any(t.lower() in text for t in role_titles) else 0
# company alias token hits
company_score = sum(1 for tok in alias_tokens if tok in text)
# prefer non-locale linkedin domain (weak US bias)
us_bias = 0 if re.search(r"\b(ph|ar|it|es)\.linkedin\.com\b", c["link"]) else 1
# wealth-domain weak positive
wealth_bias = 0.05 if any(m in text for m in WEALTH_MARKERS) else 0.0
return 2 * title_score + company_score + us_bias + wealth_bias
uniq = [c for c in dedup if c.get('title') and c.get('snippet')]
uniq.sort(key=quick_score_with_wealth, reverse=True)
print(f"βœ… Ranked {len(uniq)} candidates")
return uniq
def ai_verifier_agent(company_name, company_website, role, candidate):
"""LLM verifier using Pydantic + strict threshold (β‰₯0.75). Early reject if no company signal."""
if not candidate:
return False
core_company = normalize_company_name(company_name)
core_tokens = [t for t in re.split(r"[&\-\.\s]+", core_company.lower()) if t]
domain = extract_domain_from_website(company_website)
candidate_text = f"{candidate.get('title','')} {candidate.get('snippet','')}"
# Debug: Show what we're matching against (calmed for HF Spaces)
if VERBOSE_LOGGING:
print(f"πŸ” Checking candidate: {candidate.get('title', '')[:60]}...")
print(f" Company tokens: {core_tokens}")
print(f" Domain: {domain}")
print(f" Text: {candidate_text[:100]}...")
if not _company_match(candidate_text, core_tokens, domain):
if VERBOSE_LOGGING:
print("🚨 Early rejection: No company tokens/domain in candidate text")
return False
else:
if VERBOSE_LOGGING:
print("βœ… Passed company match filter")
candidate_data = {
"title": candidate.get('title', ''),
"link": candidate.get('link', ''),
"snippet": candidate.get('snippet', '')
}
prompt = f"""Verify if this LinkedIn candidate is a legitimate match for a {role} role at {company_name}.
Use only the provided title/link/snippet. Return JSON with: verified(bool), confidence(0..1), reasoning(str), red_flags(list), strengths(list).
Confidence threshold: verify only if confidence β‰₯ 0.75.
STRICT RULES:
- REJECT if the company appears only under social signals like 'Interests', 'Follows', 'Following', 'Follower', or 'Liked'.
- Prefer explicit employment cues such as 'Title at {company_name}' in the headline, or 'at {company_name}' in the snippet.
- Past-only mentions (e.g., 'formerly at {company_name}') should be treated as non-current unless clearly marked 'Present'.
Data:
{json.dumps(candidate_data, indent=2)}"""
try:
if not VERBOSE_LOGGING:
print("πŸ” AI Verifier Agent analyzing candidate...")
model = AI_MODEL
messages = [
{"role": "system", "content": "You are an expert LinkedIn profile verifier. Return valid JSON only."},
{"role": "user", "content": prompt},
]
log_openai_request("Verifier Agent", model, messages, {"type": "json_object"})
start_time = time.time()
resp = client.with_options(timeout=30).chat.completions.create(
model=model,
messages=messages,
response_format={"type": "json_object"},
temperature=0.1,
)
response_time = time.time() - start_time
log_openai_response("Verifier Agent", resp, response_time)
parsed = VerifierResponse.model_validate_json(resp.choices[0].message.content or "{}")
verified = bool(parsed.verified) and float(parsed.confidence) >= 0.75
if VERBOSE_LOGGING:
print(f"Verifier: verified={verified} conf={float(parsed.confidence):.2f}")
return verified
except Exception as e:
print(f"❌ AI Verifier Agent error: {e}")
return False
def run_pipeline(company_name, company_website, role, top_k: int = 5):
"""Main pipeline: return Top-k candidates with verification flags.
Output format: list of rows [title, link, snippet, verified]
"""
if not company_name.strip():
return []
# 1) Fetch candidates (sorted best-first) and cap to top_k
candidates = find_candidates(company_name, company_website, role)[:top_k]
if not candidates:
print("❌ No candidates found")
return []
# 2) Build simple company aliases for fast employment-style matching
core = normalize_company_name(company_name)
domain = extract_domain_from_website(company_website)
trailing = [
"Wealth Management", "Financial Advisors", "Wealth", "Advisors", "Advisory",
"Asset Management", "Investment Management", "Securities", "Financial", "Capital",
"Group", "Holdings", "Partners"
]
root = core
for tw in trailing:
if core.lower().endswith(tw.lower()):
root = core[: -len(tw)].strip()
break
aliases = {a for a in {
core,
f"{root} Advisors",
f"{root} Wealth Management",
f"{root} Financial"
} if a and a.strip()}
def fast_employment_hit(text: str) -> bool:
t = text.lower()
for a in aliases:
a = a.lower()
# common "employment" patterns in LI headlines/snippets
if f" at {a}" in t or f"{a} Β·" in t or f"{a} β€”" in t or f"{a} –" in t:
return True
return False
# 3) Fast pass + collect items that still need LLM verify
fast_rows, to_verify = [], []
for c in candidates:
txt = f"{c.get('title','')} {c.get('snippet','')}"
if fast_employment_hit(txt):
fast_rows.append([c.get('title',''), c.get('link',''), c.get('snippet',''), True])
else:
to_verify.append(c)
print(f"⚑ Fast-pass verified: {len(fast_rows)} candidates")
print(f"πŸ€– Sending {len(to_verify)} candidates to AI verification...")
# 4) Parallel AI verification for remaining candidates
if to_verify:
start_time = time.time()
with ThreadPoolExecutor(max_workers=3) as executor: # Reduced workers for HF Spaces
# Submit all verification tasks in parallel
future_to_candidate = {
executor.submit(ai_verifier_agent, company_name, company_website, role, c): c
for c in to_verify
}
# Collect results as they complete
for future in as_completed(future_to_candidate):
candidate = future_to_candidate[future]
try:
verified = future.result()
fast_rows.append([candidate.get('title',''), candidate.get('link',''), candidate.get('snippet',''), bool(verified)])
except Exception as exc:
print(f"❌ Verification failed for candidate: {exc}")
fast_rows.append([candidate.get('title',''), candidate.get('link',''), candidate.get('snippet',''), False])
verification_time = time.time() - start_time
if not VERBOSE_LOGGING:
print(f"⚑ AI verification completed in {verification_time:.2f}s")
# 5) Final results
rows = fast_rows[:top_k]
print(f"\nπŸ“‹ Final Top-{len(rows)} Results:")
for i, (title, link, snippet, verified) in enumerate(rows, 1):
status = "βœ… Verified" if verified else "❌ Not verified"
print(f"{i}. {title[:50]}... | {status}")
return rows
def run_pipeline_with_debug(company_name, company_website, role, top_k: int = 5):
"""Main pipeline with debug output captured for Gradio (returns Markdown + log)."""
import io, contextlib
debug_log = io.StringIO()
with contextlib.redirect_stdout(debug_log):
rows = run_pipeline(company_name, company_website, role, top_k=top_k)
debug_output = debug_log.getvalue()
print(debug_output)
return rows_to_markdown(rows), debug_output
def rows_to_markdown(rows):
if not rows:
return "No results."
header = "| title | link | snippet | verified |\n|---|---|---|:---:|"
lines = []
for title, link, snippet, verified in rows:
t = (title or "").replace("|","\\|")
s = (snippet or "").replace("\n"," ").replace("|","\\|")
v = "βœ…" if verified else "❌"
lines.append(f"| {t} | [{link}]({link}) | {s} | {v} |")
return "\n".join([header, *lines])
# Create Gradio interface
def create_interface():
with gr.Blocks(title="LinkedIn Profile Finder & Verifier") as interface:
gr.Markdown(f"# LinkedIn Profile Finder & Verifier {APP_VERSION}")
gr.Markdown(f"**Build Time:** {BUILD_TIME}")
gr.Markdown("---")
gr.Markdown("""
**Features:**
- πŸ€– Two AI agents using GPT-4o-mini for cost/speed optimization
- πŸš€ Parallel processing for faster verification
- πŸ“Š Pydantic structured outputs for reliable parsing
- πŸ” SerpAPI integration with comprehensive logging
- βœ… Advanced candidate verification with confidence scoring
Find and verify LinkedIn profiles for specific roles at companies using AI-powered search and verification.
""")
with gr.Row():
with gr.Column(scale=1):
company_name = gr.Textbox(
label="Company Name",
placeholder="e.g., Ameriprise, Goldman Sachs",
value=""
)
company_website = gr.Textbox(
label="Company Website (Optional)",
placeholder="e.g., https://ameriprise.com",
value=""
)
role = gr.Dropdown(
choices=["engineering", "product", "sales", "finance", "legal"],
label="Role",
value="finance"
)
run_btn = gr.Button("πŸ” Find Profiles", variant="primary")
# === OUTPUT TABLE ===
with gr.Column(scale=2):
results_md = gr.Markdown(label="Top 5 Candidates (clickable links)")
# Debug output section
gr.Markdown("## Debug Output")
debug_output = gr.Textbox(
label="Search & AI Debug Log",
placeholder="Debug information will appear here when you run a search...",
lines=10, # Reduced for HF Spaces
interactive=False
)
# Wire the button to the pipeline
run_btn.click(
fn=run_pipeline_with_debug,
inputs=[company_name, company_website, role],
outputs=[results_md, debug_output]
)
return interface
if __name__ == "__main__":
# Print version and startup info (calmed for HF Spaces)
print("=" * 50)
print(f"πŸš€ LinkedIn Profile Finder & Verifier {APP_VERSION}")
print(f"πŸ“… Build Time: {BUILD_TIME}")
if VERBOSE_LOGGING:
print(f"πŸ”§ Debug Mode: ON (comprehensive logging enabled)")
else:
print(f"πŸ”§ Quiet Mode: ON (minimal logging for HF Spaces)")
print("=" * 50)
# Check for API keys
if not os.getenv('SERPAPI_API_KEY'):
print("⚠️ Warning: SERPAPI_API_KEY not found in environment variables")
if not os.getenv('OPENAI_API_KEY'):
print("⚠️ Warning: OPENAI_API_KEY not found in environment variables")
interface = create_interface()
# HF Spaces compatible launch with dynamic port handling
port = int(os.getenv("PORT", 7860)) # HF Spaces injects PORT
interface.launch(
server_name="0.0.0.0", # Required for HF Spaces
server_port=port, # Dynamic port from environment
show_error=True,
share=False, # No sharing needed on HF Spaces
inbrowser=False # Don't try to open browser
)