""" Job Assessor — 2-phase pipeline. Phase 1 (INSTANT): Keyword-based scoring for all jobs. - No LLM calls — pure keyword/signal matching against Saiteja's profile - Scores 1-10 based on title, skills, and description signals - Takes <1 second for any number of jobs Phase 2 (LLM, top jobs only): Detailed assessment for top PHASE2_MAX jobs. - Sends to multi-model pool (GLM + Qwen + GPT-OSS + others) - Each model picks up batches from shared queue as it finishes - Fallback: if all models fail, keeps keyword score from Phase 1 """ import re from colorama import Fore, Style, init from .model_pool import ModelPool, PHASE2_THRESHOLD, PHASE2_MAX from .scrapers.base import Job init(autoreset=True) # PM keywords for pre-filtering PM_KEYWORDS = { "product manager", "product owner", "product lead", "product director", "vp product", "head of product", "chief product", "product strategist", "technical product", "growth product", "ai product", "data product", "platform product", "saas product", "product marketing", "program manager", "product operations", "product analyst", "associate product", "senior product", "lead product", "product development", "product management", } BLOCK_KEYWORDS = { "teacher", "faculty", "lecturer", "professor", "tutor", "civil engineer", "mechanical engineer", "electrical engineer", "accountant", "chartered accountant", " ca ", "nurse", "doctor", "chef", "cook", "driver", "security guard", "hardware engineer", "network engineer", "rf engineer", "sap consultant", "sap basis", "sap abap", "erp consultant", "custody product", "care advisor", "specialty care", "speciality care", } # High-match signals for Saiteja's profile HIGH_MATCH_SIGNALS = { # Role signals (title) "title": [ "ai product", "ai pm", "conversational", "edtech", "growth product", "growth pm", "technical product", "technical pm", "platform product", "data product", "saas product", "senior product manager", "lead product", "head of product", "product director", ], # Skill signals (description) "skills": [ "funnel optimization", "funnel", "conversion rate", "a/b test", "conversational ai", "llm", "chatbot", "ocr", "automation pipeline", "crm", "webengage", "whatsapp", "growth hacking", "user acquisition", "retention", "cohort", "edtech", "b2c", "0 to 1", "0→1", "product-led", "viral", "referral", "engagement", ], # Company type signals "company": [ "swiggy", "zomato", "meesho", "razorpay", "cred", "juspay", "groww", "zepto", "blinkit", "nykaa", "mamaearth", "unacademy", "byju", "classplus", "vedantu", "toppr", "upgrad", "scaler", "google", "microsoft", "amazon", "meta", "flipkart", "myntra", "ola", "uber", "paytm", "phonepe", "gpay", "freshworks", "zoho", ], } MEDIUM_MATCH_SIGNALS = { "title": [ "product owner", "associate product", "product manager", "product specialist", "product analyst", ], "skills": [ "product strategy", "roadmap", "agile", "scrum", "user research", "ux research", "data-driven", "kpi", "analytics", "feature prioritization", "prd", "go-to-market", "gtm", "cross-functional", "stakeholder", ], } def _is_relevant(title: str) -> bool: """Use the same PM filter as the scraper for consistency.""" from .scrapers.base import BaseScraper return BaseScraper.is_pm_role(title) def keyword_score(job: Job) -> int: """ Instant 1-10 score based on title, description, and company signals. No LLM required — deterministic, sub-millisecond. """ title = (job.title or "").lower() desc = (job.description or "").lower() comp = (job.company or "").lower() text = title + " " + desc + " " + comp score = 4 # baseline for PM-relevant job # High match — title signals (+3) for sig in HIGH_MATCH_SIGNALS["title"]: if sig in title: score += 3 break # High match — skill signals (+2) skill_hits = sum(1 for sig in HIGH_MATCH_SIGNALS["skills"] if sig in text) score += min(skill_hits * 1, 3) # Company signals (+1) for sig in HIGH_MATCH_SIGNALS["company"]: if sig in comp: score += 1 break # Medium match — title signals (+1) for sig in MEDIUM_MATCH_SIGNALS["title"]: if sig in title: score += 1 break # Penalise if description is empty (less info) if not job.description: score -= 1 # Clamp to 1-10 return max(1, min(10, score)) class JobAssessor: BATCH_SIZE = 8 # Jobs per LLM call — all fast models handle 8 at once def __init__(self, model_pool: ModelPool, compact_profile: str): self.pool = model_pool self.compact_profile = compact_profile def assess_all(self, jobs: list[Job]) -> list[dict]: # ── Step 1: Pre-filter by title ────────────────────────────────── relevant = [j for j in jobs if _is_relevant(j.title)] irrelevant = [j for j in jobs if not _is_relevant(j.title)] print(f"\n{Fore.CYAN}Pre-filter: {len(relevant)} PM-relevant | {len(irrelevant)} irrelevant (skipped){Style.RESET_ALL}") skipped_results = [ { **j.to_dict(), "relevance_score": 1, "skills_match_percentage": 0, "experience_match": "Not Relevant", "matching_skills": "", "missing_skills": "", "key_strengths": "", "recommendation": "Not a PM role — filtered.", "ats_keywords": "", "application_priority": "Low", "_raw_assessment": {}, "_assessed_by": "pre-filter", } for j in irrelevant ] if not relevant: return skipped_results # ── Step 2: Phase 1 — instant keyword scoring ───────────────────── print(f"{Fore.CYAN}Phase 1: Keyword scoring {len(relevant)} jobs (instant)...{Style.RESET_ALL}") for job in relevant: job._keyword_score = keyword_score(job) kw_high = sum(1 for j in relevant if j._keyword_score >= 7) kw_med = sum(1 for j in relevant if 5 <= j._keyword_score < 7) print(f"{Fore.GREEN}✓ Keyword scoring done → High(7+): {kw_high} Medium(5-6): {kw_med}{Style.RESET_ALL}") # ── Step 3: Phase 2 — LLM detailed assessment of ALL relevant jobs ── # Process ALL PM jobs (no cap) — fast models handle batches quickly top_jobs = sorted(relevant, key=lambda x: x._keyword_score, reverse=True) print(f"\n{Fore.YELLOW}Phase 2: LLM detailed assessment of ALL {len(top_jobs)} PM jobs...{Style.RESET_ALL}") llm_results: dict[str, dict] = {} # url → assessment dict if top_jobs: assessed = self.pool.process_all( jobs=top_jobs, compact_profile=self.compact_profile, batch_size=self.BATCH_SIZE, ) for r in assessed: url = r.get("url", "") if url: llm_results[url] = r # ── Step 4: Merge — LLM result if available, else keyword score ── final = [] for job in relevant: url = job.url or "" if url in llm_results: # Use LLM result (already a full dict) final.append(llm_results[url]) else: # Use keyword score score = job._keyword_score final.append({ **job.to_dict(), "relevance_score": score, "skills_match_percentage": min(score * 10, 100), "experience_match": "Good fit" if score >= 7 else "Unknown", "matching_skills": "", "missing_skills": "", "key_strengths": "", "recommendation": f"Keyword score: {score}/10. LLM assessment not run.", "ats_keywords": "", "application_priority": "High" if score >= 8 else ("Medium" if score >= 6 else "Low"), "_raw_assessment": {"score": score}, "_assessed_by": "keyword", }) all_results = final + skipped_results all_results.sort(key=lambda x: x.get("relevance_score", 0), reverse=True) return all_results