diff --git a/backend/app/agent/career_explorer_agent/non_priority_sector_explorer.py b/backend/app/agent/career_explorer_agent/non_priority_sector_explorer.py index 8605f920b..840504607 100644 --- a/backend/app/agent/career_explorer_agent/non_priority_sector_explorer.py +++ b/backend/app/agent/career_explorer_agent/non_priority_sector_explorer.py @@ -1,6 +1,6 @@ """ Explorer for non-priority sectors using Google Search grounding. Answers about careers outside priority sectors. -Uses google-genai SDK (Tool with google_search) as vertexai's google_search_retrieval is deprecated. +Uses the google_search tool of the google-genai Interactions API (see GeminiGenerativeLLM). Two-stage design ---------------- @@ -14,12 +14,9 @@ import json import logging -import os from textwrap import dedent -from google import genai -from google.genai import types -from google.genai.types import GenerateContentConfig, GoogleSearch, HttpOptions, Tool, GroundingMetadata +from google.genai.types import GroundingMetadata from app.agent.agent_types import LLMStats, LLMQuickReplyOption from app.agent.config import ModelTier @@ -33,11 +30,10 @@ from app.i18n.translation_service import t from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter from common_libs.llm.factory import get_llm -from common_libs.llm.utils import extract_grounding_metadata_from_genai_response -from common_libs.llm.models_utils import DEFAULT_VERTEX_API_GEN_AI_REGION, LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG, \ - llm_input_to_traceable, resolve_model_name +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG, \ + resolve_model_name from common_libs.llm.schema_builder import with_response_schema -from common_libs.observability.tracing import traced_observation, update_observation def _build_non_priority_instructions() -> str: @@ -120,15 +116,6 @@ def _build_non_priority_instructions() -> str: """) -def _llm_input_to_contents(llm_input) -> list[types.Content]: - contents = [] - for turn in llm_input.turns: - role = "user" if turn.role == "user" else "model" - part = types.Part.from_text(text=turn.content) - contents.append(types.Content(role=role, parts=[part])) - return contents - - def _build_priority_nudge_section(should_nudge: bool) -> str: """Periodic bridge back to priority sectors. Only injected on the every-Nth user turn so the conversation does not push priority sectors on every reply.""" @@ -231,74 +218,37 @@ async def explore( context=context, user_input=user_input, ) - contents = _llm_input_to_contents(llm_input) - - project = os.getenv("GOOGLE_CLOUD_PROJECT") - location = os.getenv("VERTEX_API_GEN_AI_REGION") or DEFAULT_VERTEX_API_GEN_AI_REGION - client = genai.Client( - vertexai=True, - project=project, - location=location, - http_options=HttpOptions(api_version="v1"), - ) - - stage1_config = GenerateContentConfig( - system_instruction=full_instructions, - tools=[Tool(google_search=GoogleSearch())], - temperature=1.0, + # Stage 1: Google Search grounded call -- produces free-text answer. + # Google Search grounding is Gemini-only, so the Gemini model is used whatever the configured provider is. + grounded_search_llm = GeminiGenerativeLLM( + system_instructions=full_instructions, + config=LLMConfig( + language_model_name=resolve_model_name(tier=ModelTier.DEFAULT, provider="gemini"), + generation_config={"temperature": 1.0}, + ), + tools=[{"type": "google_search"}], ) - # Google Search grounding is Gemini-only, so resolve the Gemini model whatever the configured provider is. - grounded_search_model = resolve_model_name(tier=ModelTier.DEFAULT, provider="gemini") - llm_stats: list[LLMStats] = [] grounding_metadata: GroundingMetadata | None = None raw_text: str | None = None - - # Stage 1: Google Search grounded call -- produces free-text answer - # This is one of the two LLM call sites that bypass BasicLLM, so it needs its own explicit - # generation observation; the chokepoint instrumentation does not see it. - with traced_observation( - name="non_priority_sector_explorer.grounded_search", - as_type="generation", - input=llm_input_to_traceable(llm_input), - model=grounded_search_model, - model_parameters={"temperature": stage1_config.temperature}, - metadata={"grounding": "google_search", "stage": 1}, - ) as generation: - try: - response = await client.aio.models.generate_content( - model=grounded_search_model, - contents=contents, - config=stage1_config, - ) - prompt_token_count = getattr(response.usage_metadata, "prompt_token_count", 0) or 0 - response_token_count = getattr(response.usage_metadata, "candidates_token_count", 0) or 0 - llm_stats.append( - LLMStats( - prompt_token_count=prompt_token_count, - response_token_count=response_token_count, - response_time_in_sec=0, - ) - ) - raw_text = response.text - self._logger.debug("Stage 1 raw response (first 500 chars): %.500s", raw_text) - grounding_metadata = extract_grounding_metadata_from_genai_response(response) - update_observation( - generation, - output=raw_text, - usage_details={ - "input": prompt_token_count, - "output": response_token_count, - "total": prompt_token_count + response_token_count, - }, - ) - except Exception as e: # pylint: disable=broad-except - self._logger.exception("Stage 1 (Google Search) LLM call failed: %s", e) - llm_stats.append( - LLMStats(error=str(e), prompt_token_count=0, response_token_count=0, response_time_in_sec=0) + try: + response = await grounded_search_llm.generate_content(llm_input) + llm_stats.append( + LLMStats( + prompt_token_count=response.prompt_token_count, + response_token_count=response.response_token_count, + response_time_in_sec=0, ) - update_observation(generation, level="ERROR", status_message=str(e)) + ) + raw_text = response.text + self._logger.debug("Stage 1 raw response (first 500 chars): %.500s", raw_text) + grounding_metadata = response.grounding_metadata + except Exception as e: # pylint: disable=broad-except + self._logger.exception("Stage 1 (Google Search) LLM call failed: %s", e) + llm_stats.append( + LLMStats(error=str(e), prompt_token_count=0, response_token_count=0, response_time_in_sec=0) + ) if grounding_metadata: self._logger.info( diff --git a/backend/app/agent/career_explorer_agent/test_non_priority_sector_explorer.py b/backend/app/agent/career_explorer_agent/test_non_priority_sector_explorer.py index 8863067ef..92794dbbf 100644 --- a/backend/app/agent/career_explorer_agent/test_non_priority_sector_explorer.py +++ b/backend/app/agent/career_explorer_agent/test_non_priority_sector_explorer.py @@ -7,7 +7,7 @@ Two-stage design ---------------- -Stage 1 (Google Search) is mocked via patching genai.Client. +Stage 1 (Google Search) is mocked via patching GeminiGenerativeLLM. Stage 2 (structured reformat) is mocked via patching _reformat_to_structured on the explorer instance, since it requires Vertex AI credentials unavailable in unit tests. The reformat stage returns a ModelResponse directly, simulating what response_schema @@ -60,14 +60,13 @@ ) -def _make_mock_response(text: str) -> MagicMock: - """Build a minimal mock of the genai GenerateContentResponse.""" +def _make_mock_response(text: str, grounding_metadata=None) -> MagicMock: + """Build a minimal mock of the LLMResponse of the grounded search LLM.""" mock_response = MagicMock() mock_response.text = text - mock_response.usage_metadata.prompt_token_count = 100 - mock_response.usage_metadata.candidates_token_count = 200 - # No grounding metadata for these unit tests. - mock_response.candidates = [] + mock_response.prompt_token_count = 100 + mock_response.response_token_count = 200 + mock_response.grounding_metadata = grounding_metadata return mock_response @@ -146,13 +145,9 @@ async def test_bug_reproduction_reasoning_not_leaked_to_user(self, explorer, moc with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_raw_text) # WHEN the explorer processes the response @@ -191,13 +186,9 @@ async def test_happy_path_plain_message_returned(self, explorer, mock_app_config with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_raw_text) # WHEN the explorer processes the response @@ -226,13 +217,9 @@ async def test_llm_returns_plain_text_no_json(self, explorer, mock_app_config): with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_plain_text) # WHEN the explorer processes the response @@ -270,13 +257,9 @@ async def test_bare_json_fields_without_braces_recovered(self, explorer, mock_ap with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) # Stage 2 receives the raw text and returns only the message field explorer._reformat_to_structured = AsyncMock(return_value=ModelResponse( reasoning="The user asked about construction careers in Zambia.", @@ -313,13 +296,9 @@ async def test_edge_case_malformed_json_returns_generic_error(self, explorer, mo with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_raw_text) # WHEN the explorer processes the response @@ -352,13 +331,9 @@ async def test_edge_case_double_json_from_tool_call_intermediate(self, explorer, with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) explorer._reformat_to_structured = AsyncMock(return_value=ModelResponse( reasoning="I searched for ICT Data Engineer roles.", finished=False, @@ -403,13 +378,9 @@ async def test_edge_case_json_with_preamble_prose(self, explorer, mock_app_confi with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) explorer._reformat_to_structured = AsyncMock(return_value=ModelResponse( reasoning="The user asked about ICT, a non-priority sector. I searched.", finished=False, @@ -456,13 +427,9 @@ async def test_hypothesis1_message_field_contains_json_string(self, explorer, mo with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) # Stage 2 extracts the innermost human-readable message, discarding the nested JSON explorer._reformat_to_structured = AsyncMock(return_value=ModelResponse( reasoning="I searched and found relevant information.", @@ -509,13 +476,9 @@ async def test_edge_case_response_text_raises_valueerror(self, explorer, mock_ap new_callable=PropertyMock, create=True, side_effect=ValueError("Multiple content parts")), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=given_mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=given_mock_response) # WHEN the explorer processes the response actual_message, actual_finished, actual_reasoning, actual_llm_stats, actual_metadata = \ @@ -551,13 +514,9 @@ async def test_quick_reply_options_returned_when_llm_includes_them(self, explore with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_raw_text) # WHEN the explorer processes the response @@ -587,13 +546,9 @@ async def test_no_quick_reply_options_when_llm_omits_them(self, explorer, mock_a with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_raw_text) # WHEN the explorer processes the response @@ -627,13 +582,10 @@ async def test_quick_reply_options_coexist_with_grounding_metadata(self, explore with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=given_grounding): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - mock_genai.Client.return_value = mock_client + mock_response.grounding_metadata = given_grounding + mock_llm_class.return_value.generate_content = AsyncMock(return_value=mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_raw_text) # WHEN the explorer processes the response @@ -659,13 +611,9 @@ async def test_quick_reply_options_absent_on_plain_text_fallback(self, explorer, with patch("app.agent.career_explorer_agent.non_priority_sector_explorer.get_application_config", return_value=mock_app_config), \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer.genai") as mock_genai, \ - patch("app.agent.career_explorer_agent.non_priority_sector_explorer." - "extract_grounding_metadata_from_genai_response", return_value=None): + patch("app.agent.career_explorer_agent.non_priority_sector_explorer.GeminiGenerativeLLM") as mock_llm_class: - mock_client = AsyncMock() - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - mock_genai.Client.return_value = mock_client + mock_llm_class.return_value.generate_content = AsyncMock(return_value=mock_response) explorer._reformat_to_structured = _make_reformat_mock(given_plain_text) # WHEN the explorer processes the response diff --git a/backend/app/agent/llm_caller.py b/backend/app/agent/llm_caller.py index f20be24e8..3380921dd 100644 --- a/backend/app/agent/llm_caller.py +++ b/backend/app/agent/llm_caller.py @@ -2,10 +2,9 @@ import time from typing import Generic, TypeVar, Type, Tuple -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from app.agent.agent_types import LLMStats -from common_libs.llm.generative_models import GeminiGenerativeLLM from common_libs.llm.models_utils import LLM, LLMInput, llm_input_to_traceable from common_libs.observability.tracing import record_score, traced_observation, update_observation from common_libs.text_formatters.extract_json import extract_json, ExtractJSONError @@ -15,7 +14,6 @@ _MAX_ATTEMPTS = 3 # Maximum values for generation parameters -_MAX_FREQUENCY_PENALTY = 1.0 _MAX_TEMPERATURE = 1.0 # Increment step for adjusting generation parameters @@ -28,8 +26,8 @@ class LLMCaller(Generic[RESPONSE_T]): """ A class that calls the LLM to generate a response to an input. - It can retry multiple times if the LLM fails to respond with a JSON object that - complies with the model_response_type. + The model_response_type is passed to the LLM as the schema of the response (structured output), + so the LLM responds with a JSON object that complies with it. It retries multiple times if the LLM fails to. Additionally, it logs errors it captures the statistics of the LLM calls. """ @@ -107,14 +105,10 @@ async def _call_llm_with_retries(self, *, attempt_count = 0 model_response: RESPONSE_T | None = None - # Gemini-specific: mutate frequency_penalty/temperature to escape repetition traps. - # Non-Gemini LLMs skip this — retries still fire but sampling params won't change. - generation_config = ( - llm._model._generation_config._raw_generation_config # pylint: disable=protected-access - if isinstance(llm, GeminiGenerativeLLM) else None - ) - original_frequency_penalty = generation_config.frequency_penalty if generation_config is not None else 0.0 - original_temperature = generation_config.temperature if generation_config is not None else 0.0 + # Raise the temperature to escape repetition traps, for the LLMs whose generation config is read at call time. + # The other LLMs skip this — retries still fire but sampling params won't change. + generation_config: dict | None = getattr(llm, "generation_config", None) + original_temperature = generation_config.get("temperature") if generation_config is not None else None while not success and attempt_count < _MAX_ATTEMPTS: attempt_count += 1 @@ -126,7 +120,8 @@ async def _call_llm_with_retries(self, *, try: # Call the LLM to generate content. llm_response = await llm.generate_content( - llm_input=llm_input + llm_input=llm_input, + response_schema=self._model_response_type, ) except Exception as e: # If for some reason, the LLM fails to call, we log the error and continue to the next attempt. @@ -158,7 +153,7 @@ async def _call_llm_with_retries(self, *, response_text = llm_response.text try: - model_response = extract_json(response_text, self._model_response_type) + model_response = self._parse_response(response_text) success = True logger.info("LLM call completed (model=%s)", getattr(llm, "_model_name", type(llm).__name__)) except ExtractJSONError as e: @@ -166,48 +161,38 @@ async def _call_llm_with_retries(self, *, llm_stats.error = log_message logger.warning("Raw LLM response text (first 500 chars): %s", response_text[:500] if response_text else "None") logger.warning("Raw LLM response text length: %d characters", len(response_text) if response_text else 0) - if generation_config is not None: - logger.warning("Response token count: %d, Max output tokens: %d", llm_stats.response_token_count, generation_config.max_output_tokens) + max_output_tokens = generation_config.get("max_output_tokens") if generation_config is not None else None + if max_output_tokens is not None: + logger.warning("Response token count: %d, Max output tokens: %d", llm_stats.response_token_count, max_output_tokens) if attempt_count == _MAX_ATTEMPTS: # The agent failed to respond with a JSON object after the last attempt, logger.error(log_message) # And set the response to the model output and hope that the caller can handle it else: logger.warning(log_message) - if generation_config is not None and llm_stats.response_token_count == generation_config.max_output_tokens: + if max_output_tokens is not None and llm_stats.response_token_count >= max_output_tokens: # Most-likely we run into a "repetition trap". This happens often with prompts that have Chain Of Thought reasoning tasks. - # We will increase the frequency_penalty and the temperature and return the model to avoid repetition. - # However, higher frequency_penalty might cause the model to penalize punctuation and JSON format characters, - # and in combination with the higher the temperature will most likely result in an invalid JSON. - # Therefore, there is no guarantee that the result is a valid JSON. - # However, we must perform this process to get unstuck from the repetition trap as experiments have - # shown that just rerunning the model will not solve the problem on its own, but changing the parameters will. - generation_config.frequency_penalty += _PENALTY_INCREMENT - if generation_config.frequency_penalty > _MAX_FREQUENCY_PENALTY: - generation_config.frequency_penalty = _MAX_FREQUENCY_PENALTY - - generation_config.temperature += _PENALTY_INCREMENT - if generation_config.temperature > _MAX_TEMPERATURE: - generation_config.temperature = _MAX_TEMPERATURE + # Experiments have shown that just rerunning the model will not solve the problem on its own, + # but changing the parameters will, so we increase the temperature for the next attempt. + temperature = min((generation_config.get("temperature") or 0.0) + _PENALTY_INCREMENT, _MAX_TEMPERATURE) + generation_config["temperature"] = temperature logger.warning("The model reached the maximum number of tokens %s.\n" - "To escape the repetition trap, we increased the frequency_penalty to %s\n" "To escape the repetition trap, we increased the temperature to %s", - generation_config.max_output_tokens, - generation_config.frequency_penalty, - generation_config.temperature) + max_output_tokens, temperature) record_score( name="llm_repetition_trap", value=1, - comment=f"attempt {attempt_count}: frequency_penalty raised to {generation_config.frequency_penalty}, " - f"temperature raised to {generation_config.temperature}", + comment=f"attempt {attempt_count}: temperature raised to {temperature}", ) finally: llm_stats_list.append(llm_stats) if generation_config is not None: - generation_config.frequency_penalty = original_frequency_penalty - generation_config.temperature = original_temperature + if original_temperature is None: + generation_config.pop("temperature", None) + else: + generation_config["temperature"] = original_temperature # Note: We intentionally do NOT reset llm_call_duration_ms_ctx_var here. # The duration should remain set so that observability logs in calling code @@ -217,3 +202,14 @@ async def _call_llm_with_retries(self, *, logger.debug("Model input: %s", llm_input) logger.debug("Model output: %s", model_response) return model_response, llm_stats_list + + def _parse_response(self, response_text: str) -> RESPONSE_T: + """ + Parse the JSON response of the LLM into the model_response_type. + The structured output makes the response a JSON object that follows the schema, so it is validated directly. + Falls back to extracting the JSON object from the text, for LLMs that wrap it (e.g. in a markdown code block). + """ + try: + return self._model_response_type.model_validate_json(response_text) + except ValidationError: + return extract_json(response_text, self._model_response_type) diff --git a/backend/app/career_readiness/test_agent.py b/backend/app/career_readiness/test_agent.py index 586060495..1b3238477 100644 --- a/backend/app/career_readiness/test_agent.py +++ b/backend/app/career_readiness/test_agent.py @@ -519,30 +519,31 @@ def test_json_schema_contains_enum_constraint(self): assert "Alpha" in actual_schema_str assert "Beta" in actual_schema_str - def test_with_response_schema_preserves_enum_after_vertex_cleaning(self): + def test_response_schema_preserves_enum(self): # GIVEN a dynamic model built for specific topics given_topics = ["Alpha", "Beta"] actual_model = _build_module_response_model(given_topics) - # WHEN the Vertex-shaped schema is built - actual_schema = with_response_schema(actual_model) + # WHEN the JSON schema of the structured output is built + actual_schema = with_response_schema(actual_model)["response_schema"].model_json_schema() - # THEN the cleaned schema still carries an enum constraint on topic_status[].topic_id - actual_topic_status_items = ( - actual_schema["response_schema"]["properties"]["topic_status"]["items"] - ) + # THEN the schema still carries an enum constraint on topic_status[].topic_id + actual_topic_status_items = actual_schema["properties"]["topic_status"]["items"] + if "$ref" in actual_topic_status_items: + actual_topic_status_items = actual_schema["$defs"][actual_topic_status_items["$ref"].split("/")[-1]] actual_topic_id_schema = actual_topic_status_items["properties"]["topic_id"] + if "$ref" in actual_topic_id_schema: + actual_topic_id_schema = actual_schema["$defs"][actual_topic_id_schema["$ref"].split("/")[-1]] assert set(actual_topic_id_schema.get("enum", [])) == {"Alpha", "Beta"} - def test_vertex_schema_marks_topic_status_as_required(self): + def test_response_schema_marks_topic_status_as_required(self): # GIVEN a dynamic model built for a non-empty topic list given_topics = ["Alpha", "Beta"] actual_model = _build_module_response_model(given_topics) - # WHEN the Vertex-shaped schema is built - actual_schema = with_response_schema(actual_model) + # WHEN the JSON schema of the structured output is built + actual_schema = with_response_schema(actual_model)["response_schema"].model_json_schema() # THEN topic_status is in the top-level `required` list - expected_required = actual_schema["response_schema"].get("required", []) - assert "topic_status" in expected_required + assert "topic_status" in actual_schema.get("required", []) diff --git a/backend/app/vector_search/embeddings_model.py b/backend/app/vector_search/embeddings_model.py index ec769ea46..f61977e50 100644 --- a/backend/app/vector_search/embeddings_model.py +++ b/backend/app/vector_search/embeddings_model.py @@ -4,9 +4,8 @@ from abc import ABC, abstractmethod from typing import Coroutine, Any -import vertexai -from google.cloud.aiplatform import initializer as aiplatform_initializer -from vertexai.language_models import TextEmbeddingInput, TextEmbeddingModel, TextEmbedding +from google import genai +from google.genai.types import EmbedContentConfig from common_libs.retry import Retry @@ -54,17 +53,9 @@ def __init__(self, model_name: str): raise ValueError("Environment variable 'VERTEX_API_EMBEDDINGS_REGION' is not set.") self.logger.info(f"Constructing {self.__class__.__name__} with model: {model_name} and region: {self.region}") - # vertexai.init() is process-wide and shared with the Gemini models (see BasicLLM), so leaving the - # embeddings region set would send every Gemini model created afterwards to that region. - previous_location = aiplatform_initializer.global_config.location - vertexai.init(location=self.region) - try: - self.model = TextEmbeddingModel.from_pretrained(model_name) - # Otherwise the SDK creates the client on the first call, with whatever location is set by then. - # noinspection PyProtectedMember - _ = self.model._endpoint._prediction_async_client # pylint: disable=protected-access - finally: - vertexai.init(location=previous_location) + # The client is bound to the embeddings region, independently of the generative-AI client (see GeminiGenerativeLLM). + self._client = genai.Client(vertexai=True, location=self.region) + self._config = EmbedContentConfig(task_type=self._TASK) async def embed(self, text: str) -> list[float]: """ @@ -101,19 +92,20 @@ async def internal_embed_batch(self, text_list: list[str]) -> list[list[float]]: batch_size = 5 embeddings = [] for i in range(0, len(text_list), batch_size): - def _callback() -> Coroutine[Any, Any, list[TextEmbedding]]: - inputs: list[TextEmbeddingInput] = [TextEmbeddingInput(query, self._TASK) for query in text_list[i:i + batch_size]] - return self._run_batch(inputs) + def _callback() -> Coroutine[Any, Any, list[list[float]]]: + return self._run_batch(text_list[i:i + batch_size]) - batch_embeddings: list[TextEmbedding] = await Retry[list[TextEmbedding]].call_with_exponential_backoff(callback=_callback,logger=self.logger) + batch_embeddings: list[list[float]] = await Retry[list[list[float]]].call_with_exponential_backoff(callback=_callback, + logger=self.logger) embeddings.extend(batch_embeddings) - return [embedding.values for embedding in embeddings] + return embeddings - async def _run_batch(self, inputs: list[TextEmbeddingInput]) -> list[TextEmbedding]: + async def _run_batch(self, inputs: list[str]) -> list[list[float]]: try: # measure how long it takes to generate the embeddings start_time = time.time() - result = await self.model.get_embeddings_async(inputs) + response = await self._client.aio.models.embed_content(model=self.model_name, contents=inputs, config=self._config) + result = [embedding.values for embedding in response.embeddings] end_time = time.time() self.logger.debug(f"Time taken to generate embeddings: {end_time - start_time:.2f} seconds for {len(inputs)} queries, " f"{len(result)/(end_time - start_time):.2f} queries/seconds") diff --git a/backend/app/vector_search/test_embeddings_model.py b/backend/app/vector_search/test_embeddings_model.py index edfb3dc7d..3b899d21c 100644 --- a/backend/app/vector_search/test_embeddings_model.py +++ b/backend/app/vector_search/test_embeddings_model.py @@ -1,95 +1,80 @@ -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch import pytest -import vertexai -from google.cloud.aiplatform import initializer as aiplatform_initializer from app.vector_search.embeddings_model import GoogleEmbeddingService -GEN_AI_LOCATION = "global" -EMBEDDINGS_REGION = "us-central1" +def _fake_client() -> MagicMock: + """A fake google-genai client that returns an embedding [index, index] for each text, in order.""" + client = MagicMock() -class _FakeEmbedding: - def __init__(self, values: list[float]): - self.values = values + async def _embed_content(*, model, contents, config): # pylint: disable=unused-argument + return SimpleNamespace(embeddings=[SimpleNamespace(values=[float(len(text)), 1.0]) for text in contents]) - -class _FakeEndpoint: - """Records the Vertex AI location that is set when the prediction client is created.""" - - def __init__(self): - self.client_location: str | None = None - - @property - def _prediction_async_client(self): - self.client_location = aiplatform_initializer.global_config.location - return object() - - -class _FakeTextEmbeddingModel: - def __init__(self, location: str): - self.location = location - self._endpoint = _FakeEndpoint() - - async def get_embeddings_async(self, inputs): - return [_FakeEmbedding([0.1, 0.2]) for _ in inputs] - - -def _fake_from_pretrained(_model_name: str) -> _FakeTextEmbeddingModel: - return _FakeTextEmbeddingModel(location=aiplatform_initializer.global_config.location) - - -@pytest.fixture -def given_gen_ai_location(monkeypatch): - """Initializes the Vertex AI SDK with the gen-AI location, as BasicLLM does, and restores the previous location.""" - monkeypatch.setenv("VERTEX_API_EMBEDDINGS_REGION", EMBEDDINGS_REGION) - previous_location = aiplatform_initializer.global_config.location - vertexai.init(location=GEN_AI_LOCATION) - yield GEN_AI_LOCATION - vertexai.init(location=previous_location) + client.aio.models.embed_content = AsyncMock(side_effect=_embed_content) + return client class TestGoogleEmbeddingService: - def test_constructing_uses_the_embeddings_region_and_restores_the_gen_ai_location(self, given_gen_ai_location: str): - # GIVEN the Vertex AI SDK is initialized with the gen-AI location and the embeddings region differs from it + def test_creates_the_client_for_the_embeddings_region(self, monkeypatch): + # GIVEN the embeddings region + given_region = "us-central1" + monkeypatch.setenv("VERTEX_API_EMBEDDINGS_REGION", given_region) # WHEN the embedding service is constructed - with patch("app.vector_search.embeddings_model.TextEmbeddingModel.from_pretrained", - side_effect=_fake_from_pretrained): - service = GoogleEmbeddingService(model_name="foo-embedding-model") + with patch("app.vector_search.embeddings_model.genai.Client") as mock_client_class: + GoogleEmbeddingService(model_name="foo-model") - # THEN expect the model to be resolved in the embeddings region - assert service.model.location == EMBEDDINGS_REGION - # AND its prediction client to be created in the embeddings region - assert service.model._endpoint.client_location == EMBEDDINGS_REGION - # AND the gen-AI location to be restored, so that Gemini models created later do not use the embeddings region - assert aiplatform_initializer.global_config.location == given_gen_ai_location - - @pytest.mark.asyncio - async def test_embedding_does_not_change_the_gen_ai_location(self, given_gen_ai_location: str): - # GIVEN an embedding service constructed while the Vertex AI SDK is initialized with the gen-AI location - with patch("app.vector_search.embeddings_model.TextEmbeddingModel.from_pretrained", - side_effect=_fake_from_pretrained): - service = GoogleEmbeddingService(model_name="foo-embedding-model") + # THEN expect a Vertex AI client for the embeddings region + mock_client_class.assert_called_once_with(vertexai=True, location=given_region) - # WHEN a text is embedded - actual_embedding = await service.embed("foo") + def test_raises_when_the_embeddings_region_is_not_set(self, monkeypatch): + # GIVEN the embeddings region is not set + monkeypatch.delenv("VERTEX_API_EMBEDDINGS_REGION", raising=False) - # THEN expect the embedding to be returned - assert actual_embedding == [0.1, 0.2] - # AND the gen-AI location to be unchanged - assert aiplatform_initializer.global_config.location == given_gen_ai_location + # WHEN the embedding service is constructed + # THEN expect a ValueError + with pytest.raises(ValueError): + GoogleEmbeddingService(model_name="foo-model") - def test_constructing_restores_the_gen_ai_location_when_loading_the_model_fails(self, given_gen_ai_location: str): - # GIVEN loading the embedding model fails - given_error = RuntimeError("foo") + @pytest.mark.asyncio + @pytest.mark.parametrize("given_region, expected_batch_size", [ + ("us-central1", 250), + ("europe-west4", 5), + ]) + async def test_embeds_the_texts_in_batches_of_the_region(self, monkeypatch, given_region: str, expected_batch_size: int): + # GIVEN the embeddings region + monkeypatch.setenv("VERTEX_API_EMBEDDINGS_REGION", given_region) + # AND more texts than fit in a batch + given_texts = ["a" * (i + 1) for i in range(expected_batch_size + 1)] + # AND an embedding service with a fake client + given_client = _fake_client() + with patch("app.vector_search.embeddings_model.genai.Client", return_value=given_client): + given_service = GoogleEmbeddingService(model_name="foo-model") + + # WHEN the texts are embedded + actual_embeddings = await given_service.embed_batch(given_texts) + + # THEN expect an embedding for each text, in order + assert actual_embeddings == [[float(len(text)), 1.0] for text in given_texts] + # AND the texts to be sent in two batches of at most the region's batch size + actual_batches = [call.kwargs["contents"] for call in given_client.aio.models.embed_content.call_args_list] + assert [len(batch) for batch in actual_batches] == [expected_batch_size, 1] + # AND the model and the retrieval query task type to be used + actual_call = given_client.aio.models.embed_content.call_args_list[0] + assert actual_call.kwargs["model"] == "foo-model" + assert actual_call.kwargs["config"].task_type == "RETRIEVAL_QUERY" - # WHEN the embedding service is constructed - # THEN expect the error to be raised - with patch("app.vector_search.embeddings_model.TextEmbeddingModel.from_pretrained", side_effect=given_error), \ - pytest.raises(RuntimeError) as error_info: - GoogleEmbeddingService(model_name="foo-embedding-model") - assert error_info.value is given_error - # AND the gen-AI location to be restored - assert aiplatform_initializer.global_config.location == given_gen_ai_location + @pytest.mark.asyncio + async def test_rejects_empty_texts(self, monkeypatch): + # GIVEN an embedding service + monkeypatch.setenv("VERTEX_API_EMBEDDINGS_REGION", "us-central1") + with patch("app.vector_search.embeddings_model.genai.Client", return_value=_fake_client()): + given_service = GoogleEmbeddingService(model_name="foo-model") + + # WHEN an empty text is embedded + # THEN expect a ValueError + with pytest.raises(ValueError): + await given_service.embed_batch(["foo", " "]) diff --git a/backend/common_libs/llm/anthropic_llm.py b/backend/common_libs/llm/anthropic_llm.py index 006085d63..404826968 100644 --- a/backend/common_libs/llm/anthropic_llm.py +++ b/backend/common_libs/llm/anthropic_llm.py @@ -1,41 +1,35 @@ import json import logging import os +from typing import Type import anthropic from pydantic import BaseModel -from common_libs.llm.models_utils import LLM, LLMInput, LLMResponse +from common_libs.llm.models_utils import LLM, LLMInput, LLMResponse, get_response_schema from common_libs.retry import RetryConfigWithExponentialBackOff, DEFAULT_RETRY_CONFIG_WITH_EXP_BACKOFF, Retry _STRUCTURED_OUTPUT_TOOL_NAME = "structured_output" -def _normalize_schema(node): - """Convert Vertex AI schema format (type_/format_/nullable) to standard JSON Schema. - - Vertex AI uses type_="ARRAY" + nullable=True for Optional[list[...]]. - Standard JSON Schema expresses this as type=["array","null"] or just omits the - field from `required`. Since optional fields are already absent from `required`, - we drop `nullable` and leave the type as-is — models handle missing optional - fields correctly when they're not in `required`. +def _json_schema_for_tool(response_schema: Type[BaseModel]) -> dict: """ - if isinstance(node, dict): - out = {} - for k, v in node.items(): - if k == "type_": - # type_ can be a proto enum int or a string; normalise to lowercase string. - out["type"] = v.lower() if isinstance(v, str) else str(v).lower() - elif k == "format_": - out["format"] = v - elif k == "nullable": - pass # already expressed by absence from `required` - else: - out[k] = _normalize_schema(v) - return out - elif isinstance(node, list): - return [_normalize_schema(i) for i in node] - return node + The JSON schema of the pydantic class, with the references to its definitions ($defs) inlined, + as the input schema of the structured output tool. + """ + schema = response_schema.model_json_schema() + definitions = schema.pop("$defs", {}) + + def _inline(node): + if isinstance(node, dict): + if "$ref" in node: + return _inline(definitions[node["$ref"].split("/")[-1]]) + return {key: _inline(value) for key, value in node.items()} + if isinstance(node, list): + return [_inline(item) for item in node] + return node + + return _inline(schema) class AnthropicLLMConfig(BaseModel): @@ -51,11 +45,9 @@ class AnthropicLLM(LLM): """ Wraps the Anthropic Claude API. - Extends LLM directly rather than BasicLLM to avoid triggering vertexai.init(). - - Structured output: when the caller's generation config includes response_schema - (Gemini's structured output field), this class uses Anthropic tool use with - tool_choice forced to that tool — the API-level equivalent of Gemini's response_schema. + Structured output: when a response schema is given for the call, or the caller's generation config + includes one (see `with_response_schema`), this class uses Anthropic tool use with + tool_choice forced to that tool — the API-level equivalent of Gemini's structured output. JSON mode without schema: when response_mime_type is "application/json" but no response_schema is present, a system prompt instruction is used instead. @@ -70,21 +62,27 @@ def __init__(self, *, self._generation_config = dict(config.generation_config) self._system_instructions = system_instructions self._json_mode = config.generation_config.get("response_mime_type") == "application/json" - self._response_schema = config.generation_config.get("response_schema") self._retry_config = config.retry_config # Create the client once; reuse across all calls from this instance. self._client = anthropic.AsyncAnthropic( api_key=api_key or os.environ["ANTHROPIC_API_KEY"] ) - async def generate_content(self, llm_input: LLMInput | str) -> LLMResponse: + @property + def generation_config(self) -> dict: + """The generation parameters, read at call time.""" + return self._generation_config + + async def generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None = None) -> LLMResponse: async def _call() -> LLMResponse: - return await self._internal_generate_content(llm_input) + return await self._internal_generate_content(llm_input, get_response_schema(self._generation_config, response_schema)) return await Retry[str].call_with_exponential_backoff(callback=_call, logger=self.logger) - async def _internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: - system = self._build_system() + async def _internal_generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None) -> LLMResponse: + system = self._build_system(json_without_schema=self._json_mode and response_schema is None) messages = self._build_messages(llm_input) kwargs: dict = { @@ -102,21 +100,20 @@ async def _internal_generate_content(self, llm_input: LLMInput | str) -> LLMResp elif "top_p" in self._generation_config: kwargs["top_p"] = self._generation_config["top_p"] - if self._json_mode and self._response_schema: + if response_schema is not None: # Use tool use to enforce the response schema at the API level. # tool_choice "tool" forces the model to call exactly this tool, - # giving the same guarantee as Gemini's response_schema. - normalized = _normalize_schema(self._response_schema) + # giving the same guarantee as Gemini's structured output. kwargs["tools"] = [{ "name": _STRUCTURED_OUTPUT_TOOL_NAME, "description": "Return a structured response conforming to the required schema.", - "input_schema": normalized, + "input_schema": _json_schema_for_tool(response_schema), }] kwargs["tool_choice"] = {"type": "tool", "name": _STRUCTURED_OUTPUT_TOOL_NAME} response = await self._client.messages.create(**kwargs) - if self._json_mode and self._response_schema: + if response_schema is not None: # Extract the tool call input dict and re-serialize as JSON string # so the rest of the codebase can parse it as before. tool_block = next( @@ -149,14 +146,14 @@ def _strip_markdown_fences(text: str) -> str: text = text[: text.rfind("```")] return text.strip() - def _build_system(self) -> str: + def _build_system(self, *, json_without_schema: bool) -> str: parts = [] if self._system_instructions: if isinstance(self._system_instructions, str): parts.append(self._system_instructions) else: parts.extend(self._system_instructions) - if self._json_mode and not self._response_schema: + if json_without_schema: parts.append("You must respond with valid JSON only. Do not include any text outside the JSON object.") return "\n".join(parts) diff --git a/backend/common_libs/llm/chat_models.py b/backend/common_libs/llm/chat_models.py index 5e9e13303..a7de8685d 100644 --- a/backend/common_libs/llm/chat_models.py +++ b/backend/common_libs/llm/chat_models.py @@ -1,20 +1,19 @@ import logging +from typing import Type -from vertexai.generative_models import GenerativeModel, Content, Part, GenerationConfig -from vertexai.language_models import ChatModel, ChatMessage +from pydantic import BaseModel -from common_libs.llm.models_utils import LLMConfig, LLMInput, LLMResponse, BasicLLM +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, LLMInput, LLMResponse, LLMTurn logger = logging.getLogger(__name__) -class GeminiChatLLM(BasicLLM): +class GeminiChatLLM(GeminiGenerativeLLM): """ - A wrapper for the Gemini LLM that includes retry logic with exponential backoff and jitter - for sending messages in a chat session. The Gemini LLM chat session is stateful and maintains an in-memory history. - Essentially, the chat uses the same underlying model as the generative model. - It constructs the content by using ´system_instructions´, ´llm_input´ and ´message´ as inputs, - which are then processed by the ´generate_content()´ function to generate the desired output. + A wrapper for the Gemini LLM for sending messages in a chat session. + The chat session is stateful and maintains an in-memory history: every message is sent with the history of the + conversation, and the message and the response of the model are then added to the history. """ def __init__(self, @@ -22,146 +21,18 @@ def __init__(self, system_instructions: list[str] | str, llm_input: LLMInput = None, config: LLMConfig = LLMConfig()): - super().__init__(config=config) - self._model = GenerativeModel(model_name=self._model_name, - system_instruction=system_instructions, - generation_config=GenerationConfig.from_dict(config.generation_config), - safety_settings=list(config.safety_settings) - ) - self._params = config.generation_config - history = None if llm_input is None else [Content(role=turn.role, parts=[Part.from_text(turn.content)]) for turn - in llm_input.turns] - self._chat = self._model.start_chat(history=history) - # noinspection PyProtectedMember - self._resource_name = self._model._prediction_resource_name # pylint: disable=protected-access - - async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: - response = await self._chat.send_message_async(llm_input) - return LLMResponse(text=response.text, prompt_token_count=response.usage_metadata.prompt_token_count, - response_token_count=response.usage_metadata.candidates_token_count) - - -class GeminiStatelessChatLLM(BasicLLM): - """ - A wrapper for the Gemini LLM that includes retry logic with exponential backoff and jitter - for sending messages in a stateless chat session which is created and destroyed for each message. - - """ - - def __init__(self, - *, - system_instructions: list[str] | str, - config: LLMConfig = LLMConfig()): - super().__init__(config=config) - self._model = GenerativeModel(model_name=self._model_name, - system_instruction=system_instructions, - generation_config=GenerationConfig.from_dict(config.generation_config), - safety_settings=list(config.safety_settings) - ) - self._params = config.generation_config - # noinspection PyProtectedMember - self._resource_name = self._model._prediction_resource_name # pylint: disable=protected-access - - async def stateless_generate_content(self, *, history: LLMInput, llm_input: LLMInput | str) -> LLMResponse: - """ - Stateless Chat using the Gemini LLM. - It creates a new temporary chat session for each message, - sends the message and returns the response from the model. - It also includes retry logic with exponential backoff and jitter. - :param history: The history of the conversation. - :param llm_input: The message to send as a "user". - :return: The generated response as a "model". - """ - message_history = [Content(role=turn.role, parts=[Part.from_text(turn.content)]) for turn in history.turns] - self._chat = self._model.start_chat(history=message_history) - - return await super().generate_content(llm_input) - - async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: - response = await self._chat.send_message_async(llm_input) - return LLMResponse(text=response.text, prompt_token_count=response.usage_metadata.prompt_token_count, - response_token_count=response.usage_metadata.candidates_token_count) - - -class PalmChatLLM(BasicLLM): - """ - A wrapper for the Palm2 LLM that includes retry logic with exponential backoff and jitter - for sending messages in a chat session. The Palm2 LLM chat session is stateful and maintains an in-memory history. - It constructs the content by using ´system_instructions´, ´llm_input´ and ´message´ as inputs, - which are then processed by the ´send_message()´ function to generate the desired output. - """ - - def __init__(self, - *, - system_instructions: list[str] | str, - llm_input: LLMInput = None, - config: LLMConfig = LLMConfig()): - super().__init__(config=config) - self._model = ChatModel.from_pretrained("chat-bison@002") - history = None if llm_input is None else [ChatMessage(author=turn.role, content=turn.content) for turn in - llm_input.turns] - self._params = config.generation_config - # TODO: add here examples as well? examples: list[InputOutputTextPair] = None, - self._chat = self._model.start_chat(context=system_instructions, message_history=history, - temperature=self._params['temperature']) - # noinspection PyProtectedMember - self._resource_name = self._model._endpoint_name # pylint: disable=protected-access - - async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: - contents = llm_input if type(llm_input) == str else "Current conversation:\n" + "\n".join( - [f"{turn.role}: {turn.content}" for turn in llm_input.turns]) - - response = await self._chat.send_message_async(contents, **self._params) - return LLMResponse(text=response.text, prompt_token_count= - response.raw_prediction_response.metadata['tokenMetadata']['inputTokenCount']['totalTokens'], - response_token_count= - response.raw_prediction_response.metadata['tokenMetadata']['outputTokenCount'][ - 'totalTokens']) - - -class PalmStatelessChatLLM(BasicLLM): - """ - A wrapper for the Palm LLM that includes retry logic with exponential backoff and jitter - for sending messages in a stateless chat session which is created and destroyed for each message. - - """ - - def __init__(self, - *, - system_instructions: list[str] | str, - config: LLMConfig = LLMConfig()): - super().__init__(config=config) - self._model = ChatModel.from_pretrained("chat-bison@002") - self._system_instructions = system_instructions - self._params = config.generation_config - - # noinspection PyProtectedMember - self._resource_name = self._model._endpoint_name # pylint: disable=protected-access - - async def stateless_generate_content(self, *, history: LLMInput, llm_input: LLMInput | str) -> LLMResponse: """ - Stateless Chat using the Palm LLM. - It creates a new temporary chat session for each message, - sends the message and returns the response from the model. - It also includes retry logic with exponential backoff and jitter. - :param history: The history of the conversation. - :param llm_input: The message to send as a "user". - :return: The generated response as a "model". + :param system_instructions: The system instructions of the model. + :param llm_input: The history to start the chat session with. + :param config: The configuration of the LLM. """ - message_history = None if history is None else [ChatMessage(author=turn.role, content=turn.content) for turn in - history.turns] - self._chat = self._model.start_chat(context=self._system_instructions, message_history=message_history, - temperature=self._params['temperature']) - - return await super().generate_content(llm_input) - - async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: - contents = llm_input if type(llm_input) == str else "Current conversation:\n" + "\n".join( - [f"{turn.role}: {turn.content}" for turn in llm_input.turns]) - - response = await self._chat.send_message_async(contents, **self._params) - return LLMResponse(text=response.text, prompt_token_count= - response.raw_prediction_response.metadata['tokenMetadata']['inputTokenCount']['totalTokens'], - response_token_count= - response.raw_prediction_response.metadata['tokenMetadata']['outputTokenCount'][ - 'totalTokens']) + super().__init__(system_instructions=system_instructions, config=config) + self._history: list[LLMTurn] = [] if llm_input is None else list(llm_input.turns) + + async def generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None = None) -> LLMResponse: + new_turns = [LLMTurn(role="user", content=llm_input)] if isinstance(llm_input, str) else list(llm_input.turns) + response = await super().generate_content(LLMInput(turns=self._history + new_turns), response_schema) + self._history.extend(new_turns) + self._history.append(LLMTurn(role="model", content=response.text)) + return response diff --git a/backend/common_libs/llm/generative_models.py b/backend/common_libs/llm/generative_models.py index 8fa8ea3be..65ae23c8d 100644 --- a/backend/common_libs/llm/generative_models.py +++ b/backend/common_libs/llm/generative_models.py @@ -1,97 +1,146 @@ +import asyncio import logging +from typing import Any, Type -from google.genai.types import GroundingMetadata -from vertexai.generative_models import GenerativeModel, Content, Part, GenerationConfig, Tool -from vertexai.language_models import TextGenerationModel +from google import genai +from google.genai.types import GroundingChunk, GroundingChunkWeb, GroundingMetadata +from pydantic import BaseModel -from common_libs.llm.models_utils import LLMConfig, LLMInput, LLMResponse, BasicLLM +from common_libs.llm.models_utils import LLMConfig, LLMInput, LLMResponse, BasicLLM, get_response_schema logger = logging.getLogger(__name__) +_GENERATION_PARAMETERS = ("temperature", "top_p", "max_output_tokens", "seed", "stop_sequences") +"""The generation parameters supported by the Interactions API, the other keys of a generation config are ignored.""" + +_ROLE_TO_STEP_TYPE = { + "user": "user_input", + "model": "model_output", +} + +_clients: dict[tuple[str, int], genai.Client] = {} + + +def get_genai_client(location: str) -> genai.Client: + """ + Get the google-genai client for Vertex AI in the given location. + The project and the credentials are taken from the environment (GOOGLE_APPLICATION_CREDENTIALS). + + A client is kept per location and per event loop, as its async sessions are bound to the event loop they were + created in. + """ + try: + loop_id = id(asyncio.get_running_loop()) + except RuntimeError: + loop_id = 0 + key = (location, loop_id) + client = _clients.get(key) + if client is None: + client = genai.Client(vertexai=True, location=location) + _clients[key] = client + return client + + +def llm_input_to_interactions_input(llm_input: LLMInput | str) -> str | list[dict[str, Any]]: + """ + Convert the input to the input of the Interactions API: a string, or the conversation turns as steps. + The conversation history is always sent with the input (stateless), it is not stored on the server. + + The API rejects an empty text, so an empty input is sent as a blank text, and a turn with an empty content + as a step without content (which keeps the order of the turns). + """ + if isinstance(llm_input, str): + return llm_input if llm_input else " " + steps: list[dict[str, Any]] = [] + for turn in llm_input.turns: + step: dict[str, Any] = {"type": _ROLE_TO_STEP_TYPE.get(turn.role, "user_input")} + if turn.content: + step["content"] = [{"type": "text", "text": turn.content}] + steps.append(step) + return steps + + +def response_format_for(generation_config: dict, response_schema: Type[BaseModel] | None) -> dict[str, Any] | None: + """ + The response format of the Interactions API for the structured output, or None for plain text. + """ + if response_schema is not None: + return {"type": "text", "mime_type": "application/json", "schema": response_schema.model_json_schema()} + if generation_config.get("response_mime_type") == "application/json": + return {"type": "text", "mime_type": "application/json"} + return None + + +def extract_grounding_metadata(interaction) -> GroundingMetadata | None: + """ + Build the grounding metadata of a Google Search grounded interaction: + - the web search queries, from the google search call steps. + - the sources, from the url citations of the output text. + :return: The grounding metadata, or None if the interaction did not search the web. + """ + queries: list[str] = [] + chunks: list[GroundingChunk] = [] + seen_urls: set[str] = set() + for step in getattr(interaction, "steps", None) or []: + if step.type == "google_search_call": + arguments = getattr(step, "arguments", None) + queries.extend(getattr(arguments, "queries", None) or []) + elif step.type == "model_output": + for content in step.content or []: + for annotation in getattr(content, "annotations", None) or []: + url = getattr(annotation, "url", None) + if getattr(annotation, "type", None) == "url_citation" and url and url not in seen_urls: + seen_urls.add(url) + chunks.append(GroundingChunk(web=GroundingChunkWeb(uri=url, title=getattr(annotation, "title", None)))) + if not queries and not chunks: + return None + return GroundingMetadata(web_search_queries=queries, grounding_chunks=chunks) + class GeminiGenerativeLLM(BasicLLM): """ - A wrapper for the Gemini LLM that provides retry logic with exponential backoff and jitter for generating content. + A wrapper for the Gemini LLM, on the google-genai Interactions API of Vertex AI, + that provides retry logic with exponential backoff and jitter for generating content. """ def __init__(self, *, system_instructions: list[str] | str | None = None, config: LLMConfig = LLMConfig(), - tools: list[Tool] | None = None): + tools: list[dict[str, Any]] | None = None): + """ + :param system_instructions: The system instructions of the model. + :param config: The configuration of the LLM. + :param tools: The tools the model can use, e.g. [{"type": "google_search"}] to ground the response on a web search. + """ super().__init__(config=config) + self._system_instruction = "\n".join(system_instructions) if isinstance(system_instructions, list) else system_instructions + self._safety_settings = [dict(setting) for setting in config.safety_settings] + self._location = config.location + self._tools = tools + self._resource_name = f"locations/{config.location}/publishers/google/models/{self._model_name}" - model_kwargs = dict( - model_name=self._model_name, - system_instruction=system_instructions, - generation_config=GenerationConfig.from_dict(config.generation_config), - safety_settings=list(config.safety_settings), - ) - if tools: - model_kwargs["tools"] = tools - self._model = GenerativeModel(**model_kwargs) - # noinspection PyProtectedMember - self._resource_name = self._model._prediction_resource_name # pylint: disable=protected-access - - async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: - contents = llm_input if isinstance(llm_input, str) else [ - Content(role=turn.role, parts=[Part.from_text(turn.content)]) for turn in llm_input.turns] - response = await self._model.generate_content_async(contents=contents) - grounding_metadata = self._extract_grounding_metadata(response) - return LLMResponse( - text=response.text, - prompt_token_count=response.usage_metadata.prompt_token_count, - response_token_count=response.usage_metadata.candidates_token_count, - grounding_metadata=grounding_metadata, - ) + async def internal_generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None = None) -> LLMResponse: + request: dict[str, Any] = { + "model": self._model_name, + "input": llm_input_to_interactions_input(llm_input), + "generation_config": {key: value for key, value in self.generation_config.items() if key in _GENERATION_PARAMETERS}, + "safety_settings": self._safety_settings, + "store": False, + } + if self._system_instruction: + request["system_instruction"] = self._system_instruction + response_format = response_format_for(self.generation_config, get_response_schema(self.generation_config, response_schema)) + if response_format is not None: + request["response_format"] = response_format + if self._tools: + request["tools"] = self._tools - def _extract_grounding_metadata(self, response) -> GroundingMetadata | None: - if not response.candidates: - return None - candidate = response.candidates[0] - gm = getattr(candidate, "grounding_metadata", None) - if gm is None: - return None - - raw_dict: dict - if isinstance(gm, dict): - raw_dict = gm - elif hasattr(gm, "to_dict"): - raw_dict = gm.to_dict() - else: - try: - from google.protobuf.json_format import MessageToDict - raw_dict = MessageToDict(gm._pb) if hasattr(gm, "_pb") else MessageToDict(gm) - except Exception: - return None - - try: - return GroundingMetadata(**raw_dict) - except Exception: - return None - - -class PalmTextGenerativeLLM(BasicLLM): - """ - A wrapper for the Palm2 Text generation model that provides retry logic with exponential backoff and jitter for - generating content. - """ - - def __init__(self, *, system_instructions: list[str] | str | None = None, config: LLMConfig = LLMConfig()): - super().__init__(config=config) - self._model = TextGenerationModel.from_pretrained("text-bison@002") - self._params = config.generation_config - self._system_instructions = system_instructions - - async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: - contents = llm_input if isinstance(llm_input, str) else "Current conversation:\n" + "\n".join( - [f"{turn.role}: {turn.content}" for turn in llm_input.turns]) - prompt = contents - if self._system_instructions: - prompt = self._system_instructions + "\n" + contents - response = await self._model.predict_async(prompt=prompt, **self._params) - token_meta_data = response.raw_prediction_response.metadata['tokenMetadata'] + interaction = await get_genai_client(self._location).aio.interactions.create(**request) + usage = interaction.usage return LLMResponse( - text=response.text, - prompt_token_count=token_meta_data['inputTokenCount']['totalTokens'], - response_token_count=token_meta_data['outputTokenCount']['totalTokens'] + text=interaction.output_text or "", + prompt_token_count=(usage.total_input_tokens or 0) if usage else 0, + response_token_count=(usage.total_output_tokens or 0) if usage else 0, + grounding_metadata=extract_grounding_metadata(interaction) if self._tools else None, ) diff --git a/backend/common_libs/llm/local_llm.py b/backend/common_libs/llm/local_llm.py index 6737be222..1cff4644a 100644 --- a/backend/common_libs/llm/local_llm.py +++ b/backend/common_libs/llm/local_llm.py @@ -1,10 +1,11 @@ import logging import os +from typing import Type import httpx from pydantic import BaseModel -from common_libs.llm.models_utils import LLM, LLMInput, LLMResponse +from common_libs.llm.models_utils import LLM, LLMInput, LLMResponse, get_response_schema from common_libs.retry import RetryConfigWithExponentialBackOff, DEFAULT_RETRY_CONFIG_WITH_EXP_BACKOFF, Retry _USE_THINKING = os.getenv("LOCAL_LLM_THINK", "").lower() in ("1", "true", "yes") @@ -27,8 +28,7 @@ class LocalOpenAICompatibleLLM(LLM): Uses ollama's native API (not /v1/chat/completions) so that think:false is respected, disabling qwen3's slow chain-of-thought reasoning mode. - Extends LLM directly rather than BasicLLM to avoid triggering vertexai.init(). - Does not include traced_observation tracing. + Extends LLM directly rather than BasicLLM. Does not include traced_observation tracing. """ def __init__(self, *, @@ -42,13 +42,20 @@ def __init__(self, *, self._system_instructions = system_instructions self._resource_name = f"{config.base_url}/api/chat" - async def generate_content(self, llm_input: LLMInput | str) -> LLMResponse: + @property + def generation_config(self) -> dict: + """The generation parameters, read at call time.""" + return self._generation_config + + async def generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None = None) -> LLMResponse: async def _call() -> LLMResponse: - return await self._internal_generate_content(llm_input) + return await self._internal_generate_content(llm_input, get_response_schema(self._generation_config, response_schema)) return await Retry[str].call_with_exponential_backoff(callback=_call, logger=self.logger) - async def _internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: + async def _internal_generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None) -> LLMResponse: messages = self._build_messages(llm_input) options = {k: v for k, v in self._generation_config.items() if k in ("temperature", "top_p", "num_predict", "frequency_penalty")} @@ -63,9 +70,11 @@ async def _internal_generate_content(self, llm_input: LLMInput | str) -> LLMResp "think": _USE_THINKING, "options": options, } - # Gemini uses response_mime_type:"application/json" to enforce JSON output; - # ollama uses a top-level "format":"json" field for the same purpose. - if self._generation_config.get("response_mime_type") == "application/json": + # ollama enforces the structured output with a top-level "format" field: the JSON schema of the response, + # or "json" for any JSON object. + if response_schema is not None: + payload["format"] = response_schema.model_json_schema() + elif self._generation_config.get("response_mime_type") == "application/json": payload["format"] = "json" async with httpx.AsyncClient() as client: response = await client.post( diff --git a/backend/common_libs/llm/models_utils.py b/backend/common_libs/llm/models_utils.py index c1ccfc985..6fffa0185 100644 --- a/backend/common_libs/llm/models_utils.py +++ b/backend/common_libs/llm/models_utils.py @@ -1,15 +1,12 @@ import logging import os from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Type -import vertexai from dotenv import load_dotenv from google.genai.types import GroundingMetadata from pydantic import BaseModel -from vertexai.generative_models import HarmCategory, HarmBlockThreshold, SafetySetting - from app.agent.config import GEMINI_DEFAULT_MODELS, ModelTier from common_libs.observability.tracing import traced_observation, update_observation from common_libs.retry import RetryConfigWithExponentialBackOff, DEFAULT_RETRY_CONFIG_WITH_EXP_BACKOFF, Retry @@ -22,7 +19,7 @@ # Load environment variables from .env file load_dotenv() -# Initialize the default region for the Vertex AI generative-AI client. +# The default Vertex AI location of the generative-AI client. # Embeddings use a separate region (VERTEX_API_EMBEDDINGS_REGION) — see GoogleEmbeddingService. DEFAULT_VERTEX_API_GEN_AI_REGION = os.getenv("VERTEX_API_GEN_AI_REGION") @@ -34,43 +31,36 @@ DEFAULT_GENERATION_CONFIG = { "temperature": 0.1, - "candidate_count": 1, "top_p": 0.95, } ZERO_TEMPERATURE_GENERATION_CONFIG = { "temperature": 0.0, - "candidate_count": 1, "top_p": 0.95, } LOW_TEMPERATURE_GENERATION_CONFIG = { "temperature": 0.1, - "candidate_count": 1, "top_p": 0.95, } MODERATE_TEMPERATURE_GENERATION_CONFIG = { "temperature": 0.25, - "candidate_count": 1, "top_p": 0.95, } MEDIUM_TEMPERATURE_GENERATION_CONFIG = { "temperature": 0.5, - "candidate_count": 1, "top_p": 0.95, } HIGH_TEMPERATURE_GENERATION_CONFIG = { "temperature": 1.0, - "candidate_count": 1, "top_p": 0.95, } CRAZY_TEMPERATURE_GENERATION_CONFIG = { "temperature": 2.0, - "candidate_count": 1, "top_p": 0.95, } @@ -78,32 +68,15 @@ "response_mime_type": "application/json", } +_HARM_CATEGORIES = ("dangerous_content", "sexually_explicit", "harassment", "hate_speech") +"""The harm categories of the google-genai Interactions API safety settings.""" + # Todo(apostolos): Specify the safety settings after we have some relevant tests -DEFAULT_SAFETY_SETTINGS: frozenset[SafetySetting] = frozenset([ - SafetySetting(category=SafetySetting.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, - threshold=SafetySetting.HarmBlockThreshold.BLOCK_ONLY_HIGH), - SafetySetting(category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, - threshold=HarmBlockThreshold.BLOCK_ONLY_HIGH), - SafetySetting(category=HarmCategory.HARM_CATEGORY_HARASSMENT, - threshold=HarmBlockThreshold.BLOCK_ONLY_HIGH), - SafetySetting(category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, - threshold=HarmBlockThreshold.BLOCK_ONLY_HIGH), - SafetySetting(category=HarmCategory.HARM_CATEGORY_UNSPECIFIED, - threshold=HarmBlockThreshold.BLOCK_ONLY_HIGH), -]) - -SAFETY_OFF_SETTINGS: frozenset[SafetySetting] = frozenset([ - SafetySetting(category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, - threshold=HarmBlockThreshold.BLOCK_NONE), - SafetySetting(category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, - threshold=HarmBlockThreshold.BLOCK_NONE), - SafetySetting(category=HarmCategory.HARM_CATEGORY_HARASSMENT, - threshold=HarmBlockThreshold.BLOCK_NONE), - SafetySetting(category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, - threshold=HarmBlockThreshold.BLOCK_NONE), - SafetySetting(category=HarmCategory.HARM_CATEGORY_UNSPECIFIED, - threshold=HarmBlockThreshold.BLOCK_NONE), -]) +DEFAULT_SAFETY_SETTINGS: tuple[dict[str, str], ...] = tuple( + {"type": category, "threshold": "block_only_high"} for category in _HARM_CATEGORIES) + +SAFETY_OFF_SETTINGS: tuple[dict[str, str], ...] = tuple( + {"type": category, "threshold": "block_none"} for category in _HARM_CATEGORIES) def get_config_variation( @@ -146,8 +119,7 @@ def get_config_variation( return { "temperature": temperature, "top_p": top_p, - "candidate_count": 1, - } + } _NON_GEMINI_DEFAULT_MODELS: dict[str, str] = { @@ -221,7 +193,11 @@ class LLMConfig(BaseModel): """ location: str = DEFAULT_VERTEX_API_GEN_AI_REGION generation_config: dict = DEFAULT_GENERATION_CONFIG - safety_settings: frozenset[SafetySetting] = DEFAULT_SAFETY_SETTINGS + """ + The generation parameters (temperature, top_p, max_output_tokens, ...). It may also carry the structured output + of the response, see `common_libs.llm.schema_builder.with_response_schema`. + """ + safety_settings: tuple[dict[str, str], ...] = DEFAULT_SAFETY_SETTINGS retry_config: RetryConfigWithExponentialBackOff = DEFAULT_RETRY_CONFIG_WITH_EXP_BACKOFF class Config: @@ -230,7 +206,7 @@ class Config: """ arbitrary_types_allowed = True """ - Allow arbitrary types for the model as the generation_config is a custom class. + Allow arbitrary types, as the generation_config may carry the pydantic class of the response schema. """ protected_namespaces = () """ @@ -273,25 +249,29 @@ class LLM(ABC): """ @abstractmethod - async def generate_content(self, llm_input: LLMInput | str) -> LLMResponse: + async def generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None = None) -> LLMResponse: """ - Wrapper for the asynchronous `generate_content` method of `GenerativeModel`, `predict` of 'TextGenerationModel' - and 'send_message' of 'ChatModel' and 'GenerativeModel'. - that provides retry logic with exponential backoff. + Generate a response for the input, with retry logic with exponential backoff. :param llm_input: Either a LLMInput object for chat, or a string for general generative content. - :return: The generated response as a "model" with . + :param response_schema: The pydantic model the response must follow (structured output). + When omitted, the response schema of the LLM's generation config is used, if any. + :return: The generated response as a "model". """ raise NotImplementedError() -_llm_initialized: bool = False -def _init_once(location: str, _logger: logging.Logger): - global _llm_initialized - if _llm_initialized: - return - _logger.info("Initializing VertexAI client with location: %s", location) - vertexai.init(location=location) - _llm_initialized = True +def get_response_schema(generation_config: dict, response_schema: Type[BaseModel] | None = None) -> Type[BaseModel] | None: + """ + Get the pydantic model of the structured output: the one given for the call, else the one of the generation config. + """ + if response_schema is not None: + return response_schema + return generation_config.get("response_schema") + + +_TRACEABLE_GENERATION_PARAMETERS = ("temperature", "top_p", "max_output_tokens", "seed", "stop_sequences") +"""The generation parameters reported to the tracing backend.""" def llm_input_to_traceable(llm_input: LLMInput | str) -> str | list[dict]: @@ -308,27 +288,30 @@ def llm_input_to_traceable(llm_input: LLMInput | str) -> str | list[dict]: class BasicLLM(LLM): def __init__(self, *, config: LLMConfig = LLMConfig()): - # Before constructing the llm model, we need to initialize the VertexAI client - # as the init function may have been called in another module with different parameters self.logger = logging.getLogger(self.__class__.__name__) - _init_once(location=config.location, _logger=self.logger) self._retry_config = config.retry_config - self._model = None - self._chat = None self._resource_name = "" - # Kept so that the observability layer can report which model and parameters produced a - # generation. The concrete wrappers hand the same values to the underlying Vertex model. self._model_name = config.language_model_name or resolve_model_name(tier=config.model_tier, provider="gemini") - self._generation_config = config.generation_config + # A copy, as the callers may adjust it between attempts (see `generation_config`). + self._generation_config = dict(config.generation_config) + + @property + def generation_config(self) -> dict: + """ + The generation parameters used for every call. It is read at call time, so a caller can adjust it between + attempts, e.g. to raise the temperature to escape a repetition trap. + """ + return self._generation_config - async def generate_content(self, llm_input: LLMInput | str) -> LLMResponse: + async def generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None = None) -> LLMResponse: async def _generate_content() -> LLMResponse: try: logger.debug("Generating content with resource:%s", self._resource_name) - return await self.internal_generate_content(llm_input) + return await self.internal_generate_content(llm_input, response_schema) except Exception as e: logger.error("An error occurred while generating content with resource:%s", @@ -342,7 +325,8 @@ async def _generate_content() -> LLMResponse: as_type="generation", input=llm_input_to_traceable(llm_input), model=self._model_name, - model_parameters=self._generation_config, + model_parameters={key: value for key, value in self._generation_config.items() + if key in _TRACEABLE_GENERATION_PARAMETERS}, metadata={"resource_name": self._resource_name}, ) as generation: try: @@ -363,5 +347,6 @@ async def _generate_content() -> LLMResponse: return response @abstractmethod - async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMResponse: + async def internal_generate_content(self, llm_input: LLMInput | str, + response_schema: Type[BaseModel] | None = None) -> LLMResponse: raise NotImplementedError() diff --git a/backend/common_libs/llm/schema_builder.py b/backend/common_libs/llm/schema_builder.py index c52bdb1ac..2dce8e1b2 100644 --- a/backend/common_libs/llm/schema_builder.py +++ b/backend/common_libs/llm/schema_builder.py @@ -3,124 +3,16 @@ from pydantic import BaseModel -def resolve_refs(definitions, node: dict) -> Any: +def with_response_schema(pydantic_class: Type[BaseModel]) -> Dict[str, Any]: """ - Recursively resolves $ref in the node using definitions from defs. - """ - - if isinstance(node, dict): - if "$ref" in node: - ref_name = node["$ref"].split("/")[-1] - if ref_name in definitions: - # Resolve the reference and merge any other keys (though usually $ref is standalone) - # We recursively clean the resolved definition - resolved = resolve_refs(definitions, definitions[ref_name]) - return resolved - - new_node = {} - for key, value in node.items(): - new_node[key] = resolve_refs(definitions, value) - - return new_node - elif isinstance(node, list): - return [resolve_refs(definitions, item) for item in node] - else: - return node - - -def clean_node(node: dict) -> dict | list: - if isinstance(node, dict): - if "anyOf" in node: - # Target schema does NOT support anyOf/oneOf. - # We must flatten it. - # Strategy: - # 1. Check for the 'null' type to set nullable=True. - # 2. Pick the first non-null type. - # 3. If multiple non-null types exist, we can't represent it fully. - # For now, picking the first non-null is the safest bet for Optional[T]. - - options = node["anyOf"] - nullable = False - selected_option = None - - for option in options: - if option.get("type") == "null": - nullable = True - else: - if selected_option is None: - selected_option = option - - if selected_option: - # Recursively clean the selected option - cleaned = clean_node(selected_option) - if nullable: - cleaned["nullable"] = True - return cleaned - else: - return {"type_": "TYPE_UNSPECIFIED"} - - # --- STANDARD CLEANING --- - new_node = {} - for k, v in node.items(): - # Keys to exclude - if k in ["$defs", "definitions", "additionalProperties"]: - continue - - # Keys to keep/transform - if k == "type": - # Rename 'type' -> 'type_' and uppercase - new_node["type_"] = v.upper() if isinstance(v, str) else v - elif k == "format": - new_node["format_"] = v - elif k == "description": - new_node["description"] = v - elif k == "enum": - new_node["enum"] = v - elif k == "items": - new_node["items"] = clean_node(v) - elif k == "properties": - new_node["properties"] = {pk: clean_node(pv) for pk, pv in v.items()} - elif k == "required": - new_node["required"] = v - else: - # Keep other keys recursively cleaned (e.g. minLength, etc.) - new_node[k] = clean_node(v) - return new_node - - elif isinstance(node, list): - return [clean_node(item) for item in node] + The generation config entries that make the LLM respond with a JSON object that follows the pydantic class + (structured output). - return node - - -def build_request_schema(pydantic_class: Type[BaseModel]) -> Dict[str, Any]: - """ - Converts Pydantic class schema to the Google AI Platform open api specification -- a subset of OpenAPI 3.0 -- - ref: https://github.com/googleapis/python-aiplatform/blob/main/google/cloud/aiplatform_v1/types/openapi.py#L64 - - - Steps: - 1. Resolve definitions ($defs/$ref) - 2. Clean the schema for Vertex AI + The pydantic class itself is carried, not a converted schema: each LLM provider turns it into the schema format + of its API (e.g. `pydantic_class.model_json_schema()` for the google-genai Interactions API). The descriptions of + the fields (`Field(description=...)`) are part of the schema, so they guide the LLM on what to put in each field. """ - - # 1. convert the Pydantic model to OpenAPI 3.0 JSON Schema - schema = pydantic_class.model_json_schema() - - # 2. since pydantic uses $defs for definitions, extract them using this key - definitions = schema.get("$defs", {}) - - # 3. Resolve all the references. - resolved_schema = resolve_refs(definitions, schema) - - # clean the node for the target schema - cleaned_schema = clean_node(resolved_schema) - - return cleaned_schema - - -def with_response_schema(pydantic_class: Type[BaseModel]): return { "response_mime_type": "application/json", - "response_schema": build_request_schema(pydantic_class) + "response_schema": pydantic_class, } diff --git a/backend/common_libs/llm/test_generative_models.py b/backend/common_libs/llm/test_generative_models.py new file mode 100644 index 000000000..7090ea4df --- /dev/null +++ b/backend/common_libs/llm/test_generative_models.py @@ -0,0 +1,244 @@ +""" +Tests for the Gemini LLM on the google-genai Interactions API. +""" +from types import SimpleNamespace +from typing import Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import BaseModel, Field + +from common_libs.llm.generative_models import GeminiGenerativeLLM, extract_grounding_metadata, \ + llm_input_to_interactions_input, response_format_for +from common_libs.llm.models_utils import LLMConfig, LLMInput, LLMTurn, DEFAULT_SAFETY_SETTINGS +from common_libs.llm.schema_builder import with_response_schema + + +class _Response(BaseModel): + reasoning: str = Field(description="Why") + answer: Optional[int] = Field(default=None, description="The answer") + + +def _fake_interaction(*, output_text: str = "foo", steps: list | None = None, usage=None): + return SimpleNamespace( + output_text=output_text, + steps=steps or [], + usage=usage if usage is not None else SimpleNamespace(total_input_tokens=11, total_output_tokens=22), + ) + + +def _patch_client(given_interaction) -> tuple: + """Patch the google-genai client, returning the patcher and the mock of interactions.create.""" + mock_create = AsyncMock(return_value=given_interaction) + mock_client = MagicMock() + mock_client.aio.interactions.create = mock_create + return patch("common_libs.llm.generative_models.get_genai_client", return_value=mock_client), mock_create + + +class TestLLMInputToInteractionsInput: + def test_string_input_is_kept(self): + # GIVEN a string input + given_input = "foo" + + # WHEN it is converted + actual_input = llm_input_to_interactions_input(given_input) + + # THEN expect the same string + assert actual_input == given_input + + def test_turns_are_converted_to_steps(self): + # GIVEN a conversation with user and model turns + given_input = LLMInput(turns=[LLMTurn(role="user", content="hi"), LLMTurn(role="model", content="hello")]) + + # WHEN it is converted + actual_input = llm_input_to_interactions_input(given_input) + + # THEN expect a user input step and a model output step, in order + assert actual_input == [ + {"type": "user_input", "content": [{"type": "text", "text": "hi"}]}, + {"type": "model_output", "content": [{"type": "text", "text": "hello"}]}, + ] + + + def test_empty_string_input_is_sent_as_a_blank_text(self): + # GIVEN an empty string input + given_input = "" + + # WHEN it is converted + actual_input = llm_input_to_interactions_input(given_input) + + # THEN expect a blank text, as the API rejects an empty text + assert actual_input == " " + + def test_empty_turn_is_sent_as_a_step_without_content(self): + # GIVEN a conversation whose first user turn is empty + given_input = LLMInput(turns=[LLMTurn(role="user", content=""), LLMTurn(role="model", content="hello")]) + + # WHEN it is converted + actual_input = llm_input_to_interactions_input(given_input) + + # THEN expect the empty turn as a step without content, as the API rejects an empty text + assert actual_input == [ + {"type": "user_input"}, + {"type": "model_output", "content": [{"type": "text", "text": "hello"}]}, + ] + + +class TestResponseFormatFor: + def test_schema_of_the_pydantic_class(self): + # GIVEN a response schema + # WHEN the response format is built + actual_format = response_format_for({}, _Response) + + # THEN expect a JSON response format with the JSON schema of the class, including the field descriptions + assert actual_format == {"type": "text", "mime_type": "application/json", "schema": _Response.model_json_schema()} + assert actual_format["schema"]["properties"]["answer"]["description"] == "The answer" + + def test_json_without_schema(self): + # GIVEN a JSON generation config without a schema + # WHEN the response format is built + actual_format = response_format_for({"response_mime_type": "application/json"}, None) + + # THEN expect a JSON response format without a schema + assert actual_format == {"type": "text", "mime_type": "application/json"} + + def test_plain_text(self): + # GIVEN a generation config without a response mime type + # WHEN the response format is built + actual_format = response_format_for({"temperature": 0.1}, None) + + # THEN expect no response format + assert actual_format is None + + +class TestExtractGroundingMetadata: + def test_queries_and_cited_sources(self): + # GIVEN an interaction that searched the web and cited sources (one of them twice) + given_annotation = SimpleNamespace(type="url_citation", url="https://foo", title="foo.org") + given_interaction = _fake_interaction(steps=[ + SimpleNamespace(type="google_search_call", arguments=SimpleNamespace(queries=["q1", "q2"])), + SimpleNamespace(type="google_search_result", result=[]), + SimpleNamespace(type="model_output", content=[ + SimpleNamespace(type="text", annotations=[given_annotation, given_annotation, + SimpleNamespace(type="url_citation", url="https://bar", title=None)]) + ]), + ]) + + # WHEN the grounding metadata is extracted + actual_metadata = extract_grounding_metadata(given_interaction) + + # THEN expect the search queries + assert actual_metadata.web_search_queries == ["q1", "q2"] + # AND each cited source once, in order + assert [(chunk.web.uri, chunk.web.title) for chunk in actual_metadata.grounding_chunks] == \ + [("https://foo", "foo.org"), ("https://bar", None)] + + def test_none_without_search(self): + # GIVEN an interaction that did not search the web + given_interaction = _fake_interaction(steps=[SimpleNamespace(type="model_output", content=[])]) + + # WHEN the grounding metadata is extracted + actual_metadata = extract_grounding_metadata(given_interaction) + + # THEN expect None + assert actual_metadata is None + + +class TestGeminiGenerativeLLM: + @pytest.mark.asyncio + async def test_sends_the_request_and_returns_the_response(self, setup_application_config): + # GIVEN an LLM with system instructions and a generation config with an unsupported parameter + given_llm = GeminiGenerativeLLM( + system_instructions=["foo", "bar"], + config=LLMConfig(language_model_name="given-model", + generation_config={"temperature": 0.3, "top_p": 0.9, "candidate_count": 1})) + # AND the API returns an interaction + given_interaction = _fake_interaction(output_text='{"reasoning": "r", "answer": 1}') + client_patch, mock_create = _patch_client(given_interaction) + + # WHEN content is generated with a response schema + with client_patch: + actual_response = await given_llm.generate_content("baz", response_schema=_Response) + + # THEN expect the request to the Interactions API + actual_request = mock_create.call_args.kwargs + assert actual_request["model"] == "given-model" + assert actual_request["input"] == "baz" + assert actual_request["system_instruction"] == "foo\nbar" + # AND only the supported generation parameters + assert actual_request["generation_config"] == {"temperature": 0.3, "top_p": 0.9} + # AND the structured output of the response schema + assert actual_request["response_format"]["schema"] == _Response.model_json_schema() + # AND the safety settings + assert actual_request["safety_settings"] == [dict(setting) for setting in DEFAULT_SAFETY_SETTINGS] + # AND the interaction not to be stored, as the history is sent with every request + assert actual_request["store"] is False + # AND no tools + assert "tools" not in actual_request + # AND expect the text and the token counts of the response + assert actual_response.text == given_interaction.output_text + assert actual_response.prompt_token_count == 11 + assert actual_response.response_token_count == 22 + assert actual_response.grounding_metadata is None + + @pytest.mark.asyncio + async def test_uses_the_response_schema_of_the_generation_config(self, setup_application_config): + # GIVEN an LLM with a response schema in its generation config + given_llm = GeminiGenerativeLLM(config=LLMConfig(generation_config=with_response_schema(_Response))) + client_patch, mock_create = _patch_client(_fake_interaction()) + + # WHEN content is generated without a response schema for the call + with client_patch: + await given_llm.generate_content("foo") + + # THEN expect the structured output of the generation config's response schema + assert mock_create.call_args.kwargs["response_format"]["schema"] == _Response.model_json_schema() + # AND no system instruction, as none was given + assert "system_instruction" not in mock_create.call_args.kwargs + + @pytest.mark.asyncio + async def test_grounded_search_returns_the_grounding_metadata(self, setup_application_config): + # GIVEN an LLM with the google search tool + given_llm = GeminiGenerativeLLM(tools=[{"type": "google_search"}]) + # AND the API returns an interaction that searched the web + client_patch, mock_create = _patch_client(_fake_interaction(steps=[ + SimpleNamespace(type="google_search_call", arguments=SimpleNamespace(queries=["foo"])), + ])) + + # WHEN content is generated + with client_patch: + actual_response = await given_llm.generate_content("foo") + + # THEN expect the tools to be sent + assert mock_create.call_args.kwargs["tools"] == [{"type": "google_search"}] + # AND the grounding metadata of the search + assert actual_response.grounding_metadata.web_search_queries == ["foo"] + + @pytest.mark.asyncio + async def test_generation_config_is_read_at_call_time(self, setup_application_config): + # GIVEN an LLM + given_llm = GeminiGenerativeLLM(config=LLMConfig(generation_config={"temperature": 0.1})) + client_patch, mock_create = _patch_client(_fake_interaction()) + + # WHEN the temperature is changed before content is generated + given_llm.generation_config["temperature"] = 0.7 + with client_patch: + await given_llm.generate_content("foo") + + # THEN expect the changed temperature to be sent + assert mock_create.call_args.kwargs["generation_config"] == {"temperature": 0.7} + + @pytest.mark.asyncio + async def test_missing_usage_and_text(self, setup_application_config): + # GIVEN the API returns an interaction without usage and output text + given_llm = GeminiGenerativeLLM() + client_patch, _ = _patch_client(SimpleNamespace(output_text=None, steps=[], usage=None)) + + # WHEN content is generated + with client_patch: + actual_response = await given_llm.generate_content("foo") + + # THEN expect an empty text and zero token counts + assert actual_response.text == "" + assert actual_response.prompt_token_count == 0 + assert actual_response.response_token_count == 0 diff --git a/backend/common_libs/llm/test_model_resolution.py b/backend/common_libs/llm/test_model_resolution.py index 8fda77b16..c4ebf7d64 100644 --- a/backend/common_libs/llm/test_model_resolution.py +++ b/backend/common_libs/llm/test_model_resolution.py @@ -1,5 +1,6 @@ +from types import SimpleNamespace from typing import Optional -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -218,18 +219,23 @@ class TestGeminiGenerativeLLM: (LLMConfig(model_tier=ModelTier.DEEP_REASONING, language_model_name="given-explicit-model"), "given-explicit-model"), ]) - def test_gemini_model_is_resolved_on_creation(self, setup_application_config, - given_config: LLMConfig, expected_model: str): + @pytest.mark.asyncio + async def test_gemini_model_is_resolved_on_creation(self, setup_application_config, + given_config: LLMConfig, expected_model: str): # GIVEN the gemini provider is configured with only a deep reasoning model _set_llm_config(setup_application_config, provider="gemini", deep_reasoning_model="given-deep-reasoning") # WHEN a gemini llm is created for the given config - with patch("common_libs.llm.models_utils._init_once"), \ - patch("common_libs.llm.generative_models.GenerativeModel") as mock_generative_model: - actual_llm = GeminiGenerativeLLM(config=given_config) - - # THEN expect the underlying model to be created with the resolved model name - assert mock_generative_model.call_args.kwargs["model_name"] == expected_model + actual_llm = GeminiGenerativeLLM(config=given_config) + # AND content is generated + mock_client = MagicMock() + mock_client.aio.interactions.create = AsyncMock( + return_value=SimpleNamespace(output_text="foo", steps=[], usage=None)) + with patch("common_libs.llm.generative_models.get_genai_client", return_value=mock_client): + await actual_llm.generate_content("foo") + + # THEN expect the request to be sent to the resolved model + assert mock_client.aio.interactions.create.call_args.kwargs["model"] == expected_model # AND the llm to report the same model name (used for tracing) # noinspection PyProtectedMember assert actual_llm._model_name == expected_model # pylint: disable=protected-access diff --git a/backend/common_libs/llm/utils.py b/backend/common_libs/llm/utils.py deleted file mode 100644 index 98080c7bb..000000000 --- a/backend/common_libs/llm/utils.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging - -from google.genai.types import GroundingMetadata - -logger = logging.getLogger(__name__) - - -def extract_grounding_metadata_from_genai_response(response) -> GroundingMetadata | None: - """ - Extract grounding metadata from a google-genai SDK response. - - Args: - response: Response object from genai SDK's generate_content - - Returns: - GroundingMetadata if found, None otherwise - """ - if not hasattr(response, "candidates") or not response.candidates: - return None - - candidate = response.candidates[0] - if not hasattr(candidate, "grounding_metadata"): - return None - - gm = candidate.grounding_metadata - if not gm: - return None - - raw_dict: dict - if isinstance(gm, dict): - raw_dict = gm - elif hasattr(gm, "model_dump"): - raw_dict = gm.model_dump() - elif hasattr(gm, "__dict__"): - raw_dict = gm.__dict__ - else: - return None - - if not isinstance(raw_dict, dict): - return None - - for key in list(raw_dict.keys()): - if raw_dict[key] is None: - if key in ["web_search_queries", "webSearchQueries", "grounding_chunks", "groundingChunks", "grounding_supports", "groundingSupports"]: - raw_dict[key] = [] - else: - del raw_dict[key] - - try: - return GroundingMetadata(**raw_dict) - except Exception as e: - logger.debug("Failed to parse grounding metadata: %s. Raw dict: %s", e, raw_dict) - return None \ No newline at end of file diff --git a/backend/common_libs/retry.py b/backend/common_libs/retry.py index 99ea8e0a5..9fbdf93cf 100644 --- a/backend/common_libs/retry.py +++ b/backend/common_libs/retry.py @@ -3,6 +3,7 @@ import random from typing import TypeVar, Generic, Callable, Awaitable, Tuple, Optional +import httpx from google.api_core.exceptions import ServerError, TooManyRequests, ResourceExhausted from pydantic import BaseModel @@ -45,6 +46,29 @@ class RetryConfigWithExponentialBackOff(BaseModel): DEFAULT_RETRY_CONFIG_WITH_EXP_BACKOFF = RetryConfigWithExponentialBackOff() +_RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504}) +"""The HTTP status codes of transient errors: timeouts, rate limits and server errors.""" + +_RETRYABLE_ERROR_NAMES = frozenset({"APIConnectionError", "APITimeoutError"}) +"""The names of the google-genai Interactions API errors for connection failures and timeouts.""" + + +def is_retryable_error(e: BaseException) -> bool: + """ + Whether the error is transient, so the operation is worth retrying: + - the google.api_core rate limit and server errors. + - the google-genai errors with a timeout, rate limit or server error status code + (`code` for the models API, `status_code` for the Interactions API). + - connection failures and timeouts. + """ + if isinstance(e, (TooManyRequests, ResourceExhausted, ServerError, httpx.TransportError)): + return True + if type(e).__name__ in _RETRYABLE_ERROR_NAMES: + return True + status_code = getattr(e, "status_code", None) or getattr(e, "code", None) + return isinstance(status_code, int) and status_code in _RETRYABLE_STATUS_CODES + + class RetryLimitExceededError(Exception): """ Exception for when the maximum number of retry attempts is exceeded. @@ -77,16 +101,16 @@ async def call_with_exponential_backoff(*, result: T = await callback() logger.debug("Attempt %d to call %s succeeded", attempt + 1, callback.__name__) return result - except (TooManyRequests, ResourceExhausted, ServerError) as e: + except Exception as e: # pylint: disable=broad-except + if not is_retryable_error(e): + # rethrow the errors that are not transient + logger.error("An error occurred", exc_info=True) + raise e logger.warning("Attempt %d to call %s failed, error: %s, retrying in %.2f seconds", attempt + 1, callback.__name__, e, wait_time) await asyncio.sleep(wait_time) wait_time = Retry._get_random_wait_time(wait_time, retry_config.base_backoff_factor, retry_config.jitter) - # rethrow other exceptions - except Exception as e: - logger.error("An error occurred", exc_info=True) - raise e raise RetryLimitExceededError(retries=retry_config.max_retries) @staticmethod diff --git a/backend/common_libs/test_retry.py b/backend/common_libs/test_retry.py new file mode 100644 index 000000000..34242bbba --- /dev/null +++ b/backend/common_libs/test_retry.py @@ -0,0 +1,57 @@ +""" +Tests for the classification of the errors worth retrying. +""" +import httpx +import pytest +from google.api_core.exceptions import TooManyRequests, ServiceUnavailable, BadRequest +from google.genai.errors import ClientError, ServerError + +from common_libs.retry import is_retryable_error + + +class _StatusCodeError(Exception): + """An error with a status code, like the google-genai Interactions API errors.""" + + def __init__(self, status_code: int): + super().__init__(f"status {status_code}") + self.status_code = status_code + + +class APIConnectionError(Exception): + """An error named like the google-genai Interactions API connection error.""" + + +class TestIsRetryableError: + @pytest.mark.parametrize("given_error", [ + TooManyRequests("foo"), + ServiceUnavailable("foo"), + ClientError(429, {"error": {"message": "foo"}}), + ServerError(503, {"error": {"message": "foo"}}), + _StatusCodeError(429), + _StatusCodeError(500), + _StatusCodeError(504), + APIConnectionError("foo"), + httpx.ConnectError("foo"), + ], ids=["api_core 429", "api_core 503", "genai 429", "genai 503", "interactions 429", "interactions 500", + "interactions 504", "interactions connection", "httpx connection"]) + def test_transient_errors_are_retryable(self, given_error: BaseException): + # GIVEN a transient error + # WHEN it is classified + actual_retryable = is_retryable_error(given_error) + + # THEN expect it to be retryable + assert actual_retryable is True + + @pytest.mark.parametrize("given_error", [ + BadRequest("foo"), + ClientError(400, {"error": {"message": "foo"}}), + _StatusCodeError(404), + ValueError("foo"), + ], ids=["api_core 400", "genai 400", "interactions 404", "value error"]) + def test_other_errors_are_not_retryable(self, given_error: BaseException): + # GIVEN an error that is not transient + # WHEN it is classified + actual_retryable = is_retryable_error(given_error) + + # THEN expect it not to be retryable + assert actual_retryable is False diff --git a/backend/evaluation_tests/test_schema_builder.py b/backend/evaluation_tests/test_schema_builder.py deleted file mode 100644 index a9108f5bc..000000000 --- a/backend/evaluation_tests/test_schema_builder.py +++ /dev/null @@ -1,279 +0,0 @@ -from enum import Enum -from typing import List, Union, Optional - -import pytest -from google.cloud.aiplatform_v1beta1.types import Schema -from pydantic import BaseModel, Field - -from app.agent.agent_director._llm_router import RouterModelResponse -from app.agent.collect_experiences_agent._dataextraction_llm import _CollectedExperience -from app.agent.collect_experiences_agent.data_extraction_llm._entity_extraction_tool import \ - _LLMOutput as EntityExtractionToolOutput -from app.agent.collect_experiences_agent.data_extraction_llm._intent_analyzer_tool import \ - _LLMOutput as IntentAnalyzerToolOutput -from app.agent.collect_experiences_agent.data_extraction_llm._temporal_classifier_tool import \ - _LLMOutput as TemporalClassifierToolOutput -from app.agent.experience._experience_summarizer import ExperienceSummarizerResponse -from app.agent.linking_and_ranking_pipeline.cluster_responsibilities_tool.cluster_responsibilties_tool import \ - ClusterResponsibilitiesLLMResponse -from app.agent.linking_and_ranking_pipeline.infer_occupation_tool._contextualization_llm import \ - _ContextualizationLLMOutput -from app.agent.linking_and_ranking_pipeline.pick_top_skills_tool import _PickTopSkillsLLMOutput -from app.agent.linking_and_ranking_pipeline.relevant_entities_classifier_llm import _RelevantEntityClassifierLLMOutput -from app.agent.simple_llm_agent.llm_response import ModelResponse -from app.agent.skill_explorer_agent._responsibilities_extraction_llm import ResponsibilitiesExtractionResponse -from app.agent.skill_explorer_agent._sentence_decomposition_llm import _SentenceDecompositionResponse, \ - _SentenceDecompositionFirstPassResponse -from app.agent.welcome_agent import WelcomeAgentLLMResponse -from app.users.cv.utils.llm_extractor import CVExtractionResponse -from common_libs.llm.schema_builder import build_request_schema - - -def assert_successful_schema(schema: dict): - # THEN the schema should be able to pass validation - Schema(schema) - - -class TestPydanticToVertexSchema: - def test_basic_field_with_description(self): - # GIVEN a basic model with a str, int float - class BasicModel(BaseModel): - string_value: str = Field(..., description="A string value") - int_value: int = Field(..., description="An integer value") - float_value: float = Field(..., description="A float value") - - # WHEN building a successful schema - schema = build_request_schema(BasicModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - # AND the schema should be as expected - expected_schema = { - 'type_': 'OBJECT', - 'properties': { - 'string_value': {'type_': 'STRING', 'title': 'String Value', 'description': 'A string value'}, - 'int_value': {'type_': 'INTEGER', 'title': 'Int Value', 'description': 'An integer value'}, - 'float_value': {'type_': 'NUMBER', 'title': 'Float Value', 'description': 'A float value'} - }, - 'title': 'BasicModel', - 'required': ['string_value', 'int_value', 'float_value'], - } - - assert schema == expected_schema - - def test_inheritance(self): - # GIVEN a parent and child model - class ParentModel(BaseModel): - parent_field: str - - class ChildModel(ParentModel): - child_field: int - - # WHEN building the schema - schema = build_request_schema(ChildModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - # AND the schema should be as expected - expected_schema = { - 'type_': 'OBJECT', - 'properties': { - 'parent_field': {'type_': 'STRING', 'title': 'Parent Field'}, - 'child_field': {'type_': 'INTEGER', 'title': 'Child Field'} - }, - 'title': 'ChildModel', - 'required': ['parent_field', 'child_field'] - } - - assert schema == expected_schema - - def test_composition(self): - # GIVEN a model with a nested model - class NestedModel(BaseModel): - name: str - - class ComposedModel(BaseModel): - nested: NestedModel - - # WHEN building the schema - schema = build_request_schema(ComposedModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - # AND the schema should be as expected - expected_schema = { - 'properties': { - 'nested': { - 'properties': { - 'name': {'type_': 'STRING', 'title': 'Name'} - }, - 'required': ['name'], - 'title': 'NestedModel', - 'type_': 'OBJECT'} - }, - 'title': 'ComposedModel', - 'required': ['nested'], - 'type_': 'OBJECT' - } - - assert schema == expected_schema - - def test_union(self): - # GIVEN a model with a Union field - class UnionModel(BaseModel): - val: Union[str, int] - - # WHEN building the schema - schema = build_request_schema(UnionModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - # AND the schema should be as expected - expected_schema = { - 'properties': { - 'val': {'type_': 'STRING'} - }, - 'title': 'UnionModel', - 'required': ['val'], - 'type_': 'OBJECT' - } - - assert schema == expected_schema - - def test_optional(self): - # GIVEN a model with an Optional field - class OptionalModel(BaseModel): - val: Optional[str] - - # WHEN building the schema - schema = build_request_schema(OptionalModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - # AND the schema should be as expected - expected_schema = { - 'properties': { - 'val': {'type_': 'STRING', 'nullable': True} - }, - 'required': ['val'], - 'title': 'OptionalModel', - 'type_': 'OBJECT' - } - - assert schema == expected_schema - - def test_enum(self): - # GIVEN a model with an enum field - class MyEnum(str, Enum): - A = "a" - B = "b" - - class EnumModel(BaseModel): - choice: MyEnum - - # WHEN building the schema - schema = build_request_schema(EnumModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - # AND the schema should be as expected - expected_schema = { - 'properties': { - 'choice': { - 'enum': ['a', 'b'], - 'type_': 'STRING', - 'title': 'MyEnum' - } - }, - 'required': ['choice'], - 'title': 'EnumModel', - 'type_': 'OBJECT' - } - - assert schema == expected_schema - - def test_no_additional_properties(self): - # GIVEN a model with no additional properties - class StrictModel(BaseModel): - name: str - - class Config: - extra = "forbid" - - # WHEN building the schema - schema = build_request_schema(StrictModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - def test_list_of_objects(self): - # GIVEN a model with a list of objects - class Item(BaseModel): - name: str - - class ListModel(BaseModel): - items_list: List[Item] - - # WHEN building the schema - schema = build_request_schema(ListModel) - - # THEN it should be built successfully - assert_successful_schema(schema) - - # AND the schema should be as expected - expected_schema = { - 'properties': { - 'items_list': { - 'items': { - 'properties': { - 'name': {'type_': 'STRING', 'title': 'Name'} - }, - 'required': ['name'], - 'title': 'Item', - 'type_': 'OBJECT' - }, - 'title': 'Items List', - 'type_': 'ARRAY' - } - }, - 'required': ['items_list'], - 'title': 'ListModel', - 'type_': 'OBJECT' - } - - assert schema == expected_schema - - -llm_inputs = [ - WelcomeAgentLLMResponse, - RouterModelResponse, - _CollectedExperience, - TemporalClassifierToolOutput, - IntentAnalyzerToolOutput, - EntityExtractionToolOutput, - ExperienceSummarizerResponse, - _PickTopSkillsLLMOutput, - _RelevantEntityClassifierLLMOutput, - ClusterResponsibilitiesLLMResponse, - _ContextualizationLLMOutput, - ModelResponse, - ResponsibilitiesExtractionResponse, - _SentenceDecompositionFirstPassResponse, - _SentenceDecompositionResponse, - CVExtractionResponse -] - - -@pytest.mark.parametrize("llm_input", llm_inputs, ids=[_input.__name__ for _input in llm_inputs]) -def test_llm_inputs(llm_input: type[BaseModel]): - # GIVEN the llm input is parsed - schema = build_request_schema(llm_input) - - # THEN it should be built successfully - assert_successful_schema(schema) diff --git a/backend/poetry.lock b/backend/poetry.lock index 39d02a947..3511b4204 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -159,7 +159,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -423,18 +423,6 @@ dev = ["CacheControl[filecache,redis]", "black", "build", "cherrypy", "furo", "m filecache = ["filelock (>=3.8.0)"] redis = ["redis (>=2.10.5)"] -[[package]] -name = "cachetools" -version = "5.3.3" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, - {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, -] - [[package]] name = "certifi" version = "2024.6.2" @@ -453,7 +441,7 @@ version = "1.17.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, @@ -769,7 +757,7 @@ version = "43.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, @@ -920,6 +908,18 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] +[[package]] +name = "distro" +version = "1.9.0" +description = "Distro - an OS platform information API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, + {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, +] + [[package]] name = "dnspython" version = "2.6.1" @@ -1072,7 +1072,7 @@ files = [ [package.dependencies] cachecontrol = ">=0.12.6" -google-api-core = {version = ">=1.22.1,<3.0.0dev", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} +google-api-core = {version = ">=1.22.1,<3.0.0.dev0", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} google-api-python-client = ">=1.7.8" google-cloud-firestore = {version = ">=2.9.1", markers = "platform_python_implementation != \"PyPy\""} google-cloud-storage = ">=1.37.1" @@ -1311,7 +1311,7 @@ google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.63.2,<2.0.0" grpcio = [ {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, - {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\" and python_version < \"3.14\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] grpcio-status = [ {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, @@ -1349,27 +1349,36 @@ uritemplate = ">=3.0.1,<5" [[package]] name = "google-auth" -version = "2.30.0" +version = "2.59.1" description = "Google Authentication Library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "google-auth-2.30.0.tar.gz", hash = "sha256:ab630a1320f6720909ad76a7dbdb6841cdf5c66b328d690027e4867bdfb16688"}, - {file = "google_auth-2.30.0-py2.py3-none-any.whl", hash = "sha256:8df7da660f62757388b8a7f249df13549b3373f24388cb5d2f1dd91cc18180b5"}, + {file = "google_auth-2.59.1-py3-none-any.whl", hash = "sha256:89c3f931683a482ac97e61df7eb9da5e08a91703f3c752b2377d72cfb7d69e6a"}, + {file = "google_auth-2.59.1.tar.gz", hash = "sha256:ce50fc533ac02f489a2b183a0c156672c376ecb2091b1127bc7efba2975fff27"}, ] [package.dependencies] -cachetools = ">=2.0.0,<6.0" +cryptography = [ + {version = ">=41.0.5", markers = "python_version >= \"3.14\""}, + {version = ">=38.0.3", markers = "python_version < \"3.14\""}, +] pyasn1-modules = ">=0.2.1" -rsa = ">=3.1.4,<5" +requests = {version = ">=2.30.0,<3.0.0", optional = true, markers = "extra == \"requests\""} [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] -enterprise-cert = ["cryptography (==36.0.2)", "pyopenssl (==22.0.0)"] -pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +aiohttp = ["aiohttp (>=3.8.0,<4.0.0) ; python_version < \"3.14\"", "aiohttp (>=3.9.0,<4.0.0) ; python_version >= \"3.14\"", "requests (>=2.30.0,<3.0.0)"] +cryptography = ["cryptography (>=38.0.3) ; python_version < \"3.14\"", "cryptography (>=41.0.5) ; python_version >= \"3.14\""] +enterprise-cert = ["cryptography (>=38.0.3) ; python_version < \"3.14\"", "cryptography (>=41.0.5) ; python_version >= \"3.14\""] +grpc = ["grpcio (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] +pyjwt = ["pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3) ; python_version < \"3.14\"", "cryptography (>=41.0.5) ; python_version >= \"3.14\""] reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +requests = ["requests (>=2.30.0,<3.0.0)"] +rsa = ["rsa (>=4.0.0,<5)"] +testing = ["aiohttp (>=3.8.0,<4.0.0) ; python_version < \"3.14\"", "aiohttp (>=3.9.0,<4.0.0) ; python_version >= \"3.14\"", "aioresponses", "flask", "freezegun", "grpcio (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "packaging (>=20.0)", "pyjwt (>=2.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.30.0,<3.0.0)", "responses", "urllib3 (>=1.26.15,<3.0.0)"] +urllib3 = ["packaging (>=20.0)", "urllib3 (>=1.26.15,<3.0.0)"] [[package]] name = "google-auth-httplib2" @@ -1399,94 +1408,6 @@ files = [ {file = "google_cloud-0.34.0-py2.py3-none-any.whl", hash = "sha256:fb1ab7b0548fe44b3d538041f0a374505b7f990d448a935ea36649c5ccab5acf"}, ] -[[package]] -name = "google-cloud-aiplatform" -version = "1.115.0" -description = "Vertex AI API client library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "google_cloud_aiplatform-1.115.0-py2.py3-none-any.whl", hash = "sha256:0a4e47f1d4a7ce74a6afc75d79ddd0469200a39be988238ea4b6ee3c40d23221"}, - {file = "google_cloud_aiplatform-1.115.0.tar.gz", hash = "sha256:ab731c51e8111b93bccb1e56d961787a9896032f04f4b6c33c9f06696d5fc4c0"}, -] - -[package.dependencies] -docstring_parser = "<1" -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.8.dev0,<3.0.0", extras = ["grpc"]} -google-auth = ">=2.14.1,<3.0.0" -google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0" -google-cloud-resource-manager = ">=1.3.3,<3.0.0" -google-cloud-storage = ">=1.32.0,<3.0.0" -google-genai = ">=1.0.0,<2.0.0" -packaging = ">=14.3" -proto-plus = ">=1.22.3,<2.0.0" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" -pydantic = "<3" -shapely = "<3.0.0" -typing_extensions = "*" - -[package.extras] -adk = ["google-adk (>=1.0.0,<2.0.0)"] -ag2 = ["ag2[gemini]", "openinference-instrumentation-autogen (>=0.1.6,<0.2)"] -ag2-testing = ["absl-py", "ag2[gemini]", "cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "openinference-instrumentation-autogen (>=0.1.6,<0.2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.11.1,<3)", "pytest-xdist", "typing_extensions"] -agent-engines = ["cloudpickle (>=3.0,<4.0)", "google-cloud-logging (<4)", "google-cloud-trace (<2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "packaging (>=24.0)", "pydantic (>=2.11.1,<3)", "typing_extensions"] -autologging = ["mlflow (>=1.27.0,<=2.16.0)"] -cloud-profiler = ["tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "werkzeug (>=2.0.0,<4.0.0)"] -datasets = ["pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.0) ; python_version < \"3.11\""] -endpoint = ["requests (>=2.28.1)", "requests-toolbelt (<=1.0.0)"] -evaluation = ["jsonschema", "litellm (>=1.72.4)", "pandas (>=1.0.0)", "pyyaml", "ruamel.yaml", "scikit-learn (<1.6.0) ; python_version <= \"3.10\"", "scikit-learn ; python_version > \"3.10\"", "tqdm (>=4.23.0)"] -full = ["docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<=0.28.1)", "immutabledict", "jsonschema", "lit-nlp (==0.4.0)", "litellm (>=1.72.4)", "mlflow (>=1.27.0,<=2.16.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.0) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pyyaml", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || ==2.33.* || >=2.42.dev0,<=2.42.0) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.47.1) ; python_version == \"3.11\"", "requests (>=2.28.1)", "requests-toolbelt (<=1.0.0)", "ruamel.yaml", "scikit-learn (<1.6.0) ; python_version <= \"3.10\"", "scikit-learn ; python_version > \"3.10\"", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.3.0,<3.0.0)", "tensorflow (>=2.3.0,<3.0.0)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<4.0.0)"] -langchain = ["langchain (>=0.3,<0.4)", "langchain-core (>=0.3,<0.4)", "langchain-google-vertexai (>=2.0.22,<3)", "langgraph (>=0.2.45,<0.4)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)"] -langchain-testing = ["absl-py", "cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "langchain (>=0.3,<0.4)", "langchain-core (>=0.3,<0.4)", "langchain-google-vertexai (>=2.0.22,<3)", "langgraph (>=0.2.45,<0.4)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.11.1,<3)", "pytest-xdist", "typing_extensions"] -lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0)"] -llama-index = ["llama-index", "llama-index-llms-google-genai", "openinference-instrumentation-llama-index (>=3.0,<4.0)"] -llama-index-testing = ["absl-py", "cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "llama-index", "llama-index-llms-google-genai", "openinference-instrumentation-llama-index (>=3.0,<4.0)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.11.1,<3)", "pytest-xdist", "typing_extensions"] -metadata = ["numpy (>=1.15.0)", "pandas (>=1.0.0)"] -pipelines = ["pyyaml (>=5.3.1,<7)"] -prediction = ["docker (>=5.0.3)", "fastapi (>=0.71.0,<=0.114.0)", "httpx (>=0.23.0,<=0.28.1)", "starlette (>=0.17.1)", "uvicorn[standard] (>=0.16.0)"] -private-endpoints = ["requests (>=2.28.1)", "urllib3 (>=1.21.1,<1.27)"] -ray = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0)", "pyarrow (>=6.0.1)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || ==2.33.* || >=2.42.dev0,<=2.42.0) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.47.1) ; python_version == \"3.11\""] -ray-testing = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0)", "pyarrow (>=6.0.1)", "pytest-xdist", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || ==2.33.* || >=2.42.dev0,<=2.42.0) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.47.1) ; python_version == \"3.11\"", "ray[train]", "scikit-learn (<1.6.0)", "tensorflow", "torch (>=2.0.0,<2.1.0)", "xgboost", "xgboost_ray"] -reasoningengine = ["cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.11.1,<3)", "typing_extensions"] -tensorboard = ["tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "werkzeug (>=2.0.0,<4.0.0)"] -testing = ["aiohttp", "bigframes ; python_version >= \"3.10\"", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<=0.28.1)", "immutabledict", "immutabledict", "ipython", "jsonschema", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "litellm (>=1.72.4)", "mlflow (>=1.27.0,<=2.16.0)", "nltk", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "protobuf (<=5.29.4)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.0) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pytest-asyncio", "pytest-xdist", "pyyaml", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || ==2.33.* || >=2.42.dev0,<=2.42.0) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.47.1) ; python_version == \"3.11\"", "requests (>=2.28.1)", "requests-toolbelt (<=1.0.0)", "requests-toolbelt (<=1.0.0)", "ruamel.yaml", "scikit-learn (<1.6.0) ; python_version <= \"3.10\"", "scikit-learn (<1.6.0) ; python_version <= \"3.10\"", "scikit-learn ; python_version > \"3.10\"", "scikit-learn ; python_version > \"3.10\"", "sentencepiece (>=0.2.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (==2.14.1) ; python_version <= \"3.11\"", "tensorflow (==2.19.0) ; python_version > \"3.11\"", "tensorflow (>=2.3.0,<3.0.0)", "tensorflow (>=2.3.0,<3.0.0)", "torch (>=2.0.0,<2.1.0) ; python_version <= \"3.11\"", "torch (>=2.2.0) ; python_version > \"3.11\"", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<4.0.0)", "werkzeug (>=2.0.0,<4.0.0)", "xgboost"] -tokenization = ["sentencepiece (>=0.2.0)"] -vizier = ["google-vizier (>=0.1.6)"] -xai = ["tensorflow (>=2.3.0,<3.0.0)"] - -[[package]] -name = "google-cloud-bigquery" -version = "3.24.0" -description = "Google BigQuery API client library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google-cloud-bigquery-3.24.0.tar.gz", hash = "sha256:e95e6f6e0aa32e6c453d44e2b3298931fdd7947c309ea329a31b6ff1f939e17e"}, - {file = "google_cloud_bigquery-3.24.0-py2.py3-none-any.whl", hash = "sha256:bc08323ce99dee4e811b7c3d0cde8929f5bf0b1aeaed6bcd75fc89796dd87652"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<3.0.0dev" -google-cloud-core = ">=1.6.0,<3.0.0dev" -google-resumable-media = ">=0.6.0,<3.0dev" -packaging = ">=20.0.0" -python-dateutil = ">=2.7.2,<3.0dev" -requests = ">=2.21.0,<3.0.0dev" - -[package.extras] -all = ["Shapely (>=1.8.4,<3.0.0dev)", "db-dtypes (>=0.3.0,<2.0.0dev)", "geopandas (>=0.9.0,<1.0dev)", "google-cloud-bigquery-storage (>=2.6.0,<3.0.0dev)", "grpcio (>=1.47.0,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)", "ipywidgets (>=7.7.0)", "opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)", "pandas (>=1.1.0)", "proto-plus (>=1.15.0,<2.0.0dev)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev)", "pyarrow (>=3.0.0)", "tqdm (>=4.7.4,<5.0.0dev)"] -bigquery-v2 = ["proto-plus (>=1.15.0,<2.0.0dev)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev)"] -bqstorage = ["google-cloud-bigquery-storage (>=2.6.0,<3.0.0dev)", "grpcio (>=1.47.0,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "pyarrow (>=3.0.0)"] -geopandas = ["Shapely (>=1.8.4,<3.0.0dev)", "geopandas (>=0.9.0,<1.0dev)"] -ipython = ["ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)"] -ipywidgets = ["ipykernel (>=6.0.0)", "ipywidgets (>=7.7.0)"] -opentelemetry = ["opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)"] -pandas = ["db-dtypes (>=0.3.0,<2.0.0dev)", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "pandas (>=1.1.0)", "pyarrow (>=3.0.0)"] -tqdm = ["tqdm (>=4.7.4,<5.0.0dev)"] - [[package]] name = "google-cloud-core" version = "2.4.1" @@ -1500,11 +1421,11 @@ files = [ ] [package.dependencies] -google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" -google-auth = ">=1.25.0,<3.0dev" +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0.dev0" +google-auth = ">=1.25.0,<3.0.dev0" [package.extras] -grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] +grpc = ["grpcio (>=1.38.0,<2.0.dev0)", "grpcio-status (>=1.38.0,<2.0.dev0)"] [[package]] name = "google-cloud-dlp" @@ -1523,11 +1444,11 @@ google-api-core = {version = ">=2.17.1,<3.0.0", extras = ["grpc"]} google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" grpcio = [ {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, - {version = ">=1.59.0,<2.0.0"}, + {version = ">=1.59.0,<2.0.0", markers = "python_version < \"3.14\""}, ] proto-plus = [ {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0"}, + {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, ] protobuf = ">=4.25.8,<8.0.0" @@ -1555,32 +1476,6 @@ grpcio = [ proto-plus = ">=1.26.1,<2.0.0" protobuf = ">=6.33.5,<8.0.0" -[[package]] -name = "google-cloud-resource-manager" -version = "1.18.0" -description = "Google Cloud Resource Manager API client library" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "google_cloud_resource_manager-1.18.0-py3-none-any.whl", hash = "sha256:69bab144acd75878ebe44b720903dc3d140cb7d3be3261eaa8bc81e48afaff33"}, - {file = "google_cloud_resource_manager-1.18.0.tar.gz", hash = "sha256:db689f800a14c66d041196a7fbb8bb8aae8dc87f28c2929e101a5ec766b15512"}, -] - -[package.dependencies] -google-api-core = {version = ">=2.17.1,<3.0.0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" -grpc-google-iam-v1 = ">=0.14.0,<1.0.0" -grpcio = [ - {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, - {version = ">=1.59.0,<2.0.0"}, -] -proto-plus = [ - {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0"}, -] -protobuf = ">=4.25.8,<8.0.0" - [[package]] name = "google-cloud-storage" version = "2.19.0" @@ -1594,15 +1489,15 @@ files = [ ] [package.dependencies] -google-api-core = ">=2.15.0,<3.0.0dev" -google-auth = ">=2.26.1,<3.0dev" -google-cloud-core = ">=2.3.0,<3.0dev" -google-crc32c = ">=1.0,<2.0dev" +google-api-core = ">=2.15.0,<3.0.0.dev0" +google-auth = ">=2.26.1,<3.0.dev0" +google-cloud-core = ">=2.3.0,<3.0.dev0" +google-crc32c = ">=1.0,<2.0.dev0" google-resumable-media = ">=2.7.2" -requests = ">=2.18.0,<3.0.0dev" +requests = ">=2.18.0,<3.0.0.dev0" [package.extras] -protobuf = ["protobuf (<6.0.0dev)"] +protobuf = ["protobuf (<6.0.0.dev0)"] tracing = ["opentelemetry-api (>=1.1.0)"] [[package]] @@ -1688,28 +1583,32 @@ testing = ["pytest"] [[package]] name = "google-genai" -version = "1.26.0" +version = "2.27.0" description = "GenAI Python SDK" optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "google_genai-1.26.0-py3-none-any.whl", hash = "sha256:a050de052ee6e68654ba7cdb97028a576ad7108d0ecc9257c69bcc555498e9a2"}, - {file = "google_genai-1.26.0.tar.gz", hash = "sha256:d7b019ac98ca07888caa6121a953eb65db20f78370d8ae06aec29fb534534dc8"}, + {file = "google_genai-2.27.0-py3-none-any.whl", hash = "sha256:44940e99d5d49432bb5e4fe3bd8ac9906e75162d051ca74af63b733fb5632382"}, + {file = "google_genai-2.27.0.tar.gz", hash = "sha256:7827330be282b3337c220f5d90c501b316c1ca0ee89358afb15f673dfcdab4fb"}, ] [package.dependencies] anyio = ">=4.8.0,<5.0.0" -google-auth = ">=2.14.1,<3.0.0" +distro = ">=1.7.0,<2" +google-auth = {version = ">=2.56.0,<3.0.0", extras = ["requests"]} httpx = ">=0.28.1,<1.0.0" -pydantic = ">=2.0.0,<3.0.0" +pydantic = ">=2.12.5,<3.0.0" requests = ">=2.28.1,<3.0.0" -tenacity = ">=8.2.3,<9.0.0" -typing-extensions = ">=4.11.0,<5.0.0" -websockets = ">=13.0.0,<15.1.0" +sniffio = "*" +tenacity = ">=8.2.3,<9.2.0" +typing-extensions = ">=4.14.0,<5.0.0" +websockets = ">=13.0.0,<17.0" [package.extras] -aiohttp = ["aiohttp (<4.0.0)"] +aiohttp = ["aiohttp (>=3.10.11,<4.0.0)"] +local-tokenizer = ["pillow", "protobuf", "sentencepiece (>=0.2.0)", "torch", "torchvision", "transformers"] +pyopenssl = ["pyopenssl"] [[package]] name = "google-resumable-media" @@ -1724,11 +1623,11 @@ files = [ ] [package.dependencies] -google-crc32c = ">=1.0,<2.0dev" +google-crc32c = ">=1.0,<2.0.dev0" [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] -requests = ["requests (>=2.18.0,<3.0.0dev)"] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "google-auth (>=1.22.0,<2.0.dev0)"] +requests = ["requests (>=2.18.0,<3.0.0.dev0)"] [[package]] name = "googleapis-common-protos" @@ -1743,29 +1642,11 @@ files = [ ] [package.dependencies] -grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} protobuf = ">=4.25.8,<8.0.0" [package.extras] grpc = ["grpcio (>=1.44.0,<2.0.0)"] -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.4" -description = "IAM API client library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964"}, - {file = "grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038"}, -] - -[package.dependencies] -googleapis-common-protos = {version = ">=1.63.2,<2.0.0", extras = ["grpc"]} -grpcio = ">=1.44.0,<2.0.0" -protobuf = ">=4.25.8,<8.0.0" - [[package]] name = "grpcio" version = "1.83.0" @@ -1856,7 +1737,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1868,7 +1749,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1976,7 +1857,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -3683,7 +3564,7 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, @@ -3692,115 +3573,158 @@ files = [ [[package]] name = "pydantic" -version = "2.7.3" +version = "2.13.5" description = "Data validation using Python type hints" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic-2.7.3-py3-none-any.whl", hash = "sha256:ea91b002777bf643bb20dd717c028ec43216b24a6001a280f83877fd2655d0b4"}, - {file = "pydantic-2.7.3.tar.gz", hash = "sha256:c46c76a40bb1296728d7a8b99aa73dd70a48c3510111ff290034f860c99c419e"}, + {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, + {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, ] [package.dependencies] -annotated-types = ">=0.4.0" -pydantic-core = "2.18.4" -typing-extensions = ">=4.6.1" +annotated-types = ">=0.6.0" +pydantic-core = "2.46.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" -version = "2.18.4" +version = "2.46.5" description = "Core functionality for Pydantic validation and serialization" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic_core-2.18.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f76d0ad001edd426b92233d45c746fd08f467d56100fd8f30e9ace4b005266e4"}, - {file = "pydantic_core-2.18.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59ff3e89f4eaf14050c8022011862df275b552caef8082e37b542b066ce1ff26"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a55b5b16c839df1070bc113c1f7f94a0af4433fcfa1b41799ce7606e5c79ce0a"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d0dcc59664fcb8974b356fe0a18a672d6d7cf9f54746c05f43275fc48636851"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8951eee36c57cd128f779e641e21eb40bc5073eb28b2d23f33eb0ef14ffb3f5d"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4701b19f7e3a06ea655513f7938de6f108123bf7c86bbebb1196eb9bd35cf724"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00a3f196329e08e43d99b79b286d60ce46bed10f2280d25a1718399457e06be"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97736815b9cc893b2b7f663628e63f436018b75f44854c8027040e05230eeddb"}, - {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6891a2ae0e8692679c07728819b6e2b822fb30ca7445f67bbf6509b25a96332c"}, - {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bc4ff9805858bd54d1a20efff925ccd89c9d2e7cf4986144b30802bf78091c3e"}, - {file = "pydantic_core-2.18.4-cp310-none-win32.whl", hash = "sha256:1b4de2e51bbcb61fdebd0ab86ef28062704f62c82bbf4addc4e37fa4b00b7cbc"}, - {file = "pydantic_core-2.18.4-cp310-none-win_amd64.whl", hash = "sha256:6a750aec7bf431517a9fd78cb93c97b9b0c496090fee84a47a0d23668976b4b0"}, - {file = "pydantic_core-2.18.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:942ba11e7dfb66dc70f9ae66b33452f51ac7bb90676da39a7345e99ffb55402d"}, - {file = "pydantic_core-2.18.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2ebef0e0b4454320274f5e83a41844c63438fdc874ea40a8b5b4ecb7693f1c4"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a642295cd0c8df1b86fc3dced1d067874c353a188dc8e0f744626d49e9aa51c4"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f09baa656c904807e832cf9cce799c6460c450c4ad80803517032da0cd062e2"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98906207f29bc2c459ff64fa007afd10a8c8ac080f7e4d5beff4c97086a3dabd"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19894b95aacfa98e7cb093cd7881a0c76f55731efad31073db4521e2b6ff5b7d"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fbbdc827fe5e42e4d196c746b890b3d72876bdbf160b0eafe9f0334525119c8"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f85d05aa0918283cf29a30b547b4df2fbb56b45b135f9e35b6807cb28bc47951"}, - {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e85637bc8fe81ddb73fda9e56bab24560bdddfa98aa64f87aaa4e4b6730c23d2"}, - {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2f5966897e5461f818e136b8451d0551a2e77259eb0f73a837027b47dc95dab9"}, - {file = "pydantic_core-2.18.4-cp311-none-win32.whl", hash = "sha256:44c7486a4228413c317952e9d89598bcdfb06399735e49e0f8df643e1ccd0558"}, - {file = "pydantic_core-2.18.4-cp311-none-win_amd64.whl", hash = "sha256:8a7164fe2005d03c64fd3b85649891cd4953a8de53107940bf272500ba8a788b"}, - {file = "pydantic_core-2.18.4-cp311-none-win_arm64.whl", hash = "sha256:4e99bc050fe65c450344421017f98298a97cefc18c53bb2f7b3531eb39bc7805"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, - {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, - {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, - {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, - {file = "pydantic_core-2.18.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:823be1deb01793da05ecb0484d6c9e20baebb39bd42b5d72636ae9cf8350dbd2"}, - {file = "pydantic_core-2.18.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebef0dd9bf9b812bf75bda96743f2a6c5734a02092ae7f721c048d156d5fabae"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d6df168efb88d7d522664693607b80b4080be6750c913eefb77e34c12c71a"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9899c94762343f2cc2fc64c13e7cae4c3cc65cdfc87dd810a31654c9b7358cc"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99457f184ad90235cfe8461c4d70ab7dd2680e28821c29eca00252ba90308c78"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f469a3d2a2fdafe99296a87e8a4c37748b5080a26b806a707f25a902c040a8"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cdf28938ac6b8b49ae5e92f2735056a7ba99c9b110a474473fd71185c1af5d"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:938cb21650855054dc54dfd9120a851c974f95450f00683399006aa6e8abb057"}, - {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:44cd83ab6a51da80fb5adbd9560e26018e2ac7826f9626bc06ca3dc074cd198b"}, - {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:972658f4a72d02b8abfa2581d92d59f59897d2e9f7e708fdabe922f9087773af"}, - {file = "pydantic_core-2.18.4-cp38-none-win32.whl", hash = "sha256:1d886dc848e60cb7666f771e406acae54ab279b9f1e4143babc9c2258213daa2"}, - {file = "pydantic_core-2.18.4-cp38-none-win_amd64.whl", hash = "sha256:bb4462bd43c2460774914b8525f79b00f8f407c945d50881568f294c1d9b4443"}, - {file = "pydantic_core-2.18.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:44a688331d4a4e2129140a8118479443bd6f1905231138971372fcde37e43528"}, - {file = "pydantic_core-2.18.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2fdd81edd64342c85ac7cf2753ccae0b79bf2dfa063785503cb85a7d3593223"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86110d7e1907ab36691f80b33eb2da87d780f4739ae773e5fc83fb272f88825f"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46387e38bd641b3ee5ce247563b60c5ca098da9c56c75c157a05eaa0933ed154"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:123c3cec203e3f5ac7b000bd82235f1a3eced8665b63d18be751f115588fea30"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1803ac5c32ec324c5261c7209e8f8ce88e83254c4e1aebdc8b0a39f9ddb443"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53db086f9f6ab2b4061958d9c276d1dbe3690e8dd727d6abf2321d6cce37fa94"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abc267fa9837245cc28ea6929f19fa335f3dc330a35d2e45509b6566dc18be23"}, - {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0d829524aaefdebccb869eed855e2d04c21d2d7479b6cada7ace5448416597b"}, - {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:509daade3b8649f80d4e5ff21aa5673e4ebe58590b25fe42fac5f0f52c6f034a"}, - {file = "pydantic_core-2.18.4-cp39-none-win32.whl", hash = "sha256:ca26a1e73c48cfc54c4a76ff78df3727b9d9f4ccc8dbee4ae3f73306a591676d"}, - {file = "pydantic_core-2.18.4-cp39-none-win_amd64.whl", hash = "sha256:c67598100338d5d985db1b3d21f3619ef392e185e71b8d52bceacc4a7771ea7e"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, - {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461"}, + {file = "pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f"}, + {file = "pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069"}, + {file = "pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee"}, + {file = "pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689"}, + {file = "pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c"}, + {file = "pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed"}, + {file = "pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464"}, + {file = "pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e"}, + {file = "pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7"}, + {file = "pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47"}, + {file = "pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433"}, + {file = "pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda"}, + {file = "pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266"}, + {file = "pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc"}, ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" @@ -3871,7 +3795,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.2.2,<=3.3.0-dev0" +astroid = ">=3.2.2,<=3.3.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, @@ -4178,7 +4102,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -4362,21 +4286,6 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] -[[package]] -name = "rsa" -version = "4.9" -description = "Pure-Python RSA implementation" -optional = false -python-versions = ">=3.6,<4" -groups = ["main", "dev"] -files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - [[package]] name = "scipy" version = "1.16.1" @@ -4508,64 +4417,6 @@ statsig = ["statsig (>=0.55.3)"] tornado = ["tornado (>=6)"] unleash = ["UnleashClient (>=6.0.1)"] -[[package]] -name = "shapely" -version = "2.0.4" -description = "Manipulation and analysis of geometric objects" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "shapely-2.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:011b77153906030b795791f2fdfa2d68f1a8d7e40bce78b029782ade3afe4f2f"}, - {file = "shapely-2.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9831816a5d34d5170aa9ed32a64982c3d6f4332e7ecfe62dc97767e163cb0b17"}, - {file = "shapely-2.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c4849916f71dc44e19ed370421518c0d86cf73b26e8656192fcfcda08218fbd"}, - {file = "shapely-2.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:841f93a0e31e4c64d62ea570d81c35de0f6cea224568b2430d832967536308e6"}, - {file = "shapely-2.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b4431f522b277c79c34b65da128029a9955e4481462cbf7ebec23aab61fc58"}, - {file = "shapely-2.0.4-cp310-cp310-win32.whl", hash = "sha256:92a41d936f7d6743f343be265ace93b7c57f5b231e21b9605716f5a47c2879e7"}, - {file = "shapely-2.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:30982f79f21bb0ff7d7d4a4e531e3fcaa39b778584c2ce81a147f95be1cd58c9"}, - {file = "shapely-2.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:de0205cb21ad5ddaef607cda9a3191eadd1e7a62a756ea3a356369675230ac35"}, - {file = "shapely-2.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7d56ce3e2a6a556b59a288771cf9d091470116867e578bebced8bfc4147fbfd7"}, - {file = "shapely-2.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:58b0ecc505bbe49a99551eea3f2e8a9b3b24b3edd2a4de1ac0dc17bc75c9ec07"}, - {file = "shapely-2.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:790a168a808bd00ee42786b8ba883307c0e3684ebb292e0e20009588c426da47"}, - {file = "shapely-2.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4310b5494271e18580d61022c0857eb85d30510d88606fa3b8314790df7f367d"}, - {file = "shapely-2.0.4-cp311-cp311-win32.whl", hash = "sha256:63f3a80daf4f867bd80f5c97fbe03314348ac1b3b70fb1c0ad255a69e3749879"}, - {file = "shapely-2.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:c52ed79f683f721b69a10fb9e3d940a468203f5054927215586c5d49a072de8d"}, - {file = "shapely-2.0.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5bbd974193e2cc274312da16b189b38f5f128410f3377721cadb76b1e8ca5328"}, - {file = "shapely-2.0.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:41388321a73ba1a84edd90d86ecc8bfed55e6a1e51882eafb019f45895ec0f65"}, - {file = "shapely-2.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0776c92d584f72f1e584d2e43cfc5542c2f3dd19d53f70df0900fda643f4bae6"}, - {file = "shapely-2.0.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c75c98380b1ede1cae9a252c6dc247e6279403fae38c77060a5e6186c95073ac"}, - {file = "shapely-2.0.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3e700abf4a37b7b8b90532fa6ed5c38a9bfc777098bc9fbae5ec8e618ac8f30"}, - {file = "shapely-2.0.4-cp312-cp312-win32.whl", hash = "sha256:4f2ab0faf8188b9f99e6a273b24b97662194160cc8ca17cf9d1fb6f18d7fb93f"}, - {file = "shapely-2.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:03152442d311a5e85ac73b39680dd64a9892fa42bb08fd83b3bab4fe6999bfa0"}, - {file = "shapely-2.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:994c244e004bc3cfbea96257b883c90a86e8cbd76e069718eb4c6b222a56f78b"}, - {file = "shapely-2.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05ffd6491e9e8958b742b0e2e7c346635033d0a5f1a0ea083547fcc854e5d5cf"}, - {file = "shapely-2.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbdc1140a7d08faa748256438291394967aa54b40009f54e8d9825e75ef6113"}, - {file = "shapely-2.0.4-cp37-cp37m-win32.whl", hash = "sha256:5af4cd0d8cf2912bd95f33586600cac9c4b7c5053a036422b97cfe4728d2eb53"}, - {file = "shapely-2.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:464157509ce4efa5ff285c646a38b49f8c5ef8d4b340f722685b09bb033c5ccf"}, - {file = "shapely-2.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:489c19152ec1f0e5c5e525356bcbf7e532f311bff630c9b6bc2db6f04da6a8b9"}, - {file = "shapely-2.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b79bbd648664aa6f44ef018474ff958b6b296fed5c2d42db60078de3cffbc8aa"}, - {file = "shapely-2.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:674d7baf0015a6037d5758496d550fc1946f34bfc89c1bf247cabdc415d7747e"}, - {file = "shapely-2.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cd4ccecc5ea5abd06deeaab52fcdba372f649728050c6143cc405ee0c166679"}, - {file = "shapely-2.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb5cdcbbe3080181498931b52a91a21a781a35dcb859da741c0345c6402bf00c"}, - {file = "shapely-2.0.4-cp38-cp38-win32.whl", hash = "sha256:55a38dcd1cee2f298d8c2ebc60fc7d39f3b4535684a1e9e2f39a80ae88b0cea7"}, - {file = "shapely-2.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:ec555c9d0db12d7fd777ba3f8b75044c73e576c720a851667432fabb7057da6c"}, - {file = "shapely-2.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9103abd1678cb1b5f7e8e1af565a652e036844166c91ec031eeb25c5ca8af0"}, - {file = "shapely-2.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:263bcf0c24d7a57c80991e64ab57cba7a3906e31d2e21b455f493d4aab534aaa"}, - {file = "shapely-2.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddf4a9bfaac643e62702ed662afc36f6abed2a88a21270e891038f9a19bc08fc"}, - {file = "shapely-2.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:485246fcdb93336105c29a5cfbff8a226949db37b7473c89caa26c9bae52a242"}, - {file = "shapely-2.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8de4578e838a9409b5b134a18ee820730e507b2d21700c14b71a2b0757396acc"}, - {file = "shapely-2.0.4-cp39-cp39-win32.whl", hash = "sha256:9dab4c98acfb5fb85f5a20548b5c0abe9b163ad3525ee28822ffecb5c40e724c"}, - {file = "shapely-2.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:31c19a668b5a1eadab82ff070b5a260478ac6ddad3a5b62295095174a8d26398"}, - {file = "shapely-2.0.4.tar.gz", hash = "sha256:5dc736127fac70009b8d309a0eeb74f3e08979e530cf7017f2f507ef62e6cfb8"}, -] - -[package.dependencies] -numpy = ">=1.14,<3" - -[package.extras] -docs = ["matplotlib", "numpydoc (==1.1.*)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"] -test = ["pytest", "pytest-cov"] - [[package]] name = "shellingham" version = "1.5.4" @@ -4596,7 +4447,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -4671,7 +4522,7 @@ version = "8.5.0" description = "Retry code until it succeeds" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, @@ -4756,6 +4607,21 @@ files = [ {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, +] + +[package.dependencies] +typing-extensions = ">=4.15.0" + [[package]] name = "tzdata" version = "2024.1" @@ -5052,7 +4918,7 @@ version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, @@ -5430,4 +5296,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "6009b59f14e62438c4b33404c521984ff02ee35fe11d54b8a92e9ef4f74e5730" +content-hash = "9d02158b4c568105d2b8beeb990761733e97ffdc5553e8d3f3783df41b7728d0" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5d2f18bcd..e79b83505 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,8 +17,8 @@ google-cloud-dlp="^3.16.0" fix-busted-json=">=0.0.17,<1.0.0" json-repair=">=0.44.1,<1.0.0" motor = "^3.4.0" -google-cloud-aiplatform = "^1.79.0" -google-genai = "^1.26.0" +google-genai = "^2.27.0" +google-cloud-storage = "^2.18.2" pydantic = "^2.7.3" pydantic-settings = "^2.3.1" fastapi = "^0.111.0" @@ -47,7 +47,6 @@ google-cloud = "^0.34.0" pymongo_inmemory = "^0.4.2" pytest-repeat = "^0.9.4" matplotlib="^3.9.0" -google-genai="^1.26.0" google-cloud-storage = "^2.18.2" rich = "^14.2.0" openpyxl = "^3.1.5" diff --git a/backend/scripts/embeddings/evaluate_embeddings.py b/backend/scripts/embeddings/evaluate_embeddings.py index c33326429..0bd975744 100755 --- a/backend/scripts/embeddings/evaluate_embeddings.py +++ b/backend/scripts/embeddings/evaluate_embeddings.py @@ -7,7 +7,6 @@ import sys from typing import List, Optional, Tuple, Any -import vertexai from datasets import load_dataset, Features, Value, VerificationMode from dotenv import load_dotenv from tqdm import tqdm @@ -178,7 +177,6 @@ async def main(*, do_skills: bool = False, do_occupations: bool = False): region = os.getenv("VERTEX_API_EMBEDDINGS_REGION") if not region: raise ValueError("VERTEX_API_EMBEDDINGS_REGION environment variable is not set.") - vertexai.init(location=region) search_services = await get_search_services() diff --git a/backend/scripts/embeddings/generate_taxonomy_embeddings.py b/backend/scripts/embeddings/generate_taxonomy_embeddings.py index 815e1279d..3d2324d85 100755 --- a/backend/scripts/embeddings/generate_taxonomy_embeddings.py +++ b/backend/scripts/embeddings/generate_taxonomy_embeddings.py @@ -6,7 +6,6 @@ from datetime import datetime from typing import Any -import vertexai from bson.objectid import ObjectId from dotenv import load_dotenv from motor.motor_asyncio import AsyncIOMotorClient @@ -21,7 +20,6 @@ from scripts.embeddings._common import EmbeddingContext, generate_indexes, redact_credentials_from_uri load_dotenv() -vertexai.init() # Set up logging setup_logging_config("logging.cfg.yaml") diff --git a/backend/scripts/embeddings/search_embedings.py b/backend/scripts/embeddings/search_embedings.py index 7814c49fb..bfdd76970 100644 --- a/backend/scripts/embeddings/search_embedings.py +++ b/backend/scripts/embeddings/search_embedings.py @@ -2,7 +2,6 @@ import os from typing import List, Tuple -import vertexai import pandas as pd from motor.motor_asyncio import AsyncIOMotorClient from app.vector_search.embeddings_model import EmbeddingService @@ -113,7 +112,6 @@ async def process_batch(batch_df, embedding_service, file_name, mode='w'): if __name__ == "__main__": - vertexai.init() compass_db = AsyncIOMotorClient(os.getenv('TAXONOMY_MONGODB_URI')).get_database(DATABASE_NAME) collection = compass_db[EMBEDDINGS_COLLECTION]