Skip to content

Commit 7123f40

Browse files
authored
Merge pull request #1 from beatwad/release
Release
2 parents 82dfe1e + 8740e02 commit 7123f40

File tree

2 files changed

+161
-10
lines changed

2 files changed

+161
-10
lines changed

src/utils/json_to_readable.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def transform_vacancy_data(data: Dict[str, Any]) -> str:
4949
for key, label in [
5050
("job_title", "Vacancy name"),
5151
("company_name", "Company name"),
52+
("url", "Vacancy URL"),
5253
]:
5354
val = _format_value(data.get(key))
5455
if val:

tests/test_llm_manager.py

Lines changed: 160 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,127 @@ def test_ai_adapter_raises_error_for_unsupported_model(self, mock_api_key, mock_
263263
with pytest.raises(ValueError, match="Unsupported model type"):
264264
AIAdapter(mock_api_key, mock_llm_proxy)
265265

266+
@patch("src.llm.llm_manager.FREE_TIER", False)
267+
@patch("src.llm.llm_manager.LLM_MODEL_TYPE", "openai")
268+
@patch("src.llm.llm_manager.EASY_APPLY_MODEL", "gpt-4")
269+
@patch("src.llm.llm_manager.OpenAIModel")
270+
@patch("src.llm.llm_manager.pause")
271+
def test_ai_adapter_no_rate_limiting_when_free_tier_disabled(
272+
self, mock_pause, mock_openai, mock_api_key, mock_llm_proxy
273+
):
274+
"""Test AIAdapter does not apply rate limiting when free tier is disabled"""
275+
mock_model = MagicMock()
276+
mock_model.invoke.return_value = AIMessage(content="Test response")
277+
mock_openai.return_value = mock_model
278+
279+
adapter = AIAdapter(mock_api_key, mock_llm_proxy)
280+
281+
# Make multiple requests
282+
for _ in range(5):
283+
adapter.invoke("Test prompt")
284+
285+
# Verify pause was never called
286+
mock_pause.assert_not_called()
287+
288+
@patch("src.llm.llm_manager.FREE_TIER", True)
289+
@patch("src.llm.llm_manager.FREE_TIER_RPM_LIMIT", 3)
290+
@patch("src.llm.llm_manager.LLM_MODEL_TYPE", "openai")
291+
@patch("src.llm.llm_manager.EASY_APPLY_MODEL", "gpt-4")
292+
@patch("src.llm.llm_manager.OpenAIModel")
293+
@patch("src.llm.llm_manager.pause")
294+
def test_ai_adapter_no_pause_under_rpm_limit(
295+
self, mock_pause, mock_openai, mock_api_key, mock_llm_proxy
296+
):
297+
"""Test AIAdapter does not pause when requests are under RPM limit"""
298+
mock_model = MagicMock()
299+
mock_model.invoke.return_value = AIMessage(content="Test response")
300+
mock_openai.return_value = mock_model
301+
302+
adapter = AIAdapter(mock_api_key, mock_llm_proxy)
303+
304+
# Make requests under the limit (3)
305+
for _ in range(2):
306+
adapter.invoke("Test prompt")
307+
308+
# Verify pause was not called
309+
mock_pause.assert_not_called()
310+
311+
@patch("src.llm.llm_manager.FREE_TIER", True)
312+
@patch("src.llm.llm_manager.FREE_TIER_RPM_LIMIT", 3)
313+
@patch("src.llm.llm_manager.LLM_MODEL_TYPE", "openai")
314+
@patch("src.llm.llm_manager.EASY_APPLY_MODEL", "gpt-4")
315+
@patch("src.llm.llm_manager.OpenAIModel")
316+
@patch("src.llm.llm_manager.pause")
317+
@patch("src.llm.llm_manager.datetime")
318+
def test_ai_adapter_pauses_when_rpm_limit_reached(
319+
self, mock_datetime, mock_pause, mock_openai, mock_api_key, mock_llm_proxy
320+
):
321+
"""Test AIAdapter pauses when RPM limit is reached within 60 seconds"""
322+
from datetime import datetime, timedelta
323+
324+
# Setup mock datetime
325+
base_time = datetime(2024, 1, 1, 12, 0, 0)
326+
mock_datetime.now.side_effect = [
327+
base_time, # First request
328+
base_time + timedelta(seconds=10), # Second request
329+
base_time + timedelta(seconds=20), # Third request
330+
base_time + timedelta(seconds=30), # Fourth request - triggers check
331+
base_time + timedelta(seconds=30), # During pause calculation
332+
]
333+
334+
mock_model = MagicMock()
335+
mock_model.invoke.return_value = AIMessage(content="Test response")
336+
mock_openai.return_value = mock_model
337+
338+
adapter = AIAdapter(mock_api_key, mock_llm_proxy)
339+
340+
# Make requests that hit the limit (3 requests in queue, 4th triggers pause)
341+
for _ in range(4):
342+
adapter.invoke("Test prompt")
343+
344+
# Verify pause was called
345+
# Time delta = 30 seconds since first request
346+
# Should pause for 60 - 30 = 30 seconds
347+
assert mock_pause.call_count == 1
348+
call_args = mock_pause.call_args[0]
349+
assert call_args[0] == 30.0 # pause duration
350+
assert call_args[1] == 31.0 # pause duration + 1
351+
352+
@patch("src.llm.llm_manager.FREE_TIER", True)
353+
@patch("src.llm.llm_manager.FREE_TIER_RPM_LIMIT", 2)
354+
@patch("src.llm.llm_manager.LLM_MODEL_TYPE", "openai")
355+
@patch("src.llm.llm_manager.EASY_APPLY_MODEL", "gpt-4")
356+
@patch("src.llm.llm_manager.OpenAIModel")
357+
@patch("src.llm.llm_manager.pause")
358+
@patch("src.llm.llm_manager.datetime")
359+
def test_ai_adapter_no_pause_after_60_seconds(
360+
self, mock_datetime, mock_pause, mock_openai, mock_api_key, mock_llm_proxy
361+
):
362+
"""Test AIAdapter does not pause if oldest request is older than 60 seconds"""
363+
from datetime import datetime, timedelta
364+
365+
# Setup mock datetime - requests spaced more than 60 seconds apart
366+
base_time = datetime(2024, 1, 1, 12, 0, 0)
367+
mock_datetime.now.side_effect = [
368+
base_time, # First request
369+
base_time + timedelta(seconds=30), # Second request
370+
base_time + timedelta(seconds=65), # Third request - 65 seconds after first
371+
base_time + timedelta(seconds=65), # During check
372+
]
373+
374+
mock_model = MagicMock()
375+
mock_model.invoke.return_value = AIMessage(content="Test response")
376+
mock_openai.return_value = mock_model
377+
378+
adapter = AIAdapter(mock_api_key, mock_llm_proxy)
379+
380+
# Make 3 requests
381+
for _ in range(3):
382+
adapter.invoke("Test prompt")
383+
384+
# Verify pause was not called since 60+ seconds passed
385+
mock_pause.assert_not_called()
386+
266387

267388
class TestLLMLogger:
268389
"""Tests for LLMLogger class"""
@@ -460,11 +581,17 @@ def test_gpt_answerer_set_job(
460581
):
461582
"""Test GPTAnswerer set_job method"""
462583
mock_transform.return_value = "Transformed job data"
584+
585+
# Mock the summarize chain
586+
mock_chain = MagicMock()
587+
mock_chain.invoke.return_value = "Brief job description"
588+
463589
answerer = GPTAnswerer(mock_api_key, mock_llm_proxy)
590+
answerer.chains["summarize_job_description"] = mock_chain
464591
answerer.set_job(mock_job)
465592

466593
assert answerer.job == mock_job
467-
assert answerer.job_readable == "Transformed job data"
594+
assert answerer.job_readable == "Brief job description"
468595
mock_transform.assert_called_once_with(mock_job)
469596

470597
@patch("src.llm.llm_manager.transform_search_config_data")
@@ -606,17 +733,22 @@ def test_gpt_answerer_job_is_interesting_true(
606733
mock_job,
607734
):
608735
"""Test GPTAnswerer job_is_interesting returns True"""
609-
# Setup mock chain
736+
# Setup mock chain for job_is_interesting
610737
mock_chain = MagicMock()
611738
mock_chain.invoke.return_value = "Score: 85\nReasoning: Great match for skills"
612739

740+
# Setup mock chain for summarize_job_description
741+
mock_summarize_chain = MagicMock()
742+
mock_summarize_chain.invoke.return_value = "Brief job description"
743+
613744
answerer = GPTAnswerer(mock_api_key, mock_llm_proxy)
614-
answerer.chains = {"job_is_interesting": mock_chain}
745+
answerer.chains["job_is_interesting"] = mock_chain
746+
answerer.chains["summarize_job_description"] = mock_summarize_chain
615747
answerer.set_resume(mock_resume_structured, mock_resume_readable)
616748
answerer.set_job(mock_job)
617749
answerer.search_parameters = "Remote: True"
618750

619-
is_interesting, score, reasoning = answerer.job_is_interesting()
751+
is_interesting, score, reasoning = answerer.job_is_interesting(mock_job)
620752

621753
assert is_interesting is True
622754
assert score == "85"
@@ -636,17 +768,22 @@ def test_gpt_answerer_job_is_interesting_false(
636768
mock_job,
637769
):
638770
"""Test GPTAnswerer job_is_interesting returns False"""
639-
# Setup mock chain
771+
# Setup mock chain for job_is_interesting
640772
mock_chain = MagicMock()
641773
mock_chain.invoke.return_value = "Score: 50\nReasoning: Not a good fit"
642774

775+
# Setup mock chain for summarize_job_description
776+
mock_summarize_chain = MagicMock()
777+
mock_summarize_chain.invoke.return_value = "Brief job description"
778+
643779
answerer = GPTAnswerer(mock_api_key, mock_llm_proxy)
644-
answerer.chains = {"job_is_interesting": mock_chain}
780+
answerer.chains["job_is_interesting"] = mock_chain
781+
answerer.chains["summarize_job_description"] = mock_summarize_chain
645782
answerer.set_resume(mock_resume_structured, mock_resume_readable)
646783
answerer.set_job(mock_job)
647784
answerer.search_parameters = "Remote: True"
648785

649-
is_interesting, score, reasoning = answerer.job_is_interesting()
786+
is_interesting, score, reasoning = answerer.job_is_interesting(mock_job)
650787

651788
assert is_interesting is False
652789
assert score == "50"
@@ -665,19 +802,23 @@ def test_gpt_answerer_write_cover_letter(
665802
mock_job,
666803
):
667804
"""Test GPTAnswerer write_cover_letter method"""
668-
# Setup mock chain
805+
# Setup mock chain for cover letter
669806
mock_chain = MagicMock()
670807
mock_chain.invoke.return_value = "Dear Hiring Manager,\n\nI am writing..."
671808

809+
# Setup mock chain for summarize_job_description
810+
mock_summarize_chain = MagicMock()
811+
mock_summarize_chain.invoke.return_value = "Brief job description"
812+
672813
answerer = GPTAnswerer(mock_api_key, mock_llm_proxy)
673-
answerer._create_chain = MagicMock(return_value=mock_chain)
814+
answerer.chains["summarize_job_description"] = mock_summarize_chain
815+
answerer.chains["prompt_cover_letter"] = mock_chain
674816
answerer.set_resume(mock_resume_structured, mock_resume_readable)
675817
answerer.set_job(mock_job)
676818

677819
result = answerer.write_cover_letter()
678820

679821
assert "Dear Hiring Manager" in result
680-
answerer._create_chain.assert_called_once()
681822

682823
@patch("src.llm.llm_manager.AIAdapter")
683824
@patch("src.llm.llm_manager.LoggerChatModel")
@@ -720,11 +861,20 @@ def test_gpt_answerer_generate_html_resume(
720861
mock_job,
721862
):
722863
"""Test GPTAnswerer generate_html_resume method"""
864+
# Setup mock chain for summarize_job_description
865+
mock_summarize_chain = MagicMock()
866+
mock_summarize_chain.invoke.return_value = "Brief job description"
867+
723868
# Setup mock methods
724869
answerer = GPTAnswerer(mock_api_key, mock_llm_proxy)
870+
answerer.chains["summarize_job_description"] = mock_summarize_chain
725871
answerer.set_resume(mock_resume_structured, mock_resume_readable)
726872
answerer.set_job(mock_job)
727873

874+
# Mock load_resume_template to return empty string so generate methods are called
875+
answerer.load_resume_template = MagicMock(return_value="")
876+
answerer.save_resume_template = MagicMock()
877+
728878
answerer.generate_header = MagicMock(return_value="<header>Header</header>")
729879
answerer.generate_education_section = MagicMock(return_value="<section>Education</section>")
730880
answerer.generate_work_experience_section = MagicMock(

0 commit comments

Comments
 (0)