Spaces:
Runtime error
Runtime error
File size: 35,169 Bytes
924d10c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 | """
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
)
|