""" ModelPool — 2-phase parallel AI worker pool. Phase 1 (FAST): Score all jobs quickly → ask only for score (1-10) Output: ~15 tokens per job → very fast Batch size: 10 jobs per call Phase 2 (DETAILED): Full assessment on top-scoring jobs only Output: ~250 tokens per job Batch size: 3 jobs per call 4 model workers run simultaneously, grabbing batches from a shared queue. """ import json import re import time import queue import threading from openai import OpenAI from colorama import Fore, Style QUICK_BATCH_SIZE = 10 # Phase 1 LLM batch (unused — replaced by keyword scoring) DETAILED_BATCH_SIZE = 8 # Phase 2: 8 jobs per LLM call (more = faster, less API calls) PHASE2_THRESHOLD = 5 # Only do detailed for jobs scoring >= this (keyword threshold) PHASE2_MAX = 200 # No meaningful cap — process all jobs class ModelWorker: """A single AI model — acts as an independent worker.""" def __init__(self, name: str, model: str, api_key: str, base_url: str, extra_body: dict = None): self.name = name self.model = model self.extra_body = extra_body or {} # Hard timeout so a hung API call fails fast instead of stalling the pool self.client = OpenAI(base_url=base_url, api_key=api_key, timeout=120.0, max_retries=0) self.jobs_done = 0 self.calls_done = 0 self.errors = 0 def _call(self, messages: list, max_tokens: int, retries: int = 2) -> str: kwargs = dict( model=self.model, messages=messages, temperature=0.1, top_p=0.9, max_tokens=max_tokens, stream=False, ) if self.extra_body: kwargs["extra_body"] = self.extra_body for attempt in range(retries): try: resp = self.client.chat.completions.create(**kwargs) return resp.choices[0].message.content or "" except Exception as e: err_str = str(e) if "504" in err_str or "502" in err_str or "503" in err_str: raise RuntimeError(f"Server error ({self.name}): {err_str[:60]}") if "429" in err_str: # Rate limit — wait and retry wait = 15 * (attempt + 1) time.sleep(wait) if attempt == retries - 1: raise RuntimeError(f"Rate limited ({self.name})") continue if attempt < retries - 1: time.sleep(2 ** attempt) else: raise # ── PHASE 1: Quick score (minimal output) ───────────────────────────────── def quick_score_batch(self, jobs_batch: list[dict], compact_profile: str) -> list[dict]: """ Ask only for a score (1-10) per job. Uses a compact prompt — models respond with arrays like [7,4,9] or ["[7,4,9]"]. """ n = len(jobs_batch) lines = "\n".join( f"JOB {i}: {j.get('title','')} at {j.get('company','')} ({j.get('location','')})" for i, j in enumerate(jobs_batch, 1) ) user_msg = ( f"Candidate: {compact_profile}\n\n" f"{lines}\n\n" f"Return ONLY a JSON array of {n} integers (1-10 score per job). " f"Example: [7,4,9]. No explanation." ) # Generous tokens so model doesn't truncate mid-response max_tokens = max(200, 20 * n) try: text = self._call( [ {"role": "system", "content": "You are a PM recruiter. Return ONLY valid JSON array. No markdown."}, {"role": "user", "content": user_msg}, ], max_tokens=max_tokens, ) scores = self._parse_score_array(text, n) self.calls_done += 1 self.jobs_done += n return [{"job_index": i + 1, "score": scores[i]} for i in range(n)] except Exception: return [{"job_index": i + 1, "score": 5} for i in range(n)] # ── PHASE 2: Detailed assessment (top jobs only) ─────────────────────────── def detailed_batch(self, jobs_batch: list[dict], compact_profile: str) -> list[dict]: """ Full assessment for top-scoring jobs. Asks for matching skills, gaps, keywords, recommendation. """ jobs_text = "" for i, job in enumerate(jobs_batch, 1): desc = (job.get("description") or "")[:300].replace("\n", " ") jobs_text += ( f"\nJOB {i}: {job.get('title','')} at {job.get('company','')} | {job.get('location','')}\n" f"DESC: {desc}\n" ) user = ( f"CANDIDATE: {compact_profile}\n" f"{jobs_text}\n" f"For each job return a JSON array:\n" f'[{{"i":1,"score":<1-10>,"pct":<0-100>,"exp":"Good fit|Under-qualified|Over-qualified",' f'"match":["s1","s2"],"miss":["s1"],"note":"<15 words>",' f'"kw":["k1","k2","k3"],"pri":"High|Medium|Low"}}]' ) # ~250 tokens per job max_tokens = min(350 * len(jobs_batch), 2000) try: text = self._call( [{"role": "system", "content": "PM recruiter. Return ONLY valid JSON array. No markdown."}, {"role": "user", "content": user}], max_tokens=max_tokens, ) result = self._parse_json_list(text, len(jobs_batch)) self.calls_done += 1 self.jobs_done += len(jobs_batch) return result except Exception: return [_neutral_detailed(i + 1) for i in range(len(jobs_batch))] # ── JSON parsers ────────────────────────────────────────────────────────── def _parse_score_array(self, text: str, expected: int) -> list[int]: """ Robust parser for score arrays — handles any format the models return: [7,4,9,3,5] | {"scores":[7,4,9]} | 7\n4\n9 | Score 1: 7, Score 2: 4 ... """ def clamp(n): try: return max(1, min(10, int(float(n)))) except Exception: return 5 def extract_from_list(lst): nums = [] for item in lst: if isinstance(item, (int, float)): nums.append(clamp(item)) elif isinstance(item, str): # Kimi wraps: ["[7,6,8]"] — unwrap inner string try: inner = json.loads(item) if isinstance(inner, list): nums.extend(clamp(x) for x in inner if isinstance(x, (int, float))) elif isinstance(inner, (int, float)): nums.append(clamp(inner)) except Exception: # Try extracting digits from the string found = re.findall(r'\b(10|[1-9])\b', item) nums.extend(clamp(n) for n in found) elif isinstance(item, dict): v = item.get("score") or item.get("s") or item.get("rating") or 5 nums.append(clamp(v)) return nums # Try JSON parse try: result = json.loads(text.strip()) nums = [] if isinstance(result, list): nums = extract_from_list(result) elif isinstance(result, dict): for v in result.values(): if isinstance(v, list): nums = extract_from_list(v) break if not nums: nums = [clamp(v) for v in result.values() if isinstance(v, (int, float))] if nums: while len(nums) < expected: nums.append(5) return nums[:expected] except Exception: pass # Regex fallback: all standalone integers 1-10 all_nums = re.findall(r'\b(10|[1-9])\b', text) if all_nums: nums = [clamp(n) for n in all_nums] while len(nums) < expected: nums.append(5) return nums[:expected] return [5] * expected def _parse_json_list(self, text: str, expected: int) -> list[dict]: """Parse a JSON array from model response.""" # Try direct parse try: result = json.loads(text.strip()) if isinstance(result, list): return result if result else [_neutral_detailed(i + 1) for i in range(expected)] except Exception: pass # Try extracting from code block m = re.search(r"```(?:json)?\s*([\s\S]+?)```", text) if m: try: result = json.loads(m.group(1)) if isinstance(result, list): return result except Exception: pass # Extract first [...] block with objects idx = text.find("[{") if idx >= 0: depth = 0 for i, ch in enumerate(text[idx:], idx): if ch == "[": depth += 1 elif ch == "]": depth -= 1 if depth == 0: try: result = json.loads(text[idx:i+1]) if isinstance(result, list): return result except Exception: break return [_neutral_detailed(i + 1) for i in range(expected)] # ── Helpers ─────────────────────────────────────────────────────────────────── def _neutral_detailed(idx: int) -> dict: return { "i": idx, "score": 5, "pct": 50, "exp": "Unknown", "match": [], "miss": [], "note": "Assessment unavailable.", "kw": [], "pri": "Medium", } def _merge(job_dict: dict, quick: dict, detail: dict | None) -> dict: score = int(quick.get("score", 5)) if detail: score = int(detail.get("score", score)) return { **job_dict, "relevance_score": score, "skills_match_percentage": int(detail.get("pct", 50)) if detail else 50, "experience_match": detail.get("exp", "Unknown") if detail else "Unknown", "matching_skills": ", ".join(detail.get("match", []) if detail else []), "missing_skills": ", ".join(detail.get("miss", []) if detail else []), "key_strengths": "", "recommendation": detail.get("note", "") if detail else "", "ats_keywords": ", ".join(detail.get("kw", []) if detail else []), "application_priority": detail.get("pri", _score_to_priority(score)) if detail else _score_to_priority(score), "_raw_assessment": detail or quick, "_assessed_by": "", } def _score_to_priority(score: int) -> str: if score >= 8: return "High" if score >= 6: return "Medium" return "Low" # ── ModelPool ───────────────────────────────────────────────────────────────── class ModelPool: def __init__(self, model_configs: list[dict]): self.workers_phase1 = [] # Fast models — Phase 1 quick scoring self.workers_phase2 = [] # All models — Phase 2 detailed for cfg in model_configs: if not cfg.get("api_key"): continue try: w = ModelWorker( name=cfg["name"], model=cfg["model"], api_key=cfg["api_key"], base_url=cfg["base_url"], extra_body=cfg.get("extra_body", {}), ) if cfg.get("phase1", True): self.workers_phase1.append(w) if cfg.get("phase2", True): self.workers_phase2.append(w) except Exception: pass if not self.workers_phase1 and not self.workers_phase2: raise ValueError("No valid model configs provided.") # If no phase1 workers, fall back to phase2 if not self.workers_phase1: self.workers_phase1 = self.workers_phase2 print(f"{Fore.CYAN}Phase 1 workers: {', '.join(w.name for w in self.workers_phase1)}{Style.RESET_ALL}") print(f"{Fore.CYAN}Phase 2 workers: {', '.join(w.name for w in self.workers_phase2)}{Style.RESET_ALL}") def process_all( self, jobs: list, compact_profile: str, batch_size: int = DETAILED_BATCH_SIZE, progress_callback=None, ) -> list[dict]: """ Detailed LLM assessment of the provided jobs. Called by JobAssessor after keyword pre-scoring — only top jobs are passed. Each worker grabs batches from shared queue the moment it finishes. """ job_dicts = [j.to_dict() if hasattr(j, "to_dict") else j for j in jobs] total = len(job_dicts) if total == 0: return [] p2_names = ', '.join(w.name for w in self.workers_phase2) print(f"\n{Fore.YELLOW}LLM assessing {total} jobs in batches of {batch_size} [{p2_names}]...{Style.RESET_ALL}") print(f"{Fore.CYAN}Each worker grabs the next batch the moment it finishes.{Style.RESET_ALL}\n") raw_details = self._run_pool( job_dicts, compact_profile, batch_fn=lambda w, batch, prof: w.detailed_batch(batch, prof), batch_size=batch_size, phase_name="LLM Detail", workers=self.workers_phase2, ) if progress_callback: progress_callback(100, 100) # Map flat detail list back to jobs by position # Each batch of N jobs produces N detail dicts with "i" = 1..N results = [] detail_idx = 0 for batch_start in range(0, total, batch_size): batch_jobs = job_dicts[batch_start: batch_start + batch_size] batch_size_actual = len(batch_jobs) for pos, job in enumerate(batch_jobs): # Find detail for this job (i = pos+1 within the batch) detail = next( (d for d in raw_details if isinstance(d, dict) and d.get("i") == pos + 1 and d not in results), # avoid reuse None, ) quick = {"job_index": batch_start + pos + 1, "score": job.get("_keyword_score", 5)} results.append(_merge(job, quick, detail)) # Print worker stats print(f"\n{Fore.CYAN}Worker stats:{Style.RESET_ALL}") all_workers = list({w.name: w for w in self.workers_phase1 + self.workers_phase2}.values()) for w in all_workers: print(f" {w.name}: {w.calls_done} calls, {w.jobs_done} jobs, {w.errors} errors") results.sort(key=lambda x: x.get("relevance_score", 0), reverse=True) return results def _run_pool( self, job_dicts: list[dict], compact_profile: str, batch_fn, batch_size: int, phase_name: str, workers: list = None, ) -> list: if workers is None: workers = self.workers_phase1 """Generic worker-pool runner. Returns flat list of assessment dicts.""" batches = [job_dicts[i:i + batch_size] for i in range(0, len(job_dicts), batch_size)] total_batches = len(batches) bq: queue.Queue = queue.Queue() for i, b in enumerate(batches): bq.put((i, b, 0)) # (idx, batch, retry_count) results: dict[int, list] = {} lock = threading.Lock() done_ctr = [0] def worker_fn(worker: ModelWorker): while True: try: batch_idx, batch, retries = bq.get(timeout=3) except queue.Empty: break t0 = time.time() try: batch_result = batch_fn(worker, batch, compact_profile) elapsed = time.time() - t0 with lock: results[batch_idx] = batch_result done_ctr[0] += 1 done = done_ctr[0] pct = done / total_batches * 100 print( f" [{phase_name}] {worker.name} · batch {batch_idx+1}/{total_batches} " f"({len(batch)} jobs, {elapsed:.0f}s) — {pct:.0f}% done" ) except RuntimeError as e: # Server error — skip this model, put batch back for another worker worker.errors += 1 if retries < 1: bq.put((batch_idx, batch, retries + 1)) else: with lock: results[batch_idx] = [_neutral_detailed(i + 1) for i in range(len(batch))] print(f" {Fore.RED}[{worker.name}] server error, re-queuing batch {batch_idx+1}{Style.RESET_ALL}") except Exception as e: worker.errors += 1 if retries < 2: bq.put((batch_idx, batch, retries + 1)) else: with lock: results[batch_idx] = [_neutral_detailed(i + 1) for i in range(len(batch))] print(f" {Fore.YELLOW}[{worker.name}] error batch {batch_idx+1}: {str(e)[:60]}{Style.RESET_ALL}") finally: bq.task_done() threads = [threading.Thread(target=worker_fn, args=(w,), daemon=True) for w in workers] for t in threads: t.start() for t in threads: t.join() # Flatten in order flat = [] for idx in sorted(results.keys()): items = results.get(idx, []) if isinstance(items, list): flat.extend(items) return flat