1+ '''
2+ Agent responsible for analyzing the alignment between resume experience and job description using an LLM.
3+ '''
4+
5+ import logging
6+ from typing import Dict , List , Optional
7+ from datetime import datetime
8+ import pytz
9+
10+ from src .core .agents .utils .state import AgentState
11+ from src .core .domain .resume_match import ExperienceAlignment
12+ from src .core .domain .resume import Experience
13+ from src .core .domain .job_description import JobDescription
14+ from src .infrastructure .components import create_llm_extractor
15+ from langsmith import traceable
16+ from src .core .ports .secondary .llm_extractor import LLMExtractor
17+
18+ logger = logging .getLogger ("core.agents.experience_analyzer" )
19+
20+ @traceable (run_type = "parser" )
21+ async def calculate_years_experience (experiences : List [Experience ]) -> float :
22+ '''
23+ Calculates the total years of professional experience from a list of experiences.
24+ Handles overlapping time periods by taking the union of intervals.
25+
26+ :param experiences: List of Experience objects from the resume.
27+ :type experiences: List[Experience]
28+ :return: Total years of experience, accounting for overlaps.
29+ :rtype: float
30+ '''
31+ if not experiences :
32+ return 0.0
33+
34+ intervals = []
35+ for exp in experiences :
36+ if exp .start_date :
37+ # Use current date for ongoing jobs
38+ end_date = exp .end_date if exp .end_date else datetime .now (pytz .UTC )
39+ # Ensure start_date is not in the future and end_date is after start_date
40+ if exp .start_date < end_date :
41+ intervals .append ((exp .start_date .timestamp (), end_date .timestamp ()))
42+
43+ if not intervals :
44+ return 0.0
45+
46+ # Sort intervals by start time
47+ intervals .sort (key = lambda x : x [0 ])
48+
49+ merged = []
50+ if intervals :
51+ current_start , current_end = intervals [0 ]
52+ for next_start , next_end in intervals [1 :]:
53+ if next_start < current_end : # Overlap
54+ current_end = max (current_end , next_end )
55+ else : # No overlap
56+ merged .append ((current_start , current_end ))
57+ current_start , current_end = next_start , next_end
58+ merged .append ((current_start , current_end ))
59+
60+ total_duration_seconds = sum (end - start for start , end in merged )
61+ total_years = total_duration_seconds / (365.25 * 24 * 60 * 60 ) # Account for leap years
62+
63+ return round (total_years , 2 )
64+
65+ @traceable (run_type = "parser" )
66+ async def format_experiences_for_prompt (experiences : List [Experience ]) -> str :
67+ '''Formats the experience list into a readable string for the LLM prompt.'''
68+ output = []
69+ for exp in experiences :
70+ start_str = exp .start_date .strftime ('%Y-%m' ) if exp .start_date else "N/A"
71+ end_str = exp .end_date .strftime ('%Y-%m' ) if exp .end_date else "Present"
72+ desc_str = "\n " .join ([f" - { d } " for d in exp .description ])
73+ ach_str = "\n " .join ([f" - Achievement: { a } " for a in exp .achievements ])
74+ output .append (
75+ f"Title: { exp .title } \n "
76+ f"Company: { exp .company } \n "
77+ f"Dates: { start_str } to { end_str } \n "
78+ f"Description:\n { desc_str } \n "
79+ f"Achievements:\n { ach_str } \n "
80+ f"---"
81+ )
82+ return "\n " .join (output )
83+
84+ @traceable (run_type = "parser" )
85+ async def format_job_details_for_prompt (job : JobDescription ) -> Dict [str , str ]:
86+ '''Formats relevant job details into strings for the LLM prompt.'''
87+ tech_stack_str = "\n " .join ([f"- { ts .tech_description } ({ ts .priority } )" for ts in job .tech_stack ])
88+ requirements_str = "\n " .join ([f"- { req .requirement_description } ({ req .requirement_type } )" for req in job .requirements ])
89+ return {
90+ "job_title" : job .title ,
91+ "job_location" : job .location ,
92+ "job_description_text" : job .description ,
93+ "job_tech_stack" : tech_stack_str if tech_stack_str else "Not specified" ,
94+ "job_requirements" : requirements_str if requirements_str else "Not specified"
95+ }
96+
97+ @traceable (run_type = "parser" )
98+ async def analyze_experience_node (
99+ state : AgentState ,
100+ extractor : Optional [LLMExtractor ] = None
101+ ) -> Dict [str , Optional [ExperienceAlignment ]]:
102+ '''
103+ Analyzes the resume's experience section against the job description
104+ using an LLM to determine alignment scores.
105+
106+ :param state: The current state of the LangGraph execution.
107+ :type state: AgentState
108+ :param extractor: The LLM extractor to use for structured output generation, created if None
109+ :type extractor: LLMExtractor, optional
110+ :return: A dictionary containing the updated ExperienceAlignment or None if analysis fails.
111+ :rtype: Dict[str, Optional[ExperienceAlignment]]
112+ '''
113+ logger .info ("--- Analyzing Experience Alignment (LLM) ---" )
114+ resume = state ['resume' ]
115+ job_description = state ['job_description' ]
116+
117+ # Use provided extractor or create one
118+ if extractor is None :
119+ extractor = create_llm_extractor ()
120+
121+ if not resume .experiences :
122+ logger .warning ("No experience found in resume. Skipping experience analysis." )
123+ return {"experience_alignment" : None }
124+
125+ # 1. Prepare data for the prompt
126+ total_years = await calculate_years_experience (resume .experiences )
127+ experiences_text = await format_experiences_for_prompt (resume .experiences )
128+
129+ try :
130+ # Call the LLM and parse the response
131+ alignment = await extractor .generate_structured_output (
132+ template_path = "prompts/agents/experience_analyzer.j2" ,
133+ template_vars = {"resume_experiences" : experiences_text ,
134+ "job_description" : job_description ,
135+ "total_years_experience" : total_years },
136+ output_model = ExperienceAlignment
137+ )
138+
139+ # Validate years_overlap for consistency
140+ if abs (float (alignment .years_overlap .score ) - total_years ) > 0.01 :
141+ logger .warning (f"LLM years_overlap ({ alignment .years_overlap .score } ) doesn't match calculated { total_years } . Using calculated value." )
142+ alignment .years_overlap .score = total_years
143+
144+ logger .info (f"EXPERIENCE ANALYZER: Determined experience alignment: { alignment } " )
145+ # Return the alignment results
146+ return {"experience_alignment" : alignment }
147+
148+ except Exception as e :
149+ logger .error (f"Failed to analyze experience alignment: { str (e )} " )
150+ return {"experience_alignment" : None }
0 commit comments