"""AI service for CV parsing, matching, and generation using GLM-5.2 via Ollama Cloud.""" import json import re from datetime import datetime, date from dateutil.relativedelta import relativedelta from openai import OpenAI from config import get_settings settings = get_settings() # LLM client _client = None def get_client(): global _client if _client is None: _client = OpenAI( api_key=settings.llm_api_key, base_url=settings.llm_base_url, ) return _client def llm_chat(messages, temperature=0.3, max_tokens=4000, response_format=None): """Call the LLM with messages and return the response text.""" client = get_client() kwargs = { "model": settings.llm_model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } if response_format: kwargs["response_format"] = response_format resp = client.chat.completions.create(**kwargs) return resp.choices[0].message.content def extract_json(text): """Extract JSON from LLM response, handling markdown code blocks, truncation, and common LLM JSON errors.""" json_str = text.strip() if json_str.startswith("```"): json_str = re.sub(r'^```(?:json)?\s*', '', json_str) json_str = re.sub(r'\s*```$', '', json_str) # First attempt: parse as-is try: return json.loads(json_str) except json.JSONDecodeError: pass # Second attempt: fix truncated JSON by closing open braces/brackets try: open_b = json_str.count('{') close_b = json_str.count('}') open_arr = json_str.count('[') close_arr = json_str.count(']') if open_b > close_b or open_arr > close_arr: json_str = json_str.rstrip() json_str = re.sub(r'[\s,]*"[^"]*":\s*"?[^",}\]]*$', '', json_str) json_str += '}' * max(0, open_b - json_str.count('}')) json_str += ']' * max(0, open_arr - json_str.count(']')) return json.loads(json_str) except json.JSONDecodeError: pass # Third attempt: fix common LLM JSON errors # - Unescaped newlines inside string values # - Single quotes instead of double quotes # - Trailing commas before closing brackets # - Unescaped quotes inside string values (the "Expecting ',' delimiter" error) try: fixed = json_str # Remove trailing commas fixed = re.sub(r',\s*([}\]])', r'\1', fixed) # Fix unescaped newlines inside strings fixed = fixed.replace('\\\n', '\\n') return json.loads(fixed) except json.JSONDecodeError: pass # Fourth attempt: line-by-line repair — find the error location and try to fix it try: return json.loads(json_str, strict=False) except json.JSONDecodeError as e: # Last resort: try to salvage by removing the problematic section # and parsing what we can lines = json_str.split('\n') if e.lineno and e.lineno <= len(lines): # Remove the problematic line and try again repaired = '\n'.join(lines[:e.lineno-1] + lines[e.lineno:]) try: return json.loads(repaired, strict=False) except json.JSONDecodeError: pass # If all else fails, raise with context error_line = lines[e.lineno-1] if e.lineno and e.lineno <= len(lines) else '?' raise ValueError(f"JSON parse failed at line {e.lineno}: {e.msg}\nProblem line: {error_line[:200]}\nFull response length: {len(text)} chars") # ============================================================ # CV PARSING # ============================================================ PARSE_PROMPT = """You are a CV/resume parser. Analyze the following CV text and extract structured information. Return ONLY a valid JSON object with this exact structure: { "first_name": "", "last_name": "", "email": "", "phone": "", "address": "", "linkedin": "", "github": "", "website": "", "summary": "", "skills": [ { "skill_name": "", "skill_category": "Programming|Database|Cloud|DevOps|Tools|Framework|Language|Soft Skill|Other", "proficiency": "Expert|Advanced|Intermediate|Beginner", "start_date": "YYYY-MM-DD or YYYY-01-01 if only year is known", "end_date": "YYYY-MM-DD or null if still active/present" } ], "experience": [ { "company": "", "position": "", "location": "", "start_date": "YYYY-MM-DD or null", "end_date": "YYYY-MM-DD or null if current", "description": "", "achievements": ["bullet point 1", "bullet point 2"], "skills_used": ["skill1", "skill2"] } ], "education": [ { "institution": "", "degree": "", "field_of_study": "", "start_date": "YYYY-MM-DD or null", "end_date": "YYYY-MM-DD or null", "grade": "", "description": "" } ], "certifications": [ { "name": "", "issuer": "", "issue_date": "YYYY-MM-DD or null", "expiry_date": "YYYY-MM-DD or null", "credential_id": "" } ] } Rules: - Extract dates as accurately as possible. If only a year is mentioned, use YYYY-01-01. - If a skill or job is described as "present" or "current", set end_date to null. - For skills, infer the start_date from when they first appear in experience/education. - Be thorough — extract ALL skills, experience entries, education, and certifications. - If a field is not found, use empty string "" or null as appropriate. - Return ONLY the JSON, no other text. CV Text to parse: --- __CV_TEXT__ ---""" def parse_cv(cv_text: str) -> dict: """Parse a CV's raw text into structured data using AI.""" max_chars = 12000 if len(cv_text) > max_chars: cv_text = cv_text[:max_chars] + "\n[...truncated...]" prompt = PARSE_PROMPT.replace("__CV_TEXT__", cv_text) messages = [ {"role": "system", "content": "You are a precise CV parser that outputs only valid JSON. Make sure all string values are properly escaped (no unescaped quotes, no unescaped newlines)."}, {"role": "user", "content": prompt} ] # Retry with increasing max_tokens in case of truncation for max_tokens in [4000, 6000, 8000]: try: resp = llm_chat(messages, temperature=0.1, max_tokens=max_tokens) return extract_json(resp) except (ValueError, json.JSONDecodeError) as e: if max_tokens == 8000: raise # Try again with more tokens continue # ============================================================ # DYNAMIC EXPERIENCE CALCULATION # ============================================================ def calculate_years_experience(start_date, end_date, reference_date=None): """Calculate years of experience dynamically based on reference date. If end_date is None (still active), use reference_date (or today) as the end. Returns a float representing years of experience. """ if reference_date is None: reference_date = date.today() elif isinstance(reference_date, str): reference_date = datetime.strptime(reference_date, "%Y-%m-%d").date() if isinstance(start_date, str): start_date = datetime.strptime(start_date, "%Y-%m-%d").date() if end_date is None: end_date = reference_date elif isinstance(end_date, str): end_date = datetime.strptime(end_date, "%Y-%m-%d").date() # Cap end_date at reference_date (don't count future time) if end_date > reference_date: end_date = reference_date if start_date > end_date: return 0.0 delta = relativedelta(end_date, start_date) years = delta.years + delta.months / 12.0 return round(years, 1) def calculate_skill_years(skill_start, skill_end, reference_date=None): """Calculate years for a specific skill.""" return calculate_years_experience(skill_start, skill_end, reference_date) # ============================================================ # CV MATCHING # ============================================================ MATCH_PROMPT = """You are a recruitment AI that matches candidates to job requirements. Given a candidate's structured CV data and a list of position requirements, determine how well the candidate matches each position. Return ONLY a valid JSON array: [ { "position_title": "", "match_score": 0-100, "matching_skills": ["skill1", "skill2"], "missing_skills": ["skill3"], "experience_analysis": "Brief analysis of experience relevance", "recommendation": "strong_match|moderate_match|weak_match|no_match", "reasoning": "Detailed explanation" } ] Calculate experience years dynamically as of __REFERENCE_DATE__. Candidate CV Data (JSON): __CANDIDATE_DATA__ Position Requirements (JSON): __REQUIREMENTS__ Return ONLY the JSON array, no other text.""" def match_candidate_to_requirements(candidate_data: dict, requirements: list, reference_date: date = None) -> list: """Match a candidate against a list of position requirements.""" ref_str = (reference_date or date.today()).isoformat() prompt = (MATCH_PROMPT .replace("__REFERENCE_DATE__", ref_str) .replace("__CANDIDATE_DATA__", json.dumps(candidate_data, default=str)) .replace("__REQUIREMENTS__", json.dumps(requirements, default=str))) messages = [ {"role": "system", "content": "You are a precise recruitment matching AI that outputs only valid JSON."}, {"role": "user", "content": prompt} ] resp = llm_chat(messages, temperature=0.2, max_tokens=3000) return extract_json(resp) # ============================================================ # CV GENERATION (regenerate CV aligned to requirement) # ============================================================ GENERATE_PROMPT = """You are an expert CV writer who regenerates CVs to align with specific job requirements. IMPORTANT RULES: 1. DO NOT invent or fabricate skills, experience, or qualifications the candidate does not have. 2. DO reorder and emphasize relevant experience that matches the requirement. 3. DO rephrase descriptions to highlight relevant aspects without lying. 4. DO use dynamic experience calculations based on the generation date: __GENERATION_DATE__ 5. DO format the CV according to the template structure provided. 6. DO ensure all dates and durations are accurate and dynamically calculated. 7. DO NOT change job titles to something the candidate didn't do. You can rephrase but not invent. For each skill, calculate years of experience dynamically: - If a skill started in 2020-01-01 and is still active (end_date=null), and the generation date is __GENERATION_DATE__, the experience is calculated from start to generation date. - If a skill has both start and end dates, calculate the duration between them. Template Structure: __TEMPLATE_STRUCTURE__ Candidate Data (with full CV): __CANDIDATE_DATA__ Target Position Requirement: __REQUIREMENT__ Generate the CV content. Return a JSON object: { "generated_content": "The full formatted CV as HTML or structured text", "generated_data": { "skills_with_years": [ { "skill_name": "", "years": 0.0, "start_date": "", "end_date": "", "calculated_as_of": "__GENERATION_DATE__" } ], "experience_highlights": ["relevant achievement 1", "relevant achievement 2"], "summary_aligned": "A summary paragraph aligned to the requirement" }, "changes_made": "List of what was changed/reordered/emphasized (for transparency)" } Return ONLY the JSON, no other text.""" def generate_aligned_cv( candidate_data: dict, requirement: dict, template_structure: dict, generation_date: date = None ) -> dict: """Generate a CV aligned to a specific requirement.""" gen_date_str = (generation_date or date.today()).isoformat() prompt = (GENERATE_PROMPT .replace("__GENERATION_DATE__", gen_date_str) .replace("__TEMPLATE_STRUCTURE__", json.dumps(template_structure, default=str)) .replace("__CANDIDATE_DATA__", json.dumps(candidate_data, default=str)) .replace("__REQUIREMENT__", json.dumps(requirement, default=str))) messages = [ {"role": "system", "content": "You are an expert CV writer that outputs only valid JSON."}, {"role": "user", "content": prompt} ] resp = llm_chat(messages, temperature=0.4, max_tokens=4000) return extract_json(resp) # ============================================================ # TEMPLATE GENERATION # ============================================================ TEMPLATE_GEN_PROMPT = """You are a CV template designer. Based on the user's description, create a CV template structure. The template structure defines the sections and layout of a CV. Return a JSON object: { "sections": [ { "name": "Section Name", "type": "header|summary|experience|education|skills|certifications|projects|custom", "order": 1, "fields": ["field1", "field2"], "styling": { "show_years": true, "group_by_category": true, "show_proficiency": false, "max_items": null } } ], "styling": "CSS string for the template, dark or light theme" } User description of desired template: __DESCRIPTION__ If the user wants a specific industry or role focus, tailor the template accordingly. Return ONLY the JSON, no other text.""" def generate_template(description: str) -> dict: """Generate a CV template from a text description using AI.""" prompt = TEMPLATE_GEN_PROMPT.replace("__DESCRIPTION__", description) messages = [ {"role": "system", "content": "You are a CV template designer that outputs only valid JSON."}, {"role": "user", "content": prompt} ] # Retry with increasing max_tokens if JSON parsing fails for attempt, max_tok in enumerate([2000, 3000, 4000]): try: resp = llm_chat(messages, temperature=0.5, max_tokens=max_tok) return extract_json(resp) except json.JSONDecodeError as e: if attempt < 2: # Retry with more tokens continue # Last attempt failed — try to salvage partial JSON try: # Attempt to fix truncated JSON by closing braces json_str = resp.strip() if json_str.startswith("```"): json_str = re.sub(r'^```(?:json)?\s*', '', json_str) json_str = re.sub(r'\s*```$', '', json_str) # Count open vs close braces open_b = json_str.count('{') close_b = json_str.count('}') open_arr = json_str.count('[') close_arr = json_str.count(']') # Append missing closers json_str += '}' * (open_b - close_b) json_str += ']' * (open_arr - close_arr) return json.loads(json_str) except: raise ValueError(f"AI returned invalid JSON (truncated at {max_tok} tokens). Please try again with a shorter description.") # ============================================================ # CHAT (general AI interaction) # ============================================================ SYSTEM_PROMPT = """You are an AI assistant for a CV/Candidate management system. You help users with: 1. Analyzing CVs in the database 2. Matching candidates to job requirements 3. Creating and modifying CV templates 4. Generating tailored CVs for specific positions 5. General questions about the candidate database You have access to the system's database. When the user asks about candidates or requirements, use the provided context to answer accurately. Be concise and direct. When suggesting actions, format them clearly.""" def chat(user_message: str, conversation_history: list = None, context: str = "") -> str: """General AI chat with optional context.""" messages = [{"role": "system", "content": SYSTEM_PROMPT}] if context: messages.append({"role": "system", "content": f"Context:\n{context}"}) if conversation_history: for msg in conversation_history[-10:]: # last 10 messages messages.append({"role": msg["role"], "content": msg["content"]}) messages.append({"role": "user", "content": user_message}) return llm_chat(messages, temperature=0.5, max_tokens=2000) # ============================================================ # BATCH FUNCTIONS — extract requirements, match candidates, batch chat # ============================================================ EXTRACT_REQUIREMENTS_PROMPT = """You are a requirements analyst. Analyze the following document and extract the job positions and their requirements. Return ONLY a valid JSON object with this structure: { "batch_name": "Short name for this batch (from the document title or first heading)", "description": "Brief description of what this batch is for (1-2 sentences)", "positions": [ { "job_title": "Position title", "num_positions": 1, "required_skills": ["skill1", "skill2"], "required_years": 5, "required_certs": ["cert1"], "nice_to_have": ["skill3"], "disqualifiers": ["something that would disqualify a candidate"], "description": "Brief description of the role" } ] } Rules: - Extract ALL positions mentioned in the document. - If a field is not specified, use empty string "" or empty array []. - Be precise about required skills — distinguish must-haves from nice-to-haves. - If no positions are found, return an empty positions array. - Return ONLY the JSON, no other text. Document text: --- __DOC_TEXT__ ---""" def extract_requirements(doc_text: str) -> dict: """Extract job requirements from a document using AI.""" max_chars = 15000 if len(doc_text) > max_chars: doc_text = doc_text[:max_chars] + "\n[...truncated...]" prompt = EXTRACT_REQUIREMENTS_PROMPT.replace("__DOC_TEXT__", doc_text) messages = [ {"role": "system", "content": "You are a requirements analyst that outputs only valid JSON. Make sure all string values are properly escaped."}, {"role": "user", "content": prompt} ] for max_tokens in [4000, 6000, 8000]: try: resp = llm_chat(messages, temperature=0.1, max_tokens=max_tokens) return extract_json(resp) except (ValueError, json.JSONDecodeError): if max_tokens == 8000: raise continue MATCH_PROMPT = """You are a CV matching specialist. You are given a job position's requirements and a list of candidates with their skills, experience, education, and certifications. For each candidate, determine: 1. A match score (0-100) based on how well they meet the requirements 2. Whether they are a good fit (score >= 60) 3. A brief reasoning for the match or mismatch 4. If they are a close match (score 50-79), suggest how their CV could be realigned (reworded) to better fit — without fabricating skills they don't have Return ONLY a valid JSON array of matching candidates: [ { "candidate_id": "the UUID", "candidate_name": "First Last", "match_score": 85, "fit": true, "reasoning": "Strong match: has 7 years Python, AWS cert, Kubernetes experience", "realignment_suggestion": "Emphasize microservices architecture experience over generic 'distributed systems' wording; reorder skills to put AWS and Kubernetes first" } ] Only include candidates with match_score >= 40. Sort by match_score descending. Return ONLY the JSON array, no other text. Position Requirements: __POSITION__ Candidates: __CANDIDATES__""" def match_candidates_for_position(position: dict, candidates: list) -> list: """Match candidates against a single position's requirements using AI.""" # Build a compact summary of each candidate for the LLM cand_summaries = [] for c in candidates: skills = ", ".join([s.get("skill_name", "") for s in c.get("skills", [])[:15]]) exp = "; ".join([f"{e.get('position','')} at {e.get('company','')}" for e in c.get("experience", [])[:5]]) certs = ", ".join([cert.get("name", "") for cert in c.get("certifications", [])[:5]]) edu = "; ".join([f"{e.get('degree','')} {e.get('field_of_study','')} at {e.get('institution','')}" for e in c.get("education", [])[:3]]) cand_summaries.append(f"""Candidate ID: {c.get('id', 'N/A')} Name: {c.get('first_name', '')} {c.get('last_name', '')} Skills: {skills} Experience: {exp} Certifications: {certs} Education: {edu} Summary: {c.get('summary', '')[:200]}""") position_json = json.dumps(position, indent=2) candidates_text = "\n\n---\n\n".join(cand_summaries) prompt = MATCH_PROMPT.replace("__POSITION__", position_json).replace("__CANDIDATES__", candidates_text) messages = [ {"role": "system", "content": "You are a CV matching specialist that outputs only valid JSON. Make sure all string values are properly escaped."}, {"role": "user", "content": prompt} ] for max_tokens in [4000, 6000, 8000]: try: resp = llm_chat(messages, temperature=0.1, max_tokens=max_tokens) result = extract_json(resp) if isinstance(result, list): return result elif isinstance(result, dict) and 'candidates' in result: return result['candidates'] return [result] except (ValueError, json.JSONDecodeError): if max_tokens == 8000: raise continue return [] BATCH_CHAT_SYSTEM = """You are an AI assistant helping a user manage a CV batch. You have context about the batch (name, description, positions, and matched candidates). You can help the user with: 1. Discussing which candidates are best for specific positions 2. Suggesting CV realignment — rewording a candidate's experience to better match a position (without fabricating) 3. Answering questions about candidate qualifications 4. Recommending which candidates to approve or remove Be concise and direct. When suggesting changes, be specific about what to change and why. Batch context: __BATCH_CONTEXT__""" def batch_chat(user_message: str, batch_context: str, conversation_history: list = None) -> str: """Context-aware chat for a specific batch.""" system_content = BATCH_CHAT_SYSTEM.replace("__BATCH_CONTEXT__", batch_context) messages = [{"role": "system", "content": system_content}] if conversation_history: for msg in conversation_history[-10:]: messages.append({"role": msg["role"], "content": msg["content"]}) messages.append({"role": "user", "content": user_message}) return llm_chat(messages, temperature=0.5, max_tokens=3000)