Skip to content

Commit 2b3e434

Browse files
Merge pull request #7 from egehanyorulmaz/feat/resume_matching
Implement Resume Matching and Experience Alignment Agent
2 parents c4e5e0b + f8e7803 commit 2b3e434

File tree

29 files changed

+1722
-127
lines changed

29 files changed

+1722
-127
lines changed

.github/workflows/python-tests.yml

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,29 @@ name: Python Tests
22

33
on:
44
push:
5-
branches: [ "main" ]
5+
branches: [ main ]
66
pull_request:
7-
branches: [ "main" ]
7+
branches: [ main ]
88

99
jobs:
1010
test:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ["3.11"]
14+
python-version: [3.11]
1515

1616
steps:
1717
- uses: actions/checkout@v3
18-
1918
- name: Set up Python ${{ matrix.python-version }}
2019
uses: actions/setup-python@v4
2120
with:
2221
python-version: ${{ matrix.python-version }}
23-
2422
- name: Install dependencies
2523
run: |
2624
python -m pip install --upgrade pip
27-
# Install development dependencies
28-
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
29-
# Install package in development mode
30-
pip install -e .
31-
32-
- name: Run tests
25+
pip install -r requirements-dev.txt --no-cache-dir
26+
- name: Test with pytest
3327
run: |
3428
pytest
29+
env:
30+
TESTING: "true"

README.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ CV Optimizer aims to democratize access to high-quality resume optimization usin
1212
- 🎯 Job description matching
1313
- ✍️ Smart content improvement suggestions
1414
- 🌍 Multi-format support (PDF, DOCX, TXT)
15+
- 🔄 Career development planning
1516

1617
## Architecture
17-
Built using hexagonal architecture principles, ensuring:
18+
Built using hexagonal architecture principles with a LangGraph-based workflow, ensuring:
1819
- Clean separation of concerns
1920
- Pluggable AI providers
2021
- Extensible agent system
@@ -26,7 +27,44 @@ This is a side project that I'm currently working on, but I intend to deliver an
2627

2728
1. Clone the repository
2829
2. Create a virtual environment:
29-
3. Install dependencies
30-
4. Create a `.env.` file in the project root
31-
5. Configure your environment variables in `.env`
32-
6. Run tests to verify setup by running 'pytest' in your terminal
30+
```
31+
python -m venv .venv
32+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
33+
```
34+
3. Install dependencies:
35+
```
36+
pip install -r requirements.txt
37+
```
38+
4. Copy the example environment file:
39+
```
40+
cp env.example .env
41+
```
42+
5. Configure your environment variables in `.env`:
43+
- Add your OpenAI API key
44+
- Set up LangSmith for tracing (optional)
45+
6. Run tests to verify setup:
46+
```
47+
pytest
48+
```
49+
50+
## LangGraph Flow
51+
52+
The application uses LangGraph to create a workflow that analyzes resumes and job descriptions:
53+
54+
1. Parse Resume → Parse Job Description → Experience Analyzer → ...
55+
56+
More nodes will be added as development continues.
57+
58+
## Development
59+
60+
To contribute to the project:
61+
62+
1. Set up the environment as described above
63+
2. Install development dependencies if not already included
64+
3. Follow the existing code style patterns
65+
4. Add tests for new functionality
66+
5. Ensure all existing tests pass
67+
68+
## License
69+
70+
[MIT License](LICENSE)

env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
OPENAI_API_KEY=
2+
LANGCHAIN_TRACING_V2=true
3+
LANGSMITH_TRACING=true
4+
LANGSMITH_ENDPOINT="https://api.smith.langchain.com"
5+
LANGSMITH_API_KEY=
6+
LANGSMITH_PROJECT=

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ pythonpath = [
1818
testpaths = [
1919
"tests"
2020
]
21+
asyncio_mode = "strict"
22+
asyncio_default_fixture_loop_scope = "function"

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
langgraph==0.3.34
2+
langsmith==0.3.34
13
openai
24
pathlib
35
pdfplumber>=0.10.3
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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}

