Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |
| ) | |