| """ |
| LLM integration module for analyzing job descriptions and tailoring CV/cover letter. |
| Supports OpenAI, Grok, Groq, and local Ollama via the OpenAI-compatible API format. |
| """ |
|
|
| import os |
| from openai import OpenAI |
| from typing import Dict, List, Tuple, Any, Optional |
| import json |
| import re |
| import requests |
| from bs4 import BeautifulSoup |
| import time |
|
|
|
|
| class OpenAIIntegration: |
| """Class for handling LLM API interactions.""" |
| |
| def __init__(self, api_key: Optional[str] = None): |
| """Initialize LLM integration. |
| |
| Args: |
| api_key: API key for the configured provider. Optional for local Ollama. |
| """ |
| self.provider = (os.environ.get("LLM_PROVIDER", "ollama") or "ollama").lower() |
|
|
| if self.provider == "openai": |
| self.api_key = api_key or os.environ.get("OPENAI_API_KEY") |
| self.base_url = os.environ.get("OPENAI_BASE_URL") |
| self.model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") |
| if self.api_key: |
| if self.base_url: |
| self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) |
| else: |
| self.client = OpenAI(api_key=self.api_key) |
| else: |
| self.client = None |
| elif self.provider == "grok": |
| |
| self.api_key = api_key or os.environ.get("GROK_API_KEY") |
| self.base_url = os.environ.get("GROK_BASE_URL", "https://api.x.ai/v1") |
| self.model = os.environ.get("GROK_MODEL", "grok-2") |
| if self.api_key: |
| self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) |
| else: |
| self.client = None |
| elif self.provider == "groq": |
| |
| self.api_key = api_key or os.environ.get("GROQ_API_KEY") |
| self.base_url = os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1") |
| self.model = os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile") |
| if self.api_key: |
| self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) |
| else: |
| self.client = None |
| else: |
| |
| self.api_key = api_key or os.environ.get("OLLAMA_API_KEY", "ollama") |
| self.base_url = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434/v1") |
| self.model = os.environ.get("OLLAMA_MODEL", "llama3.1:8b") |
| self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) |
| |
| def is_api_key_set(self) -> bool: |
| """Check if API client is available. |
| |
| Returns: |
| Boolean indicating if API client is ready |
| """ |
| if self.provider in ("openai", "grok", "groq"): |
| return bool(self.api_key) and bool(self.client) |
| return bool(self.client) |
| |
| def set_api_key(self, api_key: str) -> None: |
| """Set API key for the current provider. |
| |
| Args: |
| api_key: Provider API key |
| """ |
| self.api_key = api_key |
| if self.base_url: |
| self.client = OpenAI(api_key=api_key, base_url=self.base_url) |
| else: |
| self.client = OpenAI(api_key=api_key) |
|
|
| def _build_model_candidates(self) -> List[str]: |
| """Build an ordered model candidate list for provider fallback.""" |
| candidates: List[str] = [self.model] |
|
|
| |
| extra = os.environ.get("LLM_FALLBACK_MODELS", "") |
| if extra: |
| candidates.extend([m.strip() for m in extra.split(",") if m.strip()]) |
|
|
| if self.provider == "grok": |
| candidates.extend([ |
| "grok-3-mini", |
| "grok-3", |
| "grok-3-fast", |
| "grok-2-latest", |
| "grok-2", |
| "grok-beta", |
| ]) |
| elif self.provider == "groq": |
| candidates.extend([ |
| "llama-3.3-70b-versatile", |
| "llama-3.1-8b-instant", |
| "mixtral-8x7b-32768", |
| ]) |
| elif self.provider == "openai": |
| candidates.extend([ |
| "gpt-4o-mini", |
| "gpt-4.1-mini", |
| ]) |
|
|
| |
| deduped: List[str] = [] |
| seen = set() |
| for model in candidates: |
| if model and model not in seen: |
| deduped.append(model) |
| seen.add(model) |
| return deduped |
|
|
| def _chat_completion_with_fallback(self, messages: List[Dict[str, str]], temperature: float, max_tokens: int): |
| """Run chat completion with model fallback on model-not-found errors.""" |
| model_candidates = self._build_model_candidates() |
| last_error: Optional[Exception] = None |
|
|
| for model_name in model_candidates: |
| try: |
| response = self.client.chat.completions.create( |
| model=model_name, |
| messages=messages, |
| temperature=temperature, |
| max_tokens=max_tokens, |
| ) |
| |
| self.model = model_name |
| return response |
| except Exception as e: |
| last_error = e |
| error_text = str(e).lower() |
| is_model_error = ( |
| "model not found" in error_text |
| or "invalid model" in error_text |
| or "does not exist" in error_text |
| ) |
| if is_model_error: |
| continue |
| raise |
|
|
| if last_error is not None: |
| raise ValueError( |
| f"All model candidates failed for provider '{self.provider}': {model_candidates}. " |
| f"Last error: {last_error}" |
| ) |
| raise ValueError("No model candidates available for completion.") |
| |
| def extract_job_description_from_url(self, url: str) -> str: |
| """Extract job description from LinkedIn URL. |
| |
| Args: |
| url: LinkedIn job posting URL |
| |
| Returns: |
| Extracted job description text |
| """ |
| if not url.startswith(('http://', 'https://')): |
| raise ValueError("Invalid URL format") |
|
|
| if 'linkedin.com' not in url: |
| raise ValueError("URL must be from LinkedIn") |
|
|
| try: |
| |
| headers = { |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' |
| } |
| |
| |
| response = requests.get(url, headers=headers) |
| response.raise_for_status() |
| |
| |
| soup = BeautifulSoup(response.text, 'html.parser') |
| |
| |
| job_title = "" |
| title_element = soup.find('h1', class_='top-card-layout__title') |
| if title_element: |
| job_title = title_element.get_text(strip=True) |
| |
| |
| company = "" |
| company_element = soup.find('a', class_='topcard__org-name-link') |
| if company_element: |
| company = company_element.get_text(strip=True) |
| |
| |
| description = "" |
| desc_element = soup.find('div', class_='show-more-less-html__markup') |
| if desc_element: |
| description = desc_element.get_text(strip=True) |
| |
| |
| if not description: |
| desc_element = soup.find('div', class_='description__text') |
| if desc_element: |
| description = desc_element.get_text(strip=True) |
| |
| |
| full_description = f"Job Title: {job_title}\nCompany: {company}\n\nJob Description:\n{description}" |
| |
| return full_description |
| |
| except requests.RequestException as e: |
| raise ValueError(f"Error fetching job description: {str(e)}") |
| except Exception as e: |
| raise ValueError(f"Error parsing job description: {str(e)}") |
|
|
| def analyze_job_description(self, job_description_or_url: str, cv_content: str) -> Dict[str, Any]: |
| """Analyze job description and suggest CV modifications. |
| |
| Args: |
| job_description_or_url: Text of the job description or LinkedIn URL |
| cv_content: Current content of the CV |
| |
| Returns: |
| Dictionary with suggested modifications for different CV sections |
| """ |
| if not self.is_api_key_set(): |
| if self.provider == "openai": |
| raise ValueError("OpenAI API key is not set. Please set OPENAI_API_KEY or call set_api_key().") |
| elif self.provider == "grok": |
| raise ValueError("Grok API key is not set. Please set GROK_API_KEY or call set_api_key().") |
| elif self.provider == "groq": |
| raise ValueError("Groq API key is not set. Please set GROQ_API_KEY or call set_api_key().") |
| raise ValueError("LLM client is not initialized. Check local Ollama settings.") |
| |
| |
| if job_description_or_url.startswith(('http://', 'https://')) and 'linkedin.com' in job_description_or_url: |
| try: |
| job_description = self.extract_job_description_from_url(job_description_or_url) |
| except Exception as e: |
| raise ValueError(f"Error extracting job description from URL: {str(e)}") |
| else: |
| job_description = job_description_or_url |
|
|
| |
| prompt = f""" |
| You are an expert CV and resume tailoring assistant. Your task is to analyze a job description |
| and suggest modifications to a CV to better match the job requirements. |
| |
| JOB DESCRIPTION: |
| {job_description} |
| |
| CURRENT CV CONTENT: |
| {cv_content} |
| |
| Please analyze the job description and suggest specific modifications to the following sections of the CV: |
| 1. Profile/Summary: Suggest a tailored professional summary that highlights relevant skills and experience. |
| 2. Skills: Identify key skills from the job description that should be emphasized or added. |
| 3. Experience: Suggest how to reframe or emphasize certain experiences to better match the job requirements. |
| |
| Format your response as a JSON object with the following structure: |
| {{ |
| "profile_summary": "Suggested profile summary text", |
| "skills": ["skill1", "skill2", "skill3"], |
| "experience_highlights": ["point1", "point2", "point3"], |
| "keywords_to_emphasize": ["keyword1", "keyword2", "keyword3"] |
| }} |
| """ |
| |
| try: |
| |
| response = self._chat_completion_with_fallback( |
| messages=[ |
| {"role": "system", "content": "You are an expert CV tailoring assistant that provides structured JSON responses."}, |
| {"role": "user", "content": prompt} |
| ], |
| temperature=0.5, |
| max_tokens=1000, |
| ) |
| |
| |
| result = response.choices[0].message.content |
| |
| try: |
| |
| return json.loads(result) |
| except json.JSONDecodeError: |
| |
| return {"raw_response": result} |
| |
| except Exception as e: |
| return {"error": str(e)} |
| |
| def tailor_cover_letter(self, job_description: str, current_cover_letter: str, cv_content: str) -> str: |
| """Generate a tailored cover letter based on job description and CV. |
| |
| Args: |
| job_description: Text of the job description |
| current_cover_letter: Current content of the cover letter |
| cv_content: Content of the CV for reference |
| |
| Returns: |
| Tailored cover letter text |
| """ |
| if not self.is_api_key_set(): |
| if self.provider == "openai": |
| raise ValueError("OpenAI API key is not set. Please set OPENAI_API_KEY or call set_api_key().") |
| elif self.provider == "grok": |
| raise ValueError("Grok API key is not set. Please set GROK_API_KEY or call set_api_key().") |
| elif self.provider == "groq": |
| raise ValueError("Groq API key is not set. Please set GROQ_API_KEY or call set_api_key().") |
| raise ValueError("LLM client is not initialized. Check local Ollama settings.") |
| |
| |
| prompt = f""" |
| You are an expert cover letter writing assistant. Your task is to tailor a cover letter |
| to better match a specific job description, while maintaining the original structure and tone. |
| |
| JOB DESCRIPTION: |
| {job_description} |
| |
| CURRENT COVER LETTER: |
| {current_cover_letter} |
| |
| CV CONTENT (for reference): |
| {cv_content} |
| |
| Please rewrite the body of the cover letter to: |
| 1. Address specific requirements mentioned in the job description |
| 2. Highlight relevant skills and experiences from the CV |
| 3. Demonstrate enthusiasm for the specific role and company |
| 4. Maintain a professional tone similar to the original |
| 5. Keep approximately the same length as the original |
| |
| Return only the tailored body text of the cover letter, without greeting or closing. |
| """ |
| |
| try: |
| |
| response = self._chat_completion_with_fallback( |
| messages=[ |
| {"role": "system", "content": "You are an expert cover letter writing assistant."}, |
| {"role": "user", "content": prompt} |
| ], |
| temperature=0.7, |
| max_tokens=1000, |
| ) |
| |
| |
| result = response.choices[0].message.content |
| return result |
| |
| except Exception as e: |
| return f"Error generating cover letter: {str(e)}" |
|
|