src/core/agents/graph_builder.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# In your graph builder file (e.g., src/core/agents/graph_builder.py)
2+
from src.core.agents.experience_analyzer import analyze_experience_node
3+
from src.infrastructure.ai_providers.openai_provider import OpenAIProvider
4+
from src.infrastructure.template.jinja_template_service import JinjaTemplateService
5+
from src.core.domain.config import AIProviderConfig, TemplateConfig
6+
7+
from src.core.domain.job_description import JobDescription
8+
from src.core.domain.constants import TEST_RESUME_FILE_PATH, TEST_JOB_DESCRIPTION_FILE_PATH
9+
10+
# agent
11+
from langgraph.graph import StateGraph, MessagesState, START, END
12+
from src.core.agents.utils.nodes import parse_resume_node, parse_job_description_node
13+
from src.core.agents.utils.state import AgentState
14+
import logging
15+
from src.infrastructure.components import llm_extractor
16+
17+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
18+
logger = logging.getLogger("core.agents.graph_builder")
19+
20+
def build_resume_analysis_graph():
21+
workflow = StateGraph(AgentState)
22+
logger.info("Building workflow...")
23+
24+
workflow.add_node("parse_resume", lambda state: parse_resume_node(state, llm_extractor))
25+
workflow.add_node("parse_job_description", lambda state: parse_job_description_node(state, llm_extractor))
26+
27+
# Add nodes with injected dependencies
28+
workflow.add_node("experience_analyzer", lambda state: analyze_experience_node(state, llm_extractor))
29+
30+
# Connect nodes sequentially
31+
workflow.add_edge(START, "parse_resume")
32+
workflow.add_edge("parse_resume", "parse_job_description")
33+
workflow.add_edge("parse_job_description", "experience_analyzer")
34+
workflow.add_edge("experience_analyzer", END)
35+
36+
# Compile and return the graph
37+
return workflow.compile()
38+
39+
if __name__ == "__main__":
40+
import asyncio
41+
42+
logger.info("Building graph...")
43+
ai_provider = OpenAIProvider(config=AIProviderConfig())
44+
template_service = JinjaTemplateService(config=TemplateConfig.development())
45+
46+
# Accept file paths from command line or use defaults
47+
resume_path = TEST_RESUME_FILE_PATH
48+
job_description_path = TEST_JOB_DESCRIPTION_FILE_PATH
49+
50+
agent_state = {
51+
"resume_path": resume_path,
52+
"job_description_path": job_description_path
53+
}
54+
app = build_resume_analysis_graph()
55+
56+
print("\n--- LangGraph ASCII Diagram ---")
57+
print(app.get_graph().draw_ascii())
58+
59+
asyncio.run(app.ainvoke(agent_state))

src/core/agents/utils/nodes.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import asyncio
2+
from langsmith import traceable
3+
from src.core.agents.utils.state import AgentState
4+
from src.core.domain.resume import Resume
5+
from src.core.domain.job_description import JobDescription
6+
from src.infrastructure.components import llm_extractor
7+
from src.core.domain.constants import TEST_RESUME_FILE_PATH, TEST_JOB_DESCRIPTION_FILE_PATH
8+
from src.core.ports.secondary.llm_extractor import LLMExtractor
9+
10+
@traceable(run_type="llm")
11+
async def parse_resume_node(state: AgentState, extractor: LLMExtractor):
12+
resume = await extractor.parse_document(
13+
content=state["resume_path"],
14+
output_model=Resume,
15+
template_path="prompts/parsing/resume_extractor.j2"
16+
)
17+
return {"resume": resume}
18+
19+
20+
@traceable(run_type="llm")
21+
async def parse_job_description_node(state: AgentState, extractor: LLMExtractor):
22+
job_description = await extractor.parse_document(
23+
content=state["job_description_path"],
24+
output_model=JobDescription,
25+
template_path="prompts/parsing/job_description_extractor.j2"
26+
)
27+
return {"job_description": job_description}

0 commit comments

Comments
 (0)