From f5e43085f273a6ea7c8ab6921f3cbf10e03935d7 Mon Sep 17 00:00:00 2001 From: irumvanselme Date: Thu, 26 Feb 2026 09:52:16 +0200 Subject: [PATCH] feat(backend/i18n): separate conversation and reporting language --- backend/.env.example | 17 ++- .../_transition_decision_tool.py | 79 ++++++----- .../_entity_extraction_tool.py | 10 +- .../_intent_analyzer_tool.py | 2 +- .../_temporal_classifier_tool.py | 2 +- .../experience/_experience_summarizer.py | 2 +- .../_contextualization_llm.py | 2 +- .../relevant_entities_classifier_llm.py | 2 +- .../app/agent/prompt_template/locale_style.py | 46 ++++-- .../_responsibilities_extraction_llm.py | 2 +- .../_sentence_decomposition_llm.py | 4 +- backend/app/context_vars.py | 5 +- backend/app/conversations/routes.py | 66 +++++++-- backend/app/i18n/i18n_manager.py | 66 +++++++-- backend/app/i18n/language_config.py | 48 +++++-- backend/app/i18n/locale_context.py | 20 +++ backend/app/i18n/locale_date_format.py | 8 +- backend/app/i18n/test_i18n.py | 20 +-- backend/app/i18n/test_language_config.py | 63 ++++++--- backend/app/i18n/test_locale_context.py | 132 ++++++++++++++++++ backend/app/i18n/test_locale_date_format.py | 104 ++++++++------ backend/app/i18n/translation_service.py | 7 +- backend/app/server.py | 33 +++-- .../database_application_state_store_test.py | 15 +- backend/app/users/cv/test_routes.py | 4 +- .../test_utilities/setup_env_vars.py | 17 ++- backend/conftest.py | 99 ++++++++++--- .../llm_agent_director_scripted_user_test.py | 2 +- .../agent_director/llm_router_test.py | 2 +- ...imple_agent_director_scripted_user_test.py | 8 +- .../app_conversation_e2e_test.py | 2 +- .../_data_extraction_llm_es_test.py | 2 +- .../_data_extraction_llm_test.py | 2 +- ...ct_experiences_agent_scripted_user_test.py | 2 +- ...t_experiences_agent_simulated_user_test.py | 2 +- .../entity_extraction_tool_test.py | 2 +- .../intent_analyzer_llm_test.py | 2 +- .../temporal_classifier_test.py | 2 +- .../transition_decision_tool_test.py | 2 +- backend/evaluation_tests/conftest.py | 6 +- .../experience_pipeline_test.py | 2 +- .../_contextualization_llm_test.py | 2 +- ...elevant_occupations_classifier_llm_test.py | 2 +- .../infer_occupation_tool_test.py | 2 +- .../_relevant_skills_classifier_llm_test.py | 2 +- .../skill_linking/skills_linking_tool_test.py | 2 +- .../qna_agent/qna_agent_test.py | 5 +- .../_conversation_llm_test.py | 2 +- .../_responsibilities_extraction_tool_test.py | 2 +- .../_sentence_decomposition_llm_test.py | 2 +- .../loop_detection_scripted_user_test.py | 2 +- .../loop_detection_test.py | 2 +- ...ills_explorer_agent_simulated_user_test.py | 2 +- .../summarizer/summary_evaluator.py | 2 +- .../welcome_agent_scripted_user_test.py | 2 +- .../welcome_agent_simulated_user_test.py | 2 +- config/default.json | 4 +- iac/.env.example | 77 +++++++--- iac/templates/env.template | 23 +-- 59 files changed, 767 insertions(+), 283 deletions(-) create mode 100644 backend/app/i18n/locale_context.py create mode 100644 backend/app/i18n/test_locale_context.py diff --git a/backend/.env.example b/backend/.env.example index 8585361f0..669763cba 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,7 +33,7 @@ EMBEDDINGS_MODEL_NAME=text-embedding-005 BACKEND_SENTRY_DSN=https:// BACKEND_ENABLE_SENTRY=False # For supported options see: app.sentry_init.BackendSentryConfig -BACKEND_SENTRY_CONFIG={"tracesSampleRate": 0.2, "enableLogs": false, "logLevel": "info", "eventLevel": "error"} +BACKEND_SENTRY_CONFIG='{"tracesSampleRate": 0.2, "enableLogs": false, "logLevel": "info", "eventLevel": "error"}' # Country settings DEFAULT_COUNTRY_OF_USER=Unspecified @@ -53,7 +53,7 @@ GLOBAL_DISABLE_REGISTRATION_CODE=False BACKEND_FEATURES='{}' # Configuration for the experience pipeline -BACKEND_EXPERIENCE_PIPELINE_CONFIG={"number_of_clusters": 5, "number_of_top_skills_to_pick_per_cluster": 1} +BACKEND_EXPERIENCE_PIPELINE_CONFIG='{"number_of_clusters": 5, "number_of_top_skills_to_pick_per_cluster": 1}' # CV storage and limits (optional; required to persist uploads) GLOBAL_ENABLE_CV_UPLOAD= @@ -62,8 +62,17 @@ BACKEND_CV_MAX_UPLOADS_PER_USER= BACKEND_CV_RATE_LIMIT_PER_MINUTE= -# Locales -BACKEND_LANGUAGE_CONFIG='{"default_locale":"en-US","available_locales":[{"locale":"en-US","date_format":"MM/DD/YYYY"}]}' +# Language Configurations +BACKEND_LANGUAGE_CONFIG='{ + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-US", + "available_locales": [ + { + "locale": "en-US", + "date_format": "MM/DD/YYYY" + } + ] +}' # Branding settings GLOBAL_PRODUCT_NAME=Compass diff --git a/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py b/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py index a850bddec..e6f411d89 100644 --- a/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py +++ b/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py @@ -9,7 +9,6 @@ from app.agent.config import AgentsConfig from app.agent.llm_caller import LLMCaller from app.agent.penalty import get_penalty -from app.agent.prompt_template import get_language_style from app.agent.prompt_template import sanitize_input from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter from app.conversation_memory.conversation_memory_types import ConversationContext @@ -81,14 +80,13 @@ def _get_llm(collected_data_json: str, temperature_config: Optional[dict] = None return GeminiGenerativeLLM( system_instructions=_SYSTEM_INSTRUCTIONS.format( collected_data=collected_data_json, - language_style=get_language_style() ), config=LLMConfig( language_model_name=AgentsConfig.deep_reasoning_model, generation_config=ZERO_TEMPERATURE_GENERATION_CONFIG - | JSON_GENERATION_CONFIG - | temperature_config - | with_response_schema(_TransitionDecisionOutput) + | JSON_GENERATION_CONFIG + | temperature_config + | with_response_schema(_TransitionDecisionOutput) )) async def execute(self, @@ -98,7 +96,8 @@ async def execute(self, unexplored_types: list[WorkType], explored_types: list[WorkType], conversation_context: ConversationContext, - user_input: AgentInput) -> tuple[TransitionDecision, Optional[TransitionReasoning], list[LLMStats]]: + user_input: AgentInput) -> tuple[ + TransitionDecision, Optional[TransitionReasoning], list[LLMStats]]: incomplete_required = _find_incomplete_required_for_work_type(collected_data, exploring_type) if incomplete_required: self._logger.info( @@ -112,20 +111,20 @@ async def execute(self, for collected_item in collected_data: collected_item_dict = collected_item.model_dump(exclude={"defined_at_turn_number"}) cleaned_experience_dicts.append(collected_item_dict) - + json_data = json.dumps(cleaned_experience_dicts, indent=2) - + conversation_history = ConversationHistoryFormatter.format_history_for_agent_generative_prompt( conversation_context ) - + exploring_type_str = exploring_type.name if exploring_type else "None" unexplored_types_str = ", ".join([wt.name for wt in unexplored_types]) explored_types_str = ", ".join([wt.name for wt in explored_types]) - + exploring_type_description = _get_experience_type(exploring_type) if exploring_type else "None" work_type_mapping = _generate_work_type_mapping() - + prompt = _PROMPT_TEMPLATE.format( user_input=user_input.message, conversation_history=conversation_history, @@ -136,42 +135,42 @@ async def execute(self, unexplored_types=unexplored_types_str, explored_types=explored_types_str ) - + _llm_stats = [] _reasoning = None - + async def _callback(attempt: int, max_retries: int) -> tuple[TransitionDecision, float, BaseException | None]: temperature_config = get_config_variation(start_temperature=0.0, end_temperature=0.1, start_top_p=0.95, end_top_p=1.0, attempt=attempt, max_retries=max_retries) - + llm = self._get_llm(collected_data_json=json_data, temperature_config=temperature_config) self._logger.debug("Calling transition decision LLM with temperature: %s, top_p: %s", temperature_config["temperature"], temperature_config["top_p"]) - + data, reasoning, llm_stats, penalty, error = await self._internal_execute( - llm=llm, + llm=llm, prompt=prompt, unexplored_types=unexplored_types ) - + nonlocal _reasoning _reasoning = reasoning _llm_stats.extend(llm_stats) - + return data, penalty, error - + result, _result_penalty, _error = await Retry[TransitionDecision].call_with_penalty( callback=_callback, logger=self._logger) - + reasoning = _reasoning if reasoning is None: reasoning = TransitionReasoning( reasoning="No reasoning provided - error occurred during LLM call", confidence="low" ) - + # Additional validation: ensure END_CONVERSATION only when all types explored if result == TransitionDecision.END_CONVERSATION and unexplored_types: self._logger.warning( @@ -185,7 +184,7 @@ async def _callback(attempt: int, max_retries: int) -> tuple[TransitionDecision, reasoning=f"Invalid END_CONVERSATION decision - unexplored_types not empty: {[wt.name for wt in unexplored_types]}. Original reasoning: {reasoning.reasoning if reasoning else 'None'}", confidence="high" ) - + self._logger.info( "Transition decision: %s. " "Exploring type: %s, Unexplored types: %s, Explored types: %s, " @@ -204,15 +203,16 @@ async def _internal_execute(self, *, llm: GeminiGenerativeLLM, prompt: str, - unexplored_types: list[WorkType]) -> tuple[TransitionDecision, TransitionReasoning, list[LLMStats], float, BaseException | None]: - + unexplored_types: list[WorkType]) -> tuple[ + TransitionDecision, TransitionReasoning, list[LLMStats], float, BaseException | None]: + no_response_penalty_level = 3 response_data, llm_stats = await self._llm_caller.call_llm( llm=llm, llm_input=sanitize_input(prompt, _TAGS_TO_FILTER), logger=self._logger ) - + if not response_data: _error = ValueError("LLM did not return any output") self._logger.error(_error, stack_info=True) @@ -225,16 +225,17 @@ async def _internal_execute(self, continue_current_type = response_data.continue_current_type done_with_collection = response_data.done_with_collection reasoning_text = response_data.reasoning if hasattr(response_data, 'reasoning') else "No reasoning provided" - + # Truncate reasoning if too long to prevent bloat if len(reasoning_text) > MAX_REASONING_LENGTH: reasoning_text = reasoning_text[:MAX_REASONING_LENGTH].rsplit('.', 1)[0] + "." - self._logger.warning("Reasoning truncated from %d to %d characters", len(response_data.reasoning), len(reasoning_text)) - + self._logger.warning("Reasoning truncated from %d to %d characters", len(response_data.reasoning), + len(reasoning_text)) + # Ensure reasoning doesn't exceed limit (safety check) if len(reasoning_text) > MAX_REASONING_LENGTH: reasoning_text = reasoning_text[:MAX_REASONING_LENGTH] - + # Validate done_with_collection against state (deterministic check) if done_with_collection and unexplored_types: self._logger.warning( @@ -244,7 +245,7 @@ async def _internal_execute(self, reasoning_text ) done_with_collection = False - + # Map binary outputs to transition decision if continue_current_type: decision = TransitionDecision.CONTINUE @@ -252,15 +253,15 @@ async def _internal_execute(self, decision = TransitionDecision.END_CONVERSATION else: decision = TransitionDecision.END_WORKTYPE - - self._logger.debug("Transition decision: %s (continue_current_type=%s, done_with_collection=%s). Reasoning: %s", - decision, continue_current_type, done_with_collection, reasoning_text) - + + self._logger.debug("Transition decision: %s (continue_current_type=%s, done_with_collection=%s). Reasoning: %s", + decision, continue_current_type, done_with_collection, reasoning_text) + reasoning = TransitionReasoning( reasoning=reasoning_text, confidence="medium" ) - + return decision, reasoning, llm_stats, 0, None @@ -269,8 +270,6 @@ async def _internal_execute(self, #Role You decide when to transition between phases in a work experience collection conversation. -{language_style} - #Decision Logic Answer two boolean questions: @@ -333,8 +332,8 @@ async def _internal_execute(self, def _find_incomplete_required_for_work_type( - collected_data: list[CollectedData], - exploring_type: WorkType | None, + collected_data: list[CollectedData], + exploring_type: WorkType | None, ) -> list[tuple[int, CollectedData, list[str]]]: """ Find experiences of the given work type that lack required fields (experience_title, work_type). @@ -345,7 +344,7 @@ def _find_incomplete_required_for_work_type( return [] key = exploring_type.name result = [] - for i, exp in enumerate (collected_data): + for i, exp in enumerate(collected_data): if exp.work_type and exp.work_type.strip() == key: missing = [] if not (exp.experience_title and exp.experience_title.strip()): diff --git a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_entity_extraction_tool.py b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_entity_extraction_tool.py index 48bc0bc35..6f573b835 100644 --- a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_entity_extraction_tool.py +++ b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_entity_extraction_tool.py @@ -74,8 +74,9 @@ def _get_llm(temperature_config: Optional[dict] = None) -> GeminiGenerativeLLM: if temperature_config is None: temperature_config = {} + language_style = get_language_style(prompt_intent="application_state") return GeminiGenerativeLLM( - system_instructions=_SYSTEM_INSTRUCTIONS.format(language_style=get_language_style()), + system_instructions=_SYSTEM_INSTRUCTIONS.format(language_style=language_style), config=LLMConfig( generation_config=ZERO_TEMPERATURE_GENERATION_CONFIG | JSON_GENERATION_CONFIG | { "max_output_tokens": 3000 @@ -113,7 +114,8 @@ async def _callback(attempt: int, max_retries: int) -> tuple[ExtractedData, floa return data, penality, error - result, _result_penalty, _error = await Retry[ExtractedData].call_with_penalty(callback=_callback, logger=self._logger) + result, _result_penalty, _error = await Retry[ExtractedData].call_with_penalty(callback=_callback, + logger=self._logger) return result, _llm_stats @@ -176,8 +178,8 @@ async def _internal_execute(self, Extract the title of the experience from the ''. For unpaid work, use the kind of work done (e.g. "Helping Neighbors", "Volunteering" etc). Make sure that the user is actually referring to an experience they have have. - When summarizing a user-stated action (e.g., "I sell tomatoes"), convert it directly into a gerund-phrase experience title (e.g., "Selling Tomatoes"). - Return a string value containing the title of the experience. + When summarizing a user-stated action (e.g., "I sell tomatoes"), convert it directly into a gerund-phrase experience title (e.g., "Selling Tomatoes" in respective language). + Return a string value containing the title of the experience in respective language. Use `null`: If the user has not mentioned their `experience title` and has not yet been asked to provide it. Use "": If the user explicitly declines to provide their `experience title` when explicitly asked, or requests that previously stored `experience title` data be deleted. diff --git a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_intent_analyzer_tool.py b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_intent_analyzer_tool.py index 39701388e..8fb0590cb 100644 --- a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_intent_analyzer_tool.py +++ b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_intent_analyzer_tool.py @@ -73,7 +73,7 @@ def _get_llm(previously_extracted_data: str, temperature_config: Optional[dict] return GeminiGenerativeLLM( system_instructions=_SYSTEM_INSTRUCTIONS.format(previously_extracted_data=previously_extracted_data, - language_style=get_language_style()), + language_style=get_language_style(prompt_intent="application_state")), config=LLMConfig( generation_config=ZERO_TEMPERATURE_GENERATION_CONFIG | JSON_GENERATION_CONFIG | { "max_output_tokens": 3000 diff --git a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py index 1dcaaeb68..2237cdfcc 100644 --- a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py +++ b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py @@ -84,7 +84,7 @@ def _get_system_instructions(self) -> str: return _SYSTEM_INSTRUCTIONS.format( work_type_definitions=WORK_TYPE_DEFINITIONS_FOR_PROMPT, current_date=current_date_formatted, - language_style=get_language_style(), + language_style=get_language_style(prompt_intent="application_state"), date_format_full=date_formats.full, date_format_month_year=date_formats.month_year, date_format_year=date_formats.year_only, diff --git a/backend/app/agent/experience/_experience_summarizer.py b/backend/app/agent/experience/_experience_summarizer.py index dd95f2a35..5bab56558 100644 --- a/backend/app/agent/experience/_experience_summarizer.py +++ b/backend/app/agent/experience/_experience_summarizer.py @@ -88,7 +88,7 @@ def get_system_instructions(*, country_of_user: Country) -> str: """) return replace_placeholders_with_indent( _summarize_system_instructions, - language_style=get_language_style(), + language_style=get_language_style(prompt_intent="application_state"), country_instructions=_country_instructions ) diff --git a/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm.py b/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm.py index 544544acf..01d3814da 100644 --- a/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm.py +++ b/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm.py @@ -72,7 +72,7 @@ def _get_system_instructions(country_of_interest: Country, number_of_titles: int """) return replace_placeholders_with_indent(system_instructions_template, country_of_interest=country_of_interest.value, - language_style=get_language_style(), + language_style=get_language_style(prompt_intent="application_state"), work_type_names=", ".join([work_type.name for work_type in WorkType]), glossary=glossary_str, number_of_titles=f"{number_of_titles}") diff --git a/backend/app/agent/linking_and_ranking_pipeline/relevant_entities_classifier_llm.py b/backend/app/agent/linking_and_ranking_pipeline/relevant_entities_classifier_llm.py index 7a27596ed..36668d5a8 100644 --- a/backend/app/agent/linking_and_ranking_pipeline/relevant_entities_classifier_llm.py +++ b/backend/app/agent/linking_and_ranking_pipeline/relevant_entities_classifier_llm.py @@ -370,7 +370,7 @@ def _get_system_instructions(entity_type_singular: Literal['skill', 'occupation' return replace_placeholders_with_indent(system_prompt_template, entity_type_singular=entity_type_singular, - language_style=get_language_style(), + language_style=get_language_style(prompt_intent="application_state"), entity_types_plural=entity_types_plural, entity_types_plural_capitalized=entity_types_plural_capitalized) diff --git a/backend/app/agent/prompt_template/locale_style.py b/backend/app/agent/prompt_template/locale_style.py index 8882bfd68..ada429063 100644 --- a/backend/app/agent/prompt_template/locale_style.py +++ b/backend/app/agent/prompt_template/locale_style.py @@ -1,35 +1,63 @@ import textwrap +from typing import Literal from app.agent.prompt_template.agent_prompt_template import STD_LANGUAGE_STYLE from app.i18n.translation_service import get_i18n_manager -def _get_locale_section(): - language = get_i18n_manager().get_locale() +def get_conversation_locale_style_paragraph() -> str: + """ + Get the locale instructions for agents/tools that compute user responses (Questions & Answers). + """ + + language = get_i18n_manager().get_conversation_locale() language_label = language.label() return textwrap.dedent(f""" #Language - - CRITICAL: You MUST reply ONLY in {language_label}. Never mix languages or use words from other languages. + - CRITICAL: You MUST return responses ONLY in {language_label}. Never mix languages or use words from other languages. - Any questions I tell you to ask me should also be in the {language_label} language. - If my previous message is in another language, do not respond in that language, instead respond in the {language_label} language. - - Any information or data you are asked to extract from our conversation should also be in or translated to the {language_label} language. - If you see text in these instructions or prompts that is not in {language_label}, you MUST translate it to {language_label} before using it in your response. - - Never mix languages - your entire response must be in {language_label} only. + - Never mix languages - your entire response values must be in {language_label} only. + """) + + +def get_state_locale_style_paragraph() -> str: + """ + Get the locale instructions for agents/tools that return data which is to be written in application state. + """ + + language = get_i18n_manager().get_reporting_locale() + language_label = language.label() + + return textwrap.dedent(f""" + #Language for Data Extraction and State Updates + - CRITICAL: All responses, details, entities, and information you return MUST be in {language_label}. + - Any information or data you extract from the conversation should be translated to {language_label} before being returned. + - When classifying, categorizing, or labeling data, use {language_label} terminology. + - If the user provides information in another language, extract it and translate it to {language_label} for storage. + - Never mix languages - your entire response values must be in {language_label} only. """) -def get_language_style(*, with_locale: bool = True) -> str: +def get_language_style(*, + with_locale: bool = True, + prompt_intent: Literal["user_message", "application_state"] = "user_message") -> str: """ Get the language style instructions. - :arg with_locale: Whether to include the locale section. Note that if this is set to True, we expect the locale to be set. - Otherwise, an error will be raised. + :arg with_locale: Whether to include the locale section. Note that if this is set to True, + we expect the locale to be set. Otherwise, an error will be raised. + :arg prompt_intent: The intent of the prompt. If it is "user_message", then we will include the locale style for """ prompt = "" if with_locale: - prompt += _get_locale_section() + if prompt_intent == "application_state": + prompt += get_state_locale_style_paragraph() + else: + prompt += get_conversation_locale_style_paragraph() prompt += STD_LANGUAGE_STYLE diff --git a/backend/app/agent/skill_explorer_agent/_responsibilities_extraction_llm.py b/backend/app/agent/skill_explorer_agent/_responsibilities_extraction_llm.py index 4cc62ef1c..a4564fb5a 100644 --- a/backend/app/agent/skill_explorer_agent/_responsibilities_extraction_llm.py +++ b/backend/app/agent/skill_explorer_agent/_responsibilities_extraction_llm.py @@ -177,7 +177,7 @@ def _create_extraction_system_instructions() -> str: """) - return system_instructions_template.format(language_style=get_language_style()) + return system_instructions_template.format(language_style=get_language_style(prompt_intent="application_state")) @staticmethod def _extraction_prompt_template(context: ConversationContext, last_user_input: str) -> str: diff --git a/backend/app/agent/skill_explorer_agent/_sentence_decomposition_llm.py b/backend/app/agent/skill_explorer_agent/_sentence_decomposition_llm.py index 424893f56..60dab4441 100644 --- a/backend/app/agent/skill_explorer_agent/_sentence_decomposition_llm.py +++ b/backend/app/agent/skill_explorer_agent/_sentence_decomposition_llm.py @@ -196,7 +196,7 @@ def _create_first_pass_system_instructions() -> str: """) - language_name = get_i18n_manager().get_locale().label() + language_name = get_i18n_manager().get_reporting_locale().label() return replace_placeholders_with_indent(system_instructions_template, language_name=language_name) @@ -252,7 +252,7 @@ def _create_second_pass_system_instructions() -> str: """) - language_name = get_i18n_manager().get_locale().label() + language_name = get_i18n_manager().get_reporting_locale().label() return replace_placeholders_with_indent(system_instructions_template, language_name=language_name) diff --git a/backend/app/context_vars.py b/backend/app/context_vars.py index 6d2f416f9..ac3c6323c 100644 --- a/backend/app/context_vars.py +++ b/backend/app/context_vars.py @@ -1,4 +1,5 @@ import contextvars +from app.i18n.locale_context import LocaleContext # Define a context variable to store the session_id, which will be used to correlate log messages # every conversation with a user will have a unique session_id @@ -12,5 +13,5 @@ # Client ID is optional, so we set a default value of None client_id_ctx_var = contextvars.ContextVar("client_id", default=None) -# The language the user is speaking. -user_language_ctx_var = contextvars.ContextVar("user_language") +# The language context, used to determine which language used by the LLM to reply to the user. +user_language_ctx_var: contextvars.ContextVar["LocaleContext"] = contextvars.ContextVar("user_language") diff --git a/backend/app/conversations/routes.py b/backend/app/conversations/routes.py index 8752f722c..edafd099f 100644 --- a/backend/app/conversations/routes.py +++ b/backend/app/conversations/routes.py @@ -3,16 +3,17 @@ """ import logging from http import HTTPStatus +from textwrap import dedent from typing import Annotated -from fastapi import FastAPI, APIRouter, Request, Depends, HTTPException, Path +from fastapi import FastAPI, APIRouter, Request, Response, Depends, HTTPException, Path from motor.motor_asyncio import AsyncIOMotorDatabase from app.agent.agent_director.llm_agent_director import LLMAgentDirector from app.app_config import get_application_config from app.application_state import ApplicationStateManager from app.constants.errors import HTTPErrorResponse -from app.context_vars import session_id_ctx_var, user_id_ctx_var, client_id_ctx_var, user_language_ctx_var +from app.context_vars import session_id_ctx_var, user_id_ctx_var, client_id_ctx_var from app.conversation_memory.conversation_memory_manager import ConversationMemoryManager from app.conversations.constants import MAX_MESSAGE_LENGTH, UNEXPECTED_FAILURE_MESSAGE from app.conversations.experience.routes import add_experience_routes @@ -31,7 +32,9 @@ from app.server_dependencies.conversation_manager_dependencies import get_conversation_memory_manager from app.server_dependencies.db_dependencies import CompassDBProvider from app.users.auth import Authentication, UserInfo +from app.i18n.translation_service import get_i18n_manager from app.i18n.types import Locale +from app.users.types import UserPreferences async def get_conversation_service(agent_director: LLMAgentDirector = Depends(get_agent_director), @@ -51,6 +54,53 @@ async def get_conversation_service(agent_director: LLMAgentDirector = Depends(ge reaction_repository=ReactionRepository(db)) +logger = logging.getLogger(__name__) + + +def set_user_conversation_language(user_preferences: UserPreferences) -> str: + """ + Set the conversation and reporting locales based on the user preferences. + + :return the language in which the conversation will be conducted. + """ + + # Set the conversation and reporting locales + app_config = get_application_config() + + # Get reporting locale from configuration (always from config, never from user) + reporting_locale = app_config.language_config.reporting_locale + + # Get conversation locale from user preferences, with fallback. + # The requested locale must be one of the configured available_locales; otherwise we have no + # translations/date formats for it, so we fall back to the configured conversation fallback locale. + fallback_locale = app_config.language_config.conversation_fallback_locale + try: + conversation_locale = Locale.from_locale_str(user_preferences.language) + if not app_config.language_config.is_locale_available(conversation_locale): + logger.info( + "user preferences language %s is not in available_locales, using fallback %s", + user_preferences.language, fallback_locale.value + ) + conversation_locale = fallback_locale + except ValueError: + # from_locale_str raises ValueError for None or an unrecognised locale string + logger.warning( + "user preferences language %s is not a recognised locale, using fallback %s", + user_preferences.language, fallback_locale.value + ) + conversation_locale = fallback_locale + + # Set both locales atomically in the context + logger.debug(dedent(f""" + Languages used to process the request:- + Reporting -> {reporting_locale.value} + Conversation -> {conversation_locale.value}""")) + + get_i18n_manager().set_locales(conversation_locale, reporting_locale) + + return conversation_locale.value + + def add_conversation_routes(app: FastAPI, authentication: Authentication): """ Adds all the conversation routes to the FastAPI app @@ -72,7 +122,7 @@ def add_conversation_routes(app: FastAPI, authentication: Authentication): HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}}, # Internal server error, any server error description="""The main conversation route used to interact with the agent.""") - async def _send_message(request: Request, body: ConversationInput, session_id: Annotated[ + async def _send_message(request: Request, response: Response, body: ConversationInput, session_id: Annotated[ int, Path(description="The session id for the conversation history.", examples=[123])], clear_memory: bool = False, filter_pii: bool = False, user_info: UserInfo = Depends(authentication.get_user_info()), @@ -90,11 +140,6 @@ async def _send_message(request: Request, body: ConversationInput, session_id: A session_id_ctx_var.set(session_id) user_id_ctx_var.set(user_id) - # The user's language to send messages in. - # For now, it is using the backend default language from app_config. - app_config = get_application_config() - user_language_ctx_var.set(app_config.language_config.default_locale) - # Do not allow user input that is too long, # as a basic measure to prevent abuse. if len(user_input) > MAX_MESSAGE_LENGTH: @@ -109,6 +154,11 @@ async def _send_message(request: Request, body: ConversationInput, session_id: A # set the client_id in the context variable. client_id_ctx_var.set(current_user_preferences.client_id) + # Set up the language to have the conversation in. + # And set it into the headers for the Client, so that they know which language the response is in. + content_language = set_user_conversation_language(current_user_preferences) + response.headers["Content-Language"] = content_language + return await service.send(user_id, session_id, user_input, clear_memory, filter_pii) except ConversationAlreadyConcludedError as e: warning_msg = str(e) diff --git a/backend/app/i18n/i18n_manager.py b/backend/app/i18n/i18n_manager.py index 10433637c..f22878a4a 100644 --- a/backend/app/i18n/i18n_manager.py +++ b/backend/app/i18n/i18n_manager.py @@ -1,10 +1,10 @@ import json import logging import os -import argparse from typing import Dict, Any, Set, Optional from collections import defaultdict from app.i18n.types import Locale +from app.i18n.locale_context import LocaleContext from app.i18n.constants import LOCALES_DIR, DEFAULT_FALLBACK_LOCALE from app.context_vars import user_language_ctx_var from app.app_config import get_application_config @@ -17,24 +17,63 @@ def __init__(self): self.load_translations() self._logger = logging.getLogger(self.__class__.__name__) - def get_locale(self) -> Locale: + def get_conversation_locale(self) -> Locale: + """ + Returns the conversation locale from the current request context. + + Raises: + LookupError: If the locale context is not set + """ + try: + context = user_language_ctx_var.get() + return context.conversation_locale + except LookupError as e: + self._logger.error("Locale context not set. Call set_locales() first.") + raise + + def get_reporting_locale(self) -> Locale: + """ + Returns the reporting locale from the current request context. + + Raises: + LookupError: If the locale context is not set + """ try: - return user_language_ctx_var.get() + context = user_language_ctx_var.get() + return context.reporting_locale except LookupError as e: - self._logger.exception(e) + self._logger.error("Locale context not set. Call set_locales() first.") raise - def set_locale(self, locale: Locale) -> Locale: - self._logger.debug(f"Setting locale to {locale.name}") - user_language_ctx_var.set(locale) - return locale + def set_locales(self, conversation: Locale, reporting: Locale) -> LocaleContext: + """ + Sets both conversation and reporting locales atomically in the request context. + + Args: + conversation: The locale for user-facing messages and prompts + reporting: The locale for state updates and report generation + + Returns: + The LocaleContext that was set + """ + self._logger.debug( + f"Setting locales: conversation={conversation.name}, reporting={reporting.name}" + ) + + context = LocaleContext( + conversation_locale=conversation, + reporting_locale=reporting + ) + + user_language_ctx_var.set(context) + return context def load_translations(self): """ Loads all translation files from the locales directory. """ if not os.path.isdir(LOCALES_DIR): - self._logger.warning(f"Locales directory does not exist: {self.locales_dir}") + self._logger.warning(f"Locales directory does not exist: {LOCALES_DIR}") return for locale in os.listdir(LOCALES_DIR): @@ -89,17 +128,20 @@ def _get_default_variables() -> Dict[str, Any]: def t(self, domain: str, key: str, **kwargs) -> str: """ - Convenience method to get a translation using the current locale from the locale provider. + Convenience method to get a translation using the current conversation locale. + + Uses the conversation locale by default since this is typically used for + user-facing messages. Args: domain: The translation domain (e.g., 'prompts', 'errors'). key: The translation key. - **kwargs: Currently unused, reserved for future string formatting support. + **kwargs: Variables for string formatting support. Returns: The translated string. """ - locale = self.get_locale() + locale = self.get_conversation_locale() value = self.get_translation(locale, domain, key) if isinstance(value, str): vars_ = {**self._get_default_variables(), **kwargs} diff --git a/backend/app/i18n/language_config.py b/backend/app/i18n/language_config.py index f2ef6e75b..d6573edd0 100644 --- a/backend/app/i18n/language_config.py +++ b/backend/app/i18n/language_config.py @@ -7,12 +7,12 @@ from pydantic import BaseModel, field_validator, model_validator from app.i18n.types import Locale +from app.i18n.types import is_locale_supported logger = logging.getLogger(__name__) _LANGUAGE_CONFIG_ENV = "BACKEND_LANGUAGE_CONFIG" - # Matches: # - single token: YYYY | YY | MM | DD # - or 2–3 tokens separated by the same -, / or . separator @@ -66,13 +66,39 @@ def validate_date_format(cls, value: str) -> str: class LanguageConfig(BaseModel): - default_locale: Locale + conversation_fallback_locale: Locale + """ + Specifies the language to be used for the conversation. + All user-facing messages must be generated in this language. + """ + + reporting_locale: Locale + """ + Defines the language used to generate and store report data (e.g., Experience and Skills Report) in the application state. + """ + available_locales: List[LocaleDateFormatEntry] + def is_locale_available(self, locale: Locale) -> bool: + """Whether the given locale is one of the configured available_locales.""" + return any(entry.locale == locale for entry in self.available_locales) + + @staticmethod + def basic_locale_validation(cfg: "LanguageConfig", name: str, locale: Locale): + if not is_locale_supported(locale): + raise ValueError(f"{name} {locale.value} is not a supported locale") + + if not cfg.is_locale_available(locale): + raise ValueError(f"{name} {locale.value} must be in available_locales") + @model_validator(mode='after') - def ensure_default_locale_present(self) -> 'LanguageConfig': - if not any(entry.locale == self.default_locale for entry in self.available_locales): - raise ValueError(f"default_locale {self.default_locale} is not present in available_locales") + def ensure_locales_are_valid(self) -> 'LanguageConfig': + # Validate conversation_fallback_locale + LanguageConfig.basic_locale_validation(self, "conversation_fallback_locale", self.conversation_fallback_locale) + + # Validate reporting_locale + LanguageConfig.basic_locale_validation(self, "reporting_locale", self.reporting_locale) + return self @@ -90,11 +116,16 @@ def _load_config_from_env() -> LanguageConfig: raise ValueError(f"Invalid JSON in {_LANGUAGE_CONFIG_ENV}: {e}") from e config = LanguageConfig.model_validate(parsed) - logger.info("Loaded BACKEND_LANGUAGE_CONFIG with %d available locales", len(config.available_locales)) + logger.info( + "Loaded BACKEND_LANGUAGE_CONFIG with conversation_fallback_locale=%s, reporting_locale=%s, %d available locales", + config.conversation_fallback_locale.value, + config.reporting_locale.value, + len(config.available_locales) + ) return config -def get_language_config() -> LanguageConfig: +def load_language_config_from_env() -> LanguageConfig: """ Returns the language config loaded from BACKEND_LANGUAGE_CONFIG. Caches the result. Raises if missing or invalid to mirror default-locale strictness. @@ -114,6 +145,3 @@ def reset_language_config_cache(): """ global _cached_config _cached_config = None - - - diff --git a/backend/app/i18n/locale_context.py b/backend/app/i18n/locale_context.py new file mode 100644 index 000000000..b91b818cf --- /dev/null +++ b/backend/app/i18n/locale_context.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from app.i18n.types import Locale + + +@dataclass(frozen=True) +class LocaleContext: + """ + Stores both conversation and reporting locales for a request context. + """ + + conversation_locale: Locale + """ + Specifies the language to be used for the conversation. + All user-facing messages must be generated in this language. + """ + + reporting_locale: Locale + """ + Defines the language used to generate and store report data (e.g., Experience and Skills Report) in the application state. + """ diff --git a/backend/app/i18n/locale_date_format.py b/backend/app/i18n/locale_date_format.py index c82cbf032..668425356 100644 --- a/backend/app/i18n/locale_date_format.py +++ b/backend/app/i18n/locale_date_format.py @@ -101,18 +101,18 @@ def reset_date_format_cache(): def get_locale_date_format(locale: Optional[Locale] = None) -> LocaleDateFormat: """ - Returns the date formats for the provided locale or the active locale if none is provided. + Returns the date formats for the provided locale or the active reporting locale if none is provided. Prefers configured formats (BACKEND_LANGUAGE_CONFIG) with validation, then built-in defaults. """ - current_locale = locale or get_i18n_manager().get_locale() + current_locale = locale or get_i18n_manager().get_reporting_locale() configured_map = _load_configured_map() if current_locale in configured_map: return configured_map[current_locale] cfg = get_application_config().language_config - if cfg.default_locale in configured_map: - return configured_map[cfg.default_locale] + if cfg.reporting_locale in configured_map: + return configured_map[cfg.reporting_locale] return _DEFAULT_LOCALE_DATE_FORMAT diff --git a/backend/app/i18n/test_i18n.py b/backend/app/i18n/test_i18n.py index 7538e6912..7299c2b59 100644 --- a/backend/app/i18n/test_i18n.py +++ b/backend/app/i18n/test_i18n.py @@ -48,15 +48,19 @@ def test_custom_provider_locale_setting(): # GIVEN a fresh manager. manager = get_i18n_manager() - # AND a locale is set. + # AND locales are set. given_locale = Locale.EN_US - manager.set_locale(given_locale) + manager.set_locales(given_locale, given_locale) - # WHEN getting the acual locale - actual_locale = get_i18n_manager().get_locale() + # WHEN getting the conversation locale + actual_conversation_locale = get_i18n_manager().get_conversation_locale() + + # AND getting the reporting locale + actual_reporting_locale = get_i18n_manager().get_reporting_locale() - # THEN it should be the same as the one set. - assert actual_locale == given_locale + # THEN both should be the same as the one set. + assert actual_conversation_locale == given_locale + assert actual_reporting_locale == given_locale @pytest.mark.parametrize("given_locale", SUPPORTED_LOCALES) @@ -97,8 +101,8 @@ def test_translation_service_nested_key(given_locale): Ensures that the global t() function can resolve nested keys. """ manager = get_i18n_manager() - # Reset to en-US for consistency or use a specific locale - manager.set_locale(given_locale) + # Set both conversation and reporting locales to the given locale + manager.set_locales(given_locale, given_locale) # "experience.noTitleProvidedYet" should resolve to "No title provided yet" in en-US val = t("messages", "experience.noTitleProvidedYet") diff --git a/backend/app/i18n/test_language_config.py b/backend/app/i18n/test_language_config.py index 3c4c30b43..956b815e9 100644 --- a/backend/app/i18n/test_language_config.py +++ b/backend/app/i18n/test_language_config.py @@ -3,7 +3,7 @@ from app.i18n.language_config import ( _LANGUAGE_CONFIG_ENV, - get_language_config, + load_language_config_from_env, reset_language_config_cache, ) from app.i18n.types import Locale @@ -29,7 +29,7 @@ def test_missing_env_raises_runtime_error(monkeypatch): # THEN loading the config should fail with a runtime error with pytest.raises(RuntimeError): - get_language_config() + load_language_config_from_env() def test_invalid_json_raises_value_error(monkeypatch): @@ -38,13 +38,14 @@ def test_invalid_json_raises_value_error(monkeypatch): # THEN loading the config should fail with a value error with pytest.raises(ValueError): - get_language_config() + load_language_config_from_env() def test_invalid_date_format_rejected(monkeypatch): # GIVEN a config with an invalid date_format token cfg = { - "default_locale": "en-US", + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-US", "available_locales": [ {"locale": "en-US", "date_format": "YYYY/AA"}, # AA is invalid ], @@ -52,13 +53,14 @@ def test_invalid_date_format_rejected(monkeypatch): _set_config(monkeypatch, __import__("json").dumps(cfg)) with pytest.raises(ValueError): - get_language_config() + load_language_config_from_env() def test_duplicate_token_type_rejected(monkeypatch): # GIVEN a config with duplicate year token types cfg = { - "default_locale": "en-US", + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-US", "available_locales": [ {"locale": "en-US", "date_format": "YYYY-YY"}, ], @@ -66,22 +68,24 @@ def test_duplicate_token_type_rejected(monkeypatch): _set_config(monkeypatch, __import__("json").dumps(cfg)) with pytest.raises(ValueError): - get_language_config() + load_language_config_from_env() def test_single_token_allowed(monkeypatch): # GIVEN a config with a single-token format (year-only) cfg = { - "default_locale": "en-US", + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-US", "available_locales": [ {"locale": "en-US", "date_format": "YYYY"}, ], } _set_config(monkeypatch, __import__("json").dumps(cfg)) - cfg_obj = get_language_config() + cfg_obj = load_language_config_from_env() - assert cfg_obj.default_locale == Locale.EN_US + assert cfg_obj.conversation_fallback_locale == Locale.EN_US + assert cfg_obj.reporting_locale == Locale.EN_US assert len(cfg_obj.available_locales) == 1 assert cfg_obj.available_locales[0].date_format.upper() == "YYYY" @@ -89,23 +93,25 @@ def test_single_token_allowed(monkeypatch): def test_lowercase_tokens_allowed(monkeypatch): # GIVEN a config with lowercase tokens (thanks to IGNORECASE) cfg = { - "default_locale": "en-US", + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-US", "available_locales": [ {"locale": "en-US", "date_format": "dd/mm/yyyy"}, ], } _set_config(monkeypatch, __import__("json").dumps(cfg)) - cfg_obj = get_language_config() + cfg_obj = load_language_config_from_env() - assert cfg_obj.default_locale == Locale.EN_US + assert cfg_obj.conversation_fallback_locale == Locale.EN_US assert cfg_obj.available_locales[0].date_format == "dd/mm/yyyy" -def test_default_locale_must_be_in_available_locales(monkeypatch): - # GIVEN default_locale that is not listed in available_locales +def test_fallback_locale_must_be_in_available_locales(monkeypatch): + # GIVEN conversation_fallback_locale that is not listed in available_locales cfg = { - "default_locale": "en-US", + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-GB", "available_locales": [ {"locale": "en-GB", "date_format": "DD/MM/YYYY"}, ], @@ -113,13 +119,29 @@ def test_default_locale_must_be_in_available_locales(monkeypatch): _set_config(monkeypatch, __import__("json").dumps(cfg)) with pytest.raises(ValueError): - get_language_config() + load_language_config_from_env() + + +def test_reporting_locale_must_be_in_available_locales(monkeypatch): + # GIVEN reporting_locale that is not listed in available_locales + cfg = { + "conversation_fallback_locale": "en-US", + "reporting_locale": "es-ES", + "available_locales": [ + {"locale": "en-US", "date_format": "MM/DD/YYYY"}, + ], + } + _set_config(monkeypatch, __import__("json").dumps(cfg)) + + with pytest.raises(ValueError): + load_language_config_from_env() def test_successful_load(monkeypatch): # GIVEN a valid configuration cfg = { - "default_locale": "en-US", + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-US", "available_locales": [ {"locale": "en-US", "date_format": "MM/DD/YYYY"}, {"locale": "en-GB", "date_format": "DD/MM/YYYY"}, @@ -127,7 +149,8 @@ def test_successful_load(monkeypatch): } _set_config(monkeypatch, __import__("json").dumps(cfg)) - cfg_obj = get_language_config() + cfg_obj = load_language_config_from_env() - assert cfg_obj.default_locale == Locale.EN_US + assert cfg_obj.conversation_fallback_locale == Locale.EN_US + assert cfg_obj.reporting_locale == Locale.EN_US assert {e.locale for e in cfg_obj.available_locales} == {Locale.EN_US, Locale.EN_GB} diff --git a/backend/app/i18n/test_locale_context.py b/backend/app/i18n/test_locale_context.py new file mode 100644 index 000000000..ac8c03f22 --- /dev/null +++ b/backend/app/i18n/test_locale_context.py @@ -0,0 +1,132 @@ +""" +Tests for LocaleContext and dual-locale functionality. +""" +import pytest + +from app.i18n.locale_context import LocaleContext +from app.i18n.translation_service import get_i18n_manager +from app.i18n.types import Locale + + +class TestLocaleContext: + """Tests for LocaleContext class""" + + def test_locale_context_creation(self): + """ + GIVEN conversation and reporting locales + WHEN creating a LocaleContext + THEN both locales are correctly stored + """ + context = LocaleContext( + conversation_locale=Locale.EN_US, + reporting_locale=Locale.ES_ES + ) + + assert context.conversation_locale == Locale.EN_US + assert context.reporting_locale == Locale.ES_ES + + def test_locale_context_immutable(self): + """ + GIVEN a LocaleContext + WHEN attempting to modify its fields + THEN an error is raised (frozen dataclass) + """ + context = LocaleContext( + conversation_locale=Locale.EN_US, + reporting_locale=Locale.ES_ES + ) + + with pytest.raises(Exception): # FrozenInstanceError + context.conversation_locale = Locale.EN_GB # type: ignore + + def test_locale_context_same_locales(self): + """ + GIVEN the same locale for both conversation and reporting + WHEN creating a LocaleContext + THEN both fields contain the same locale + """ + context = LocaleContext( + conversation_locale=Locale.EN_US, + reporting_locale=Locale.EN_US + ) + + assert context.conversation_locale == context.reporting_locale + + +class TestI18nManagerDualLocale: + """Tests for I18nManager dual-locale methods""" + + def test_set_locales_atomicity(self): + """ + GIVEN an I18nManager + WHEN calling set_locales with conversation and reporting locales + THEN both locales are set atomically in the context + """ + manager = get_i18n_manager() + + result = manager.set_locales(Locale.EN_GB, Locale.ES_AR) + + assert isinstance(result, LocaleContext) + assert result.conversation_locale == Locale.EN_GB + assert result.reporting_locale == Locale.ES_AR + + def test_get_conversation_locale(self): + """ + GIVEN locales are set via set_locales + WHEN calling get_conversation_locale + THEN the conversation locale is returned + """ + manager = get_i18n_manager() + manager.set_locales(Locale.EN_US, Locale.ES_ES) + + actual = manager.get_conversation_locale() + + assert actual == Locale.EN_US + + def test_get_reporting_locale(self): + """ + GIVEN locales are set via set_locales + WHEN calling get_reporting_locale + THEN the reporting locale is returned + """ + manager = get_i18n_manager() + manager.set_locales(Locale.EN_US, Locale.ES_ES) + + actual = manager.get_reporting_locale() + + assert actual == Locale.ES_ES + + def test_different_conversation_and_reporting_locales(self): + """ + GIVEN different conversation and reporting locales + WHEN setting them via set_locales + THEN get_conversation_locale and get_reporting_locale return different values + """ + manager = get_i18n_manager() + manager.set_locales(Locale.EN_GB, Locale.ES_AR) + + conversation = manager.get_conversation_locale() + reporting = manager.get_reporting_locale() + + assert conversation == Locale.EN_GB + assert reporting == Locale.ES_AR + assert conversation != reporting + + def test_t_function_uses_conversation_locale(self, setup_application_config): + """ + GIVEN different conversation and reporting locales + WHEN calling the t() convenience function + THEN it uses the conversation locale + """ + manager = get_i18n_manager() + # Set EN_US for conversation, ES_ES for reporting + manager.set_locales(Locale.EN_US, Locale.ES_ES) + + # The t() method uses get_conversation_locale internally + # This test verifies it doesn't raise an error and uses conversation locale + result = manager.t("messages", "experience.noTitleProvidedYet") + + # Verify we got a valid translation (not the key itself) + assert result != "experience.noTitleProvidedYet" + assert isinstance(result, str) + assert len(result) > 0 diff --git a/backend/app/i18n/test_locale_date_format.py b/backend/app/i18n/test_locale_date_format.py index f7faddda5..e9c2239a0 100644 --- a/backend/app/i18n/test_locale_date_format.py +++ b/backend/app/i18n/test_locale_date_format.py @@ -6,8 +6,8 @@ get_locale_date_format, format_date_value_for_locale, ) -from app.i18n.language_config import LanguageConfig, LocaleDateFormatEntry from app.i18n.types import Locale +from app.i18n.language_config import LanguageConfig, LocaleDateFormatEntry @pytest.fixture(autouse=True) @@ -18,10 +18,28 @@ def _clear_cache(monkeypatch): reset_date_format_cache() -def _mock_app_config(monkeypatch, language_config: LanguageConfig): - """Helper to mock get_application_config with a given language_config.""" - fake_app_config = type("AppConfig", (), {"language_config": language_config})() - monkeypatch.setattr("app.i18n.locale_date_format.get_application_config", lambda: fake_app_config) +def _mock_app_config_with_locales(monkeypatch, available_locales: list): + """Helper to mock application config with specific locale date formats.""" + # Create a mock LanguageConfig + mock_language_config = LanguageConfig( + conversation_fallback_locale=available_locales[0].locale, + reporting_locale=available_locales[0].locale, + available_locales=available_locales + ) + + # Create a mock ApplicationConfig + mock_app_config = type("ApplicationConfig", (), { + "language_config": mock_language_config + })() + + # Mock get_application_config + monkeypatch.setattr("app.i18n.locale_date_format.get_application_config", lambda: mock_app_config) + + +def _mock_i18n_manager_locale(monkeypatch, locale: Locale): + """Helper to mock get_i18n_manager to return a specific locale.""" + fake_i18n_manager = type("I18nManager", (), {"get_reporting_locale": lambda: locale})() + monkeypatch.setattr("app.i18n.locale_date_format.get_i18n_manager", lambda: fake_i18n_manager) def test_derive_patterns_full_month_year_and_year_only_from_dd_mm_yyyy(): @@ -54,12 +72,12 @@ def test_derive_patterns_requires_year_and_month(): def test_get_locale_date_format_uses_configured_locale(monkeypatch): - # GIVEN a language_config with a specific format for EN_US - cfg = LanguageConfig( - default_locale=Locale.EN_US, - available_locales=[LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY")], - ) - _mock_app_config(monkeypatch, cfg) + # GIVEN EN_US locale with configured format MM/DD/YYYY + available_locales = [ + LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY") + ] + _mock_app_config_with_locales(monkeypatch, available_locales) + _mock_i18n_manager_locale(monkeypatch, Locale.EN_US) fmt = get_locale_date_format(Locale.EN_US) @@ -69,26 +87,26 @@ def test_get_locale_date_format_uses_configured_locale(monkeypatch): def test_get_locale_date_format_falls_back_to_default_when_locale_missing(monkeypatch): - # GIVEN config only has EN_US, but we request EN_GB - cfg = LanguageConfig( - default_locale=Locale.EN_US, - available_locales=[LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY")], - ) - _mock_app_config(monkeypatch, cfg) + # GIVEN EN_GB locale is requested with configured format DD/MM/YYYY + available_locales = [ + LocaleDateFormatEntry(locale=Locale.EN_GB, date_format="DD/MM/YYYY") + ] + _mock_app_config_with_locales(monkeypatch, available_locales) + _mock_i18n_manager_locale(monkeypatch, Locale.EN_GB) fmt = get_locale_date_format(Locale.EN_GB) - # THEN we fall back to default_locale's pattern - assert fmt.full == "MM/DD/YYYY" + # THEN we get the EN_GB configured pattern + assert fmt.full == "DD/MM/YYYY" def test_format_date_value_for_locale_full_date_en_us(monkeypatch): - # GIVEN EN_US config with full pattern MM/DD/YYYY - cfg = LanguageConfig( - default_locale=Locale.EN_US, - available_locales=[LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY")], - ) - _mock_app_config(monkeypatch, cfg) + # GIVEN EN_US locale with full pattern MM/DD/YYYY + available_locales = [ + LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY") + ] + _mock_app_config_with_locales(monkeypatch, available_locales) + _mock_i18n_manager_locale(monkeypatch, Locale.EN_US) value = "2020/06/05" # canonical (YYYY/MM/DD) formatted = format_date_value_for_locale(value, locale=Locale.EN_US) @@ -97,12 +115,12 @@ def test_format_date_value_for_locale_full_date_en_us(monkeypatch): def test_format_date_value_for_locale_year_month_only(monkeypatch): - # GIVEN EN_US config with MM/DD/YYYY (month_year derived as MM/YYYY) - cfg = LanguageConfig( - default_locale=Locale.EN_US, - available_locales=[LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY")], - ) - _mock_app_config(monkeypatch, cfg) + # GIVEN EN_US locale with MM/DD/YYYY (month_year derived as MM/YYYY) + available_locales = [ + LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY") + ] + _mock_app_config_with_locales(monkeypatch, available_locales) + _mock_i18n_manager_locale(monkeypatch, Locale.EN_US) value = "2020/06" formatted = format_date_value_for_locale(value, locale=Locale.EN_US) @@ -111,12 +129,12 @@ def test_format_date_value_for_locale_year_month_only(monkeypatch): def test_format_date_value_for_locale_year_only(monkeypatch): - # GIVEN EN_US config with year-only pattern - cfg = LanguageConfig( - default_locale=Locale.EN_US, - available_locales=[LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY")], - ) - _mock_app_config(monkeypatch, cfg) + # GIVEN EN_US locale with year-only pattern + available_locales = [ + LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY") + ] + _mock_app_config_with_locales(monkeypatch, available_locales) + _mock_i18n_manager_locale(monkeypatch, Locale.EN_US) value = "2020" formatted = format_date_value_for_locale(value, locale=Locale.EN_US) @@ -125,12 +143,12 @@ def test_format_date_value_for_locale_year_only(monkeypatch): def test_format_date_value_for_locale_preserves_special_values(monkeypatch): - # GIVEN any valid config - cfg = LanguageConfig( - default_locale=Locale.EN_US, - available_locales=[LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY")], - ) - _mock_app_config(monkeypatch, cfg) + # GIVEN any valid locale + available_locales = [ + LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY") + ] + _mock_app_config_with_locales(monkeypatch, available_locales) + _mock_i18n_manager_locale(monkeypatch, Locale.EN_US) assert format_date_value_for_locale(None, locale=Locale.EN_US) is None assert format_date_value_for_locale("", locale=Locale.EN_US) == "" diff --git a/backend/app/i18n/translation_service.py b/backend/app/i18n/translation_service.py index ba62f86ce..304684c70 100644 --- a/backend/app/i18n/translation_service.py +++ b/backend/app/i18n/translation_service.py @@ -13,7 +13,10 @@ def get_i18n_manager() -> I18nManager: def t(domain: str, key: str, fallback_message: str = "", **kwargs) -> str: """ - Retrieves a translated and formatted string. + Retrieves a translated and formatted string using the conversation locale. + + Uses the conversation locale by default since this is typically used for + user-facing messages. Args: domain: The translation domain (e.g., 'prompts', 'errors'). @@ -26,7 +29,7 @@ def t(domain: str, key: str, fallback_message: str = "", **kwargs) -> str: """ i18n_manager = get_i18n_manager() - locale = i18n_manager.get_locale() + locale = i18n_manager.get_conversation_locale() raw_string = i18n_manager.get_translation(locale, domain, key, fallback_message=fallback_message) try: diff --git a/backend/app/server.py b/backend/app/server.py index 1e71f82c9..5cd14fa10 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -19,7 +19,7 @@ from app.vector_search.skill_search_routes import add_skill_search_routes from app.vector_search.validate_taxonomy_model import validate_taxonomy_model from app.version.version_routes import add_version_routes -from app.i18n.language_config import get_language_config +from app.i18n.language_config import load_language_config_from_env from contextlib import asynccontextmanager @@ -74,7 +74,8 @@ def setup_sentry(): else: logging.warning("BACKEND_SENTRY_DSN environment variable is not set. Sentry will not be initialized") else: - logging.warning("BACKEND_ENABLE_SENTRY environment variable is not set to True. Sentry will not be initialized") + logging.warning( + "BACKEND_ENABLE_SENTRY environment variable is not set to True. Sentry will not be initialized") ############################################ @@ -107,24 +108,28 @@ def setup_sentry(): logger.info(f"Backend URL: {backend_url}") if not os.getenv("TARGET_ENVIRONMENT_TYPE"): - raise ValueError("Mandatory TARGET_ENVIRONMENT_TYPE env variable is not set! Please set it to the target environment type as it is " - "required to set the CORS policy correctly for allowing local development if it is set to 'local' or 'dev'.") + raise ValueError( + "Mandatory TARGET_ENVIRONMENT_TYPE env variable is not set! Please set it to the target environment type as it is " + "required to set the CORS policy correctly for allowing local development if it is set to 'local' or 'dev'.") target_environment_type = os.getenv("TARGET_ENVIRONMENT_TYPE") logger.info(f"Target environment: {target_environment_type}") if not os.getenv("TARGET_ENVIRONMENT_NAME"): - raise ValueError("Mandatory TARGET_ENVIRONMENT_NAME env variable is not set! Please set it to the target environment name as it is " - "Required by sentry to know on which environment some Sentry Events occurred") + raise ValueError( + "Mandatory TARGET_ENVIRONMENT_NAME env variable is not set! Please set it to the target environment name as it is " + "Required by sentry to know on which environment some Sentry Events occurred") enable_sentry = os.getenv("BACKEND_ENABLE_SENTRY") if not enable_sentry: - raise ValueError("Mandatory BACKEND_ENABLE_SENTRY env variable is not set! Please set it to the either True or False") + raise ValueError( + "Mandatory BACKEND_ENABLE_SENTRY env variable is not set! Please set it to the either True or False") logger.info(f"BACKEND_ENABLE_SENTRY: {os.getenv('BACKEND_ENABLE_SENTRY')}") _metrics_enabled_str = os.getenv("BACKEND_ENABLE_METRICS") if not _metrics_enabled_str: - raise ValueError("Mandatory BACKEND_ENABLE_METRICS env variable is not set! Please set it to the either True or False") + raise ValueError( + "Mandatory BACKEND_ENABLE_METRICS env variable is not set! Please set it to the either True or False") logger.info(f"BACKEND_ENABLE_METRICS: {_metrics_enabled_str}") _default_country_of_user_str = os.getenv("DEFAULT_COUNTRY_OF_USER") @@ -184,9 +189,9 @@ def setup_sentry(): # Validate and load BACKEND_LANGUAGE_CONFIG environment variable try: - language_config = get_language_config() - logger.info(f"Loaded BACKEND_LANGUAGE_CONFIG with {len(language_config.available_locales)} available locales") - logger.info(f"Backend default locale: {language_config.default_locale}") + language_config = load_language_config_from_env() + logger.info(f"Backend reporting locale: {language_config.reporting_locale}") + logger.info(f"Backend conversation fallback language: {language_config.conversation_fallback_locale}") except RuntimeError as e: _error_message = f"BACKEND_LANGUAGE_CONFIG environment variable is not set! {e}" logger.error(_error_message) @@ -233,7 +238,8 @@ def setup_sentry(): # warning log when registration code bypass is enabled if _disable_registration_code: - logger.warning("GLOBAL_DISABLE_REGISTRATION_CODE is enabled - registered users can create preferences without invitation codes.") + logger.warning( + "GLOBAL_DISABLE_REGISTRATION_CODE is enabled - registered users can create preferences without invitation codes.") ################## # Set Sentry Context, after setting application config. @@ -241,7 +247,6 @@ def setup_sentry(): ################# set_sentry_contexts() - ############################################ # Initialize Feature Loader ############################################ @@ -297,8 +302,8 @@ async def lifespan(_app: FastAPI): # close the database connections application_db.client.close() - userdata_db.client.close() taxonomy_db.client.close() + userdata_db.client.close() metrics_db.client.close() logger.info("Shutting down completed.") diff --git a/backend/app/store/database_application_state_store_test.py b/backend/app/store/database_application_state_store_test.py index 38b136d3b..018d0ac46 100644 --- a/backend/app/store/database_application_state_store_test.py +++ b/backend/app/store/database_application_state_store_test.py @@ -1,6 +1,7 @@ import logging.config import random from datetime import datetime, timezone +from typing import Any, Generator from uuid import uuid4 import pytest @@ -23,19 +24,21 @@ from app.users.generate_session_id import generate_new_session_id from app.vector_search.esco_entities import SkillEntity, OccupationSkillEntity, OccupationEntity, AssociatedSkillEntity from common_libs.test_utilities.guard_caplog import guard_caplog -from conftest import random_db_name +from conftest import random_db_name, drop_database_and_close_client logger = logging.getLogger() @pytest.fixture(scope='function') -def in_memory_db(in_memory_mongo_server) -> AsyncIOMotorDatabase: - in_memory_db = AsyncIOMotorClient( +def in_memory_db(in_memory_mongo_server) -> Generator[AsyncIOMotorDatabase, Any, None]: + client = AsyncIOMotorClient( in_memory_mongo_server.connection_string, tlsAllowInvalidCertificates=True - ).get_database(random_db_name()) - - return in_memory_db + ) + db_name = random_db_name() + in_memory_db = client.get_database(db_name) + yield in_memory_db + drop_database_and_close_client(client, in_memory_mongo_server.connection_string, db_name) @pytest.fixture(scope='function') diff --git a/backend/app/users/cv/test_routes.py b/backend/app/users/cv/test_routes.py index 044ba3c72..72f71c339 100644 --- a/backend/app/users/cv/test_routes.py +++ b/backend/app/users/cv/test_routes.py @@ -78,7 +78,7 @@ def _mocked_get_cv_service() -> ICVUploadService: class TestUploadCV: @pytest.mark.asyncio - @pytest.mark.parametrize("mime", tuple(ALLOWED_MIME_TYPES)) + @pytest.mark.parametrize("mime", sorted(tuple(ALLOWED_MIME_TYPES))) async def test_success_allowed_mime_type(self, client_with_mocks: TestClientWithMocks, mime: str): client, _, mocked_user = client_with_mocks # GIVEN a valid file with an allowed MIME type and allowed extension @@ -97,7 +97,7 @@ async def test_success_allowed_mime_type(self, client_with_mocks: TestClientWith assert response.json() == expected_response @pytest.mark.asyncio - @pytest.mark.parametrize("ext", tuple(ALLOWED_EXTENSIONS)) + @pytest.mark.parametrize("ext", sorted(tuple(ALLOWED_EXTENSIONS))) async def test_success_allowed_extension(self, client_with_mocks: TestClientWithMocks, ext: str): client, _, mocked_user = client_with_mocks # GIVEN a valid file with an allowed extension and allowed MIME type diff --git a/backend/common_libs/test_utilities/setup_env_vars.py b/backend/common_libs/test_utilities/setup_env_vars.py index 96d6cf63c..1c7647583 100644 --- a/backend/common_libs/test_utilities/setup_env_vars.py +++ b/backend/common_libs/test_utilities/setup_env_vars.py @@ -73,13 +73,14 @@ def test_with_fixture(setup_env): - Can be used to set up environment variables for tests or other code - Can be used as a context manager to ensure teardown is always called """ - +import json import os from contextlib import contextmanager from bson import ObjectId from app.countries import Country +from app.i18n.types import Locale # local variable to store the original environment variables _original_env_vars = {} @@ -98,6 +99,18 @@ def setup_env_vars(*, env_vars: dict[str, str] = None): if env_vars is None: env_vars = {} # setup the environment variables + + language_config = json.dumps({ + "reporting_locale": Locale.EN_US.value, + "conversation_fallback_locale": Locale.EN_US.value, + "available_locales": [ + { + "locale": Locale.EN_US.value, + "date_format": "MM/DD/YYYY" + } + ] + }) + defaults = { 'TAXONOMY_MONGODB_URI': "foo", 'TAXONOMY_DATABASE_NAME': "foo", @@ -125,7 +138,7 @@ def setup_env_vars(*, env_vars: dict[str, str] = None): 'BACKEND_CV_STORAGE_BUCKET': "foo", 'BACKEND_CV_MAX_UPLOADS_PER_USER': "5", 'BACKEND_CV_RATE_LIMIT_PER_MINUTE': "10", - "BACKEND_LANGUAGE_CONFIG": '{"default_locale":"en-US","available_locales":[{"locale":"en-US","date_format":"MM/DD/YYYY"}]}', + "BACKEND_LANGUAGE_CONFIG": language_config, "GLOBAL_PRODUCT_NAME": "foo", 'GLOBAL_DISABLE_REGISTRATION_CODE': 'false', # Add more environment variables as needed here diff --git a/backend/conftest.py b/backend/conftest.py index 8a0376763..498d85ff9 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,7 +1,9 @@ import logging import platform +import resource from typing import Generator, Any +import pymongo import pytest from bson import ObjectId from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase @@ -30,12 +32,35 @@ features={}, enable_cv_upload=True, language_config=LanguageConfig( - default_locale=Locale.EN_US, - available_locales=[LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY")] + conversation_fallback_locale=Locale.EN_US, + reporting_locale=Locale.EN_US, + available_locales=[ + LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY") + ] ), app_name="Compass" ) +def _raise_open_file_limit(target: int = 10240): + """ + Raise this process's open-file soft limit (RLIMIT_NO FILE) towards `target`, bounded by the hard limit. + + The in-memory mongodb is launched as a subprocess and inherits this limit, so raising it here gives + mongodb enough file descriptors for WiredTiger's per-collection/per-index file handles. Never lowers + the limit and silently no-ops if the platform refuses the change. + """ + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + desired = target if hard == resource.RLIM_INFINITY else min(target, hard) + new_soft = max(soft, desired) + if new_soft == soft: + return + try: + resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard)) + logging.info("Raised open-file soft limit from %s to %s (hard limit: %s)", soft, new_soft, hard) + except (ValueError, OSError) as e: + logging.warning("Could not raise open-file soft limit from %s: %s", soft, e) + + @pytest.fixture(scope='session') def in_memory_mongo_server(): """ @@ -48,6 +73,15 @@ def in_memory_mongo_server(): from pymongo_inmemory import Mongod from pymongo_inmemory.context import Context + # Raise the open-file soft limit before starting mongodb. + # The mongodb subprocess inherits this process's RLIMIT_NO FILE. On systems with a low default + # (e.g., macOS' 256) mongodb runs out of file descriptors part-way through the suite and crashes + # ("Too many open files" / dropped connections), because WiredTiger keeps a file handle per + # collection and per index. We bump the soft limit up to the hard limit (capped at a sane value) + # so the test run has enough headroom. + _raise_open_file_limit() + # ----- + # There is a bug in pymongo_inmemory where the for ubuntu and debian it will fall back to mongo v4.0.23 # https://github.com/kaizendorks/pymongo_inmemory/issues/115 # so for these operating systems we manually set the os_name in the context constructor @@ -86,8 +120,29 @@ def random_db_name(): k=10)) # nosec B311 # random is used for testing purposes +def drop_database_and_close_client(client: AsyncIOMotorClient, connection_string: str, db_name: str): + """ + Tear down a per-test database created against the session-scoped in-memory MongoDB server. + + Every test creates a fresh database (see random_db_name()) with multiple collections and indexes. + Because the mongodb server lives for the whole test session, the WiredTiger file handles for those + collections/indexes keep accumulating and are never released until the database is dropped. On systems + with a low open-file limit (e.g., the macOS default of 256) this eventually causes mongod to fail with + "Too many open files" (TooManyFilesOpen / errno 24). + + Dropping the database releases mongodb's file handles; closing the client releases client-side sockets. + A short-lived synchronous client is used so this can run from a (synchronous) pytest finalizer without + depending on an active event loop. + """ + try: + with pymongo.MongoClient(connection_string, tlsAllowInvalidCertificates=True) as sync_client: + sync_client.drop_database(db_name) + finally: + client.close() + + @pytest.fixture(scope='function') -async def in_memory_userdata_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: +async def in_memory_userdata_database(in_memory_mongo_server, request) -> AsyncIOMotorDatabase: """ Fixture to create an in-memory userdata database. @@ -96,9 +151,11 @@ async def in_memory_userdata_database(in_memory_mongo_server) -> AsyncIOMotorDat :return: The mocked userdata database. """ - userdata_db = AsyncIOMotorClient(in_memory_mongo_server.connection_string, - tlsAllowInvalidCertificates=True).get_database(random_db_name()) - + client = AsyncIOMotorClient(in_memory_mongo_server.connection_string, + tlsAllowInvalidCertificates=True) + userdata_db = client.get_database(random_db_name()) + request.addfinalizer( + lambda: drop_database_and_close_client(client, in_memory_mongo_server.connection_string, userdata_db.name)) set_application_config(_mocked_application_config) await CompassDBProvider.initialize_userdata_mongo_db(userdata_db, logger=logging.getLogger(__name__)) logging.info(f"Created userdata database: {userdata_db.name}") @@ -106,7 +163,7 @@ async def in_memory_userdata_database(in_memory_mongo_server) -> AsyncIOMotorDat @pytest.fixture(scope='function') -async def in_memory_taxonomy_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: +async def in_memory_taxonomy_database(in_memory_mongo_server, request) -> AsyncIOMotorDatabase: """ Fixture to create an in-memory taxonomy database. @@ -115,16 +172,18 @@ async def in_memory_taxonomy_database(in_memory_mongo_server) -> AsyncIOMotorDat :return: The mocked taxonomy database. """ - taxonomy_db = AsyncIOMotorClient(in_memory_mongo_server.connection_string, - tlsAllowInvalidCertificates=True).get_database(random_db_name()) - + client = AsyncIOMotorClient(in_memory_mongo_server.connection_string, + tlsAllowInvalidCertificates=True) + taxonomy_db = client.get_database(random_db_name()) + request.addfinalizer( + lambda: drop_database_and_close_client(client, in_memory_mongo_server.connection_string, taxonomy_db.name)) await CompassDBProvider.initialize_application_mongo_db(taxonomy_db, logger=logging.getLogger(__name__)) logging.info(f"Created application database: {taxonomy_db.name}") return taxonomy_db @pytest.fixture(scope='function') -async def in_memory_application_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: +async def in_memory_application_database(in_memory_mongo_server, request) -> AsyncIOMotorDatabase: """ Fixture to create an in-memory application database. @@ -133,16 +192,18 @@ async def in_memory_application_database(in_memory_mongo_server) -> AsyncIOMotor :return: The mocked application database. """ - application_db = AsyncIOMotorClient(in_memory_mongo_server.connection_string, - tlsAllowInvalidCertificates=True).get_database(random_db_name()) - + client = AsyncIOMotorClient(in_memory_mongo_server.connection_string, + tlsAllowInvalidCertificates=True) + application_db = client.get_database(random_db_name()) + request.addfinalizer( + lambda: drop_database_and_close_client(client, in_memory_mongo_server.connection_string, application_db.name)) await CompassDBProvider.initialize_application_mongo_db(application_db, logger=logging.getLogger(__name__)) logging.info(f"Created application database: {application_db.name}") return application_db @pytest.fixture(scope='function') -async def in_memory_metrics_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: +async def in_memory_metrics_database(in_memory_mongo_server, request) -> AsyncIOMotorDatabase: """ Fixture to create an in-memory metrics database. @@ -150,9 +211,11 @@ async def in_memory_metrics_database(in_memory_mongo_server) -> AsyncIOMotorData :param in_memory_mongo_server: The in-memory MongoDB server. :return: The mocked metrics database. """ - metrics_db = AsyncIOMotorClient(in_memory_mongo_server.connection_string, - tlsAllowInvalidCertificates=True).get_database(random_db_name()) - + client = AsyncIOMotorClient(in_memory_mongo_server.connection_string, + tlsAllowInvalidCertificates=True) + metrics_db = client.get_database(random_db_name()) + request.addfinalizer( + lambda: drop_database_and_close_client(client, in_memory_mongo_server.connection_string, metrics_db.name)) await CompassDBProvider.initialize_metrics_mongo_db(metrics_db, logger=logging.getLogger(__name__)) logging.info(f"Created metrics database: {metrics_db.name}") return metrics_db diff --git a/backend/evaluation_tests/agent_director/llm_agent_director_scripted_user_test.py b/backend/evaluation_tests/agent_director/llm_agent_director_scripted_user_test.py index 80a627e0c..40447db1b 100644 --- a/backend/evaluation_tests/agent_director/llm_agent_director_scripted_user_test.py +++ b/backend/evaluation_tests/agent_director/llm_agent_director_scripted_user_test.py @@ -55,7 +55,7 @@ async def setup_agent_director(setup_search_services: Awaitable[SearchServices]) async def agent_director_exec(caplog: LogCaptureFixture, test_case: ScriptedUserEvaluationTestCase): print(f"Running test case {test_case.name}") - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) output_folder = os.path.join(os.getcwd(), 'test_output/llm_agent_director/scripted', test_case.name) execute_evaluated_agent = AgentDirectorExecutor(agent_director=agent_director) diff --git a/backend/evaluation_tests/agent_director/llm_router_test.py b/backend/evaluation_tests/agent_director/llm_router_test.py index feabae6f5..9005117a7 100644 --- a/backend/evaluation_tests/agent_director/llm_router_test.py +++ b/backend/evaluation_tests/agent_director/llm_router_test.py @@ -195,7 +195,7 @@ class RouterTestCase(CompassTestCase): ids=[case.name for case in get_test_cases_to_run(test_cases_router)]) async def test_router_extraction(test_case: RouterTestCase, caplog: pytest.LogCaptureFixture): logger = logging.getLogger() - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) with caplog.at_level(logging.DEBUG): guard_caplog(logger=logger, caplog=caplog) diff --git a/backend/evaluation_tests/agent_director/simple_agent_director_scripted_user_test.py b/backend/evaluation_tests/agent_director/simple_agent_director_scripted_user_test.py index 3ee18dc0b..83ab4b2a9 100644 --- a/backend/evaluation_tests/agent_director/simple_agent_director_scripted_user_test.py +++ b/backend/evaluation_tests/agent_director/simple_agent_director_scripted_user_test.py @@ -71,7 +71,7 @@ async def setup_agent_director(setup_search_services: Awaitable[SearchServices]) async def agent_director_exec(caplog, test_case): print(f"Running test case {test_case.name}") - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) output_folder = os.path.join(os.getcwd(), 'test_output/agent_director/scripted', test_case.name) execute_evaluated_agent = AgentDirectorExecutor(agent_director=agent_director) @@ -133,7 +133,7 @@ async def test_user_says_all_the_time_yes(caplog: LogCaptureFixture, evaluations=[] ) - get_i18n_manager().set_locale(Locale.EN_GB) + get_i18n_manager().set_locales(Locale.EN_GB, Locale.EN_GB) conversation_manager, agent_director_exec = await setup_agent_director await agent_director_exec(caplog, given_test_case) @@ -177,7 +177,7 @@ class AgentState: evaluations=[] ) - get_i18n_manager().set_locale(Locale.EN_GB) + get_i18n_manager().set_locales(Locale.EN_GB, Locale.EN_GB) conversation_manager, agent_director_exec = await setup_agent_director await agent_director_exec(caplog, given_test_case) @@ -231,7 +231,7 @@ class AgentState: evaluations=[] ) - get_i18n_manager().set_locale(Locale.ES_ES) + get_i18n_manager().set_locales(Locale.ES_ES, Locale.ES_ES) conversation_manager, agent_director_exec = await setup_agent_director await agent_director_exec(caplog, given_test_case) diff --git a/backend/evaluation_tests/app_conversation_e2e_test.py b/backend/evaluation_tests/app_conversation_e2e_test.py index 4f889cce5..4c8bd7546 100644 --- a/backend/evaluation_tests/app_conversation_e2e_test.py +++ b/backend/evaluation_tests/app_conversation_e2e_test.py @@ -44,7 +44,7 @@ async def test_main_app_chat( logger.info(f"Running test case {current_test_case.test_id}") session_id = get_random_session_id() locales_manager = get_i18n_manager() - locales_manager.set_locale(current_test_case.locale) + locales_manager.set_locales(current_test_case.locale, current_test_case.locale) search_services = await setup_search_services experience_pipeline_config = ExperiencePipelineConfig.model_validate( {"number_of_clusters": current_test_case.given_number_of_clusters, diff --git a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py index 1adf65b8b..64d8368f6 100644 --- a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py @@ -227,7 +227,7 @@ class _TestCaseDataExtraction(CompassTestCase): async def test_data_extraction(test_case: _TestCaseDataExtraction, caplog: pytest.LogCaptureFixture, setup_multi_locale_app_config): logger = logging.getLogger() - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) with caplog.at_level(logging.DEBUG): guard_caplog(logger=logger, caplog=caplog) diff --git a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py index 133013411..bf2bc799c 100644 --- a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py @@ -1008,7 +1008,7 @@ async def test_data_extraction(test_case: _TestCaseDataExtraction, caplog: pytes logger = logging.getLogger() with caplog.at_level(logging.DEBUG): guard_caplog(logger=logger, caplog=caplog) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # GIVEN the previous conversation context context: ConversationContext = ConversationContext( diff --git a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_scripted_user_test.py b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_scripted_user_test.py index 645bc9b10..2463631a0 100644 --- a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_scripted_user_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_scripted_user_test.py @@ -33,7 +33,7 @@ async def setup_collect_experiences_agent() -> tuple[ async def collect_experiences_exec(caplog: LogCaptureFixture, test_case: ScriptedUserEvaluationTestCase, country: Country): print(f"Running test case {test_case.name}") - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) output_folder = os.path.join(os.getcwd(), 'test_output/collect_experiences_agent/scripted', test_case.name) execute_evaluated_agent = CollectExperiencesAgentExecutor( conversation_manager=conversation_manager, diff --git a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_simulated_user_test.py b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_simulated_user_test.py index a2d7252da..d45f7b334 100644 --- a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_simulated_user_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_agent_simulated_user_test.py @@ -62,7 +62,7 @@ async def test_collect_experiences_agent_simulated_user(test_case: CollectExperi with caplog.at_level(logging.DEBUG): # Guards to ensure that the loggers are correctly set up, guard_caplog(logger=execute_evaluated_agent._agent._logger, caplog=caplog) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # Run the main test evaluation_result: ConversationEvaluationRecord = await conversation_test_function( diff --git a/backend/evaluation_tests/collect_experiences_agent/data_extraction/entity_extraction_tool_test.py b/backend/evaluation_tests/collect_experiences_agent/data_extraction/entity_extraction_tool_test.py index 9926a12f3..41023b40d 100644 --- a/backend/evaluation_tests/collect_experiences_agent/data_extraction/entity_extraction_tool_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/data_extraction/entity_extraction_tool_test.py @@ -392,7 +392,7 @@ async def test_entity_extraction_tool(test_case: EntityExtractionToolTestCase, c logger = logging.getLogger() with caplog.at_level(logging.DEBUG): guard_caplog(logger=logger, caplog=caplog) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # GIVEN users last input given_user_input = AgentInput(message=test_case.users_input) diff --git a/backend/evaluation_tests/collect_experiences_agent/data_extraction/intent_analyzer_llm_test.py b/backend/evaluation_tests/collect_experiences_agent/data_extraction/intent_analyzer_llm_test.py index 0600011b7..13d1fcd6c 100644 --- a/backend/evaluation_tests/collect_experiences_agent/data_extraction/intent_analyzer_llm_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/data_extraction/intent_analyzer_llm_test.py @@ -271,7 +271,7 @@ async def test_intent_analyzer_tool(test_case: IntentAnalyzerToolTestCase, caplo logger = logging.getLogger() with caplog.at_level(logging.DEBUG): guard_caplog(logger=logger, caplog=caplog) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # GIVEN the previous conversation context context: ConversationContext = ConversationContext( diff --git a/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py b/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py index 0986b565a..0c13039fb 100644 --- a/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py @@ -306,7 +306,7 @@ async def test_temporal_and_work_type_classification(test_case: TemporalAndWorkT logger = logging.getLogger() with caplog.at_level(logging.DEBUG): guard_caplog(logger=logger, caplog=caplog) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # GIVEN users last input given_user_input = test_case.users_input diff --git a/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py b/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py index c05641496..f090be2b9 100644 --- a/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py @@ -455,7 +455,7 @@ async def test_transition_decision_tool(test_case: TransitionDecisionToolTestCas logger = logging.getLogger() with caplog.at_level(logging.DEBUG): guard_caplog(logger=logger, caplog=caplog) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # GIVEN user's last input given_user_input = AgentInput(message=test_case.users_last_input) diff --git a/backend/evaluation_tests/conftest.py b/backend/evaluation_tests/conftest.py index dd8cfb8af..a90ae2859 100644 --- a/backend/evaluation_tests/conftest.py +++ b/backend/evaluation_tests/conftest.py @@ -73,13 +73,14 @@ def setup_multi_locale_app_config(): current = None language_config = LanguageConfig( - default_locale=Locale.EN_US, + conversation_fallback_locale=Locale.EN_US, + reporting_locale=Locale.EN_US, available_locales=[ LocaleDateFormatEntry(locale=Locale.EN_US, date_format="MM/DD/YYYY"), LocaleDateFormatEntry(locale=Locale.EN_GB, date_format="DD/MM/YYYY"), LocaleDateFormatEntry(locale=Locale.ES_AR, date_format="DD/MM/YYYY"), LocaleDateFormatEntry(locale=Locale.ES_ES, date_format="DD/MM/YYYY") - ], + ] ) if current is None: @@ -94,6 +95,7 @@ def setup_multi_locale_app_config(): cv_storage_bucket="test", features={}, language_config=language_config, + app_name="Compass Evaluation Tests" ) else: config = current.model_copy(update={"language_config": language_config}) diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py index b418c98ee..9aadd1ecd 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py @@ -285,7 +285,7 @@ async def test_experience_pipeline(test_case: ExperiencePipelineTestCase, setup_ config=given_config, search_services=search_services ) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # Set the capl-og at the level in question - 1 to ensure that the root logger is set to the correct level. # However, this is not enough as a logger can be set up in the agent in such a way that it does not propagate diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py index 398283601..83a69d082 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py @@ -105,7 +105,7 @@ async def test_relevant_occupations_classifier_llm(test_case: ContextualizationT # the log messages to the root logger. For this reason, we add additional guards. with caplog.at_level(logging.DEBUG): # Set the locale in which the test is running - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # Guards to ensure that the loggers are correctly setup, guard_caplog(logger=contextualization_llm._logger, caplog=caplog) diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_relevant_occupations_classifier_llm_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_relevant_occupations_classifier_llm_test.py index 2635f5bf7..86968f5ca 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_relevant_occupations_classifier_llm_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_relevant_occupations_classifier_llm_test.py @@ -229,7 +229,7 @@ async def test_relevant_occupations_classifier_llm(test_case: RelevantOccupation # the log messages to the root logger. For this reason, we add additional guards. with caplog.at_level(logging.DEBUG): # Set the locale in which the test is running - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # Guards to ensure that the loggers are correctly setup, guard_caplog(logger=relevant_occupations_classifier._logger, caplog=caplog) diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test.py index f4ce3606e..3dc2b7437 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test.py @@ -51,7 +51,7 @@ async def _test_occupation_inference_tool(test_case: InferOccupationToolTestCase # However, this is not enough as a logger can be set up in the agent in such a way that it does not propagate # the log messages to the root logger. For this reason, we add additional guards. with caplog.at_level(logging.INFO): - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # Guards to ensure that the loggers are correctly set up, guard_caplog(logger=tool._logger, caplog=caplog) diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/_relevant_skills_classifier_llm_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/_relevant_skills_classifier_llm_test.py index 0e201c85f..7140781ef 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/_relevant_skills_classifier_llm_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/_relevant_skills_classifier_llm_test.py @@ -172,7 +172,7 @@ async def test_relevance_classifier_llm(test_case: RelevantSkillsClassifierLLMTe # the log messages to the root logger. For this reason, we add additional guards. with caplog.at_level(logging.DEBUG): # Set which language the test is running in - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) # Guards to ensure that the loggers are correctly setup, guard_caplog(logger=relevant_skills_classifier._logger, caplog=caplog) diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py index 51a73575f..7836d11fb 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py @@ -62,7 +62,7 @@ class SkillLinkingToolTestCase(CompassTestCase): async def test_skill_linking_tool(test_case: SkillLinkingToolTestCase, setup_search_services: Awaitable[SearchServices], caplog): search_services = await setup_search_services # Given the occupation with it's associated skills - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) given_job_titles: list[str] = [] given_occupations_with_skills: list[OccupationSkillEntity] = [] if test_case.given_occupation_code: diff --git a/backend/evaluation_tests/qna_agent/qna_agent_test.py b/backend/evaluation_tests/qna_agent/qna_agent_test.py index d38529d02..11bb476ff 100644 --- a/backend/evaluation_tests/qna_agent/qna_agent_test.py +++ b/backend/evaluation_tests/qna_agent/qna_agent_test.py @@ -14,7 +14,7 @@ async def _evaluate_with_llm(prompt: str) -> str: - llm = GeminiGenerativeLLM(config=LLMConfig(model_name="gemini-1.5-pro-preview-0409")) + llm = GeminiGenerativeLLM(config=LLMConfig()) return (await llm.generate_content(prompt)).text @@ -152,7 +152,8 @@ async def _execute_agent(context: FakeConversationContext, agent, agent_input): @pytest.mark.repeat(3) @pytest.mark.evaluation_test async def test_qna_agent_responds_to_multiple_questions_in_a_row(fake_conversation_context: FakeConversationContext, - common_folder_path: str): + common_folder_path: str, + evals_setup): """ Tests the QnA agent with multiple questions in a row. """ app_name = _get_app_name() qna_agent = QnaAgent() diff --git a/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py b/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py index cdf988c48..f1ef0b3fa 100644 --- a/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py @@ -206,7 +206,7 @@ async def test_skills_explorer_agent_first_message(test_case, caplog: pytest.Log with caplog.at_level(logging.INFO): # Guards to ensure that the loggers are correctly set up, guard_caplog(logger=logger, caplog=caplog) - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) conversation_manager = ConversationMemoryManager(UNSUMMARIZED_WINDOW_SIZE, TO_BE_SUMMARIZED_WINDOW_SIZE) conversation_manager.set_state(state=ConversationMemoryManagerState(session_id=get_random_session_id())) diff --git a/backend/evaluation_tests/skill_explorer_agent/_responsibilities_extraction_tool_test.py b/backend/evaluation_tests/skill_explorer_agent/_responsibilities_extraction_tool_test.py index 4fcea4cf8..9c363cf5b 100644 --- a/backend/evaluation_tests/skill_explorer_agent/_responsibilities_extraction_tool_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/_responsibilities_extraction_tool_test.py @@ -150,7 +150,7 @@ class _TestCaseDataExtraction(CompassTestCase): @pytest.mark.parametrize('test_case', get_test_cases_to_run(test_cases_data_extraction), ids=[case.name for case in get_test_cases_to_run(test_cases_data_extraction)]) async def test_data_extraction(test_case: _TestCaseDataExtraction): - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) context: ConversationContext = ConversationContext() # GIVEN the previous conversation context diff --git a/backend/evaluation_tests/skill_explorer_agent/_sentence_decomposition_llm_test.py b/backend/evaluation_tests/skill_explorer_agent/_sentence_decomposition_llm_test.py index 09057cd97..70f58e7a8 100644 --- a/backend/evaluation_tests/skill_explorer_agent/_sentence_decomposition_llm_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/_sentence_decomposition_llm_test.py @@ -134,7 +134,7 @@ class _TestCaseSentenceDecomposition(CompassTestCase): @pytest.mark.parametrize('test_case', get_test_cases_to_run(test_cases_sentence_decomposition), ids=[case.name for case in get_test_cases_to_run(test_cases_sentence_decomposition)]) async def test_sentence_decomposition(test_case: _TestCaseSentenceDecomposition): - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) context: ConversationContext = ConversationContext() # GIVEN the previous conversation context diff --git a/backend/evaluation_tests/skill_explorer_agent/loop_detection_scripted_user_test.py b/backend/evaluation_tests/skill_explorer_agent/loop_detection_scripted_user_test.py index b8609f53c..029c4f2c6 100644 --- a/backend/evaluation_tests/skill_explorer_agent/loop_detection_scripted_user_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/loop_detection_scripted_user_test.py @@ -188,7 +188,7 @@ async def test_skills_explorer_agent_loop_detection_scripted( print(f"Running test case {test_case.name}") session_id = get_random_session_id() - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) output_folder = os.path.join( os.getcwd(), "test_output/skills_explorer_agent/loop_detection_scripted/", diff --git a/backend/evaluation_tests/skill_explorer_agent/loop_detection_test.py b/backend/evaluation_tests/skill_explorer_agent/loop_detection_test.py index 311a0b5ac..7ceda6794 100644 --- a/backend/evaluation_tests/skill_explorer_agent/loop_detection_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/loop_detection_test.py @@ -250,7 +250,7 @@ async def test_skills_explorer_agent_loop_detection( print(f"Running test case {test_case.name}") session_id = get_random_session_id() - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) output_folder = os.path.join( os.getcwd(), "test_output/skills_explorer_agent/loop_detection/", diff --git a/backend/evaluation_tests/skill_explorer_agent/skills_explorer_agent_simulated_user_test.py b/backend/evaluation_tests/skill_explorer_agent/skills_explorer_agent_simulated_user_test.py index 91b3f5dec..f23d4a4e6 100644 --- a/backend/evaluation_tests/skill_explorer_agent/skills_explorer_agent_simulated_user_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/skills_explorer_agent_simulated_user_test.py @@ -34,7 +34,7 @@ async def test_skills_explorer_agent_simulated_user(max_iterations: int, test_ca print(f"Running test case {test_case.name}") session_id = get_random_session_id() - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) output_folder = os.path.join(os.getcwd(), 'test_output/skills_explorer_agent/simulated_user/', test_case.name) # The conversation manager for this test diff --git a/backend/evaluation_tests/summarizer/summary_evaluator.py b/backend/evaluation_tests/summarizer/summary_evaluator.py index 47024abe4..261083c71 100644 --- a/backend/evaluation_tests/summarizer/summary_evaluator.py +++ b/backend/evaluation_tests/summarizer/summary_evaluator.py @@ -17,7 +17,7 @@ def __init__(self, criteria: EvaluationType): super().__init__(criteria) self.criteria = criteria # Use GeminiGenerativeLLM as the LLM for evaluation - self.llm = GeminiGenerativeLLM(config=LLMConfig(model_name="gemini-1.5-pro-preview-0409")) + self.llm = GeminiGenerativeLLM(config=LLMConfig()) async def evaluate(self, actual: SummaryEvaluationRecord) -> EvaluationResult: prompt = PromptGenerator.generate_summary_prompt(conversation=actual.generate_conversation(), diff --git a/backend/evaluation_tests/welcome_agent/welcome_agent_scripted_user_test.py b/backend/evaluation_tests/welcome_agent/welcome_agent_scripted_user_test.py index 5af42c975..756f20007 100644 --- a/backend/evaluation_tests/welcome_agent/welcome_agent_scripted_user_test.py +++ b/backend/evaluation_tests/welcome_agent/welcome_agent_scripted_user_test.py @@ -181,7 +181,7 @@ async def test_welcome_agent_scripted_user(evals_setup, max_iterations: int, as the agent is expected to complete the conversation at the last input from the user :return: """ - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) print(f"Running test case {test_case.name}") diff --git a/backend/evaluation_tests/welcome_agent/welcome_agent_simulated_user_test.py b/backend/evaluation_tests/welcome_agent/welcome_agent_simulated_user_test.py index c07909bc5..9c58f11b9 100644 --- a/backend/evaluation_tests/welcome_agent/welcome_agent_simulated_user_test.py +++ b/backend/evaluation_tests/welcome_agent/welcome_agent_simulated_user_test.py @@ -43,7 +43,7 @@ async def test_welcome_agent_simulated_user(evals_setup, max_iterations: int, te print(f"Running test case {test_case.name}") session_id = hash(test_case.name) % 10 ** 10 - get_i18n_manager().set_locale(test_case.locale) + get_i18n_manager().set_locales(test_case.locale, test_case.locale) output_folder = os.path.join(os.getcwd(), 'test_output/welcome_agent/simulated_user/', test_case.name) # The conversation manager for this test diff --git a/config/default.json b/config/default.json index 4b99b7e6b..38aceb090 100644 --- a/config/default.json +++ b/config/default.json @@ -86,7 +86,8 @@ ] }, "conversation": { - "default_locale": "en-US", + "conversation_fallback_locale": "en-US", + "reporting_locale": "en-US", "available_locales": [ { "locale": "en-US", @@ -289,6 +290,5 @@ } } } - } } diff --git a/iac/.env.example b/iac/.env.example index 315be954f..73b6885d0 100644 --- a/iac/.env.example +++ b/iac/.env.example @@ -7,7 +7,7 @@ GCP_OAUTH_CLIENT_SECRET= ################### # Backend ################### -# Taxonomy databse settings +# Taxonomy database settings TAXONOMY_MONGODB_URI= TAXONOMY_DATABASE_NAME= TAXONOMY_MODEL_ID= @@ -35,9 +35,23 @@ EMBEDDINGS_MODEL_NAME= # Sentry settings BACKEND_SENTRY_DSN= BACKEND_ENABLE_SENTRY= +BACKEND_SENTRY_CONFIG= # Country settings -DEFAULT_COUNTRY_OF_USER=Unspecified +DEFAULT_COUNTRY_OF_USER= + +# JSON like configurations of the available features. +BACKEND_FEATURES=/.*/ + +# JSON like configuration for the experience pipeline +BACKEND_EXPERIENCE_PIPELINE_CONFIG=/.*/ + +# CV limits (optional; digits) +BACKEND_CV_MAX_UPLOADS_PER_USER=/^[0-9]*$/ +BACKEND_CV_RATE_LIMIT_PER_MINUTE=/^[0-9]*$/ + +# backend language configurations. +BACKEND_LANGUAGE_CONFIG= ################### # Frontend @@ -45,10 +59,11 @@ DEFAULT_COUNTRY_OF_USER=Unspecified # Metrics settings FRONTEND_ENABLE_METRICS= +FRONTEND_METRICS_CONFIG=/.*/ # Sentry settings -FRONTEND_SENTRY_DSN= FRONTEND_ENABLE_SENTRY= +FRONTEND_SENTRY_DSN= FRONTEND_SENTRY_CONFIG= # PII settings @@ -56,26 +71,42 @@ SENSITIVE_PERSONAL_DATA_RSA_ENCRYPTION_KEY= SENSITIVE_PERSONAL_DATA_RSA_ENCRYPTION_KEY_ID= # Invitation codes -FRONTEND_LOGIN_CODE= -FRONTEND_REGISTRATION_CODE= -GLOBAL_DISABLE_LOGIN_CODE= -GLOBAL_DISABLE_REGISTRATION_CODE= -FRONTEND_DISABLE_REGISTRATION= -FRONTEND_DISABLE_SOCIAL_AUTH= - -# Branding settings -GLOBAL_PRODUCT_NAME= -FRONTEND_BROWSER_TAB_TITLE= -FRONTEND_META_DESCRIPTION= -FRONTEND_LOGO_URL= -FRONTEND_FAVICON_URL= -FRONTEND_APP_ICON_URL= -FRONTEND_THEME_CSS_VARIABLES= -FRONTEND_SEO= - -# Skills report output settings -FRONTEND_SKILLS_REPORT_OUTPUT_CONFIG= +FRONTEND_LOGIN_CODE=/.*/ +FRONTEND_REGISTRATION_CODE=/.*/ +FRONTEND_DISABLE_REGISTRATION=/.*/ + +FRONTEND_DISABLE_SOCIAL_AUTH=/.*/ + +# Other enabled Features +# JSON like configurations of the available features. +FRONTEND_FEATURES=/.*/ + +FRONTEND_DEFAULT_LOCALE= +FRONTEND_SUPPORTED_LOCALES=["",""] + +# Branding +FRONTEND_BROWSER_TAB_TITLE=/.*/ +FRONTEND_META_DESCRIPTION=/.*/ +FRONTEND_LOGO_URL=/.*/ +FRONTEND_FAVICON_URL=/.*/ +FRONTEND_APP_ICON_URL=/.*/ +FRONTEND_THEME_CSS_VARIABLES=/.*/ +FRONTEND_SEO=/.*/ + +FRONTEND_SKILLS_REPORT_OUTPUT_CONFIG=/.*/ # Sensitive data fields -FRONTEND_SENSITIVE_DATA_FIELDS= +FRONTEND_SENSITIVE_DATA_FIELDS=/.*/ + +################### +# Global (Both frontend & Backend) +################### +GLOBAL_PRODUCT_NAME=/.*/ + +GLOBAL_DISABLE_LOGIN_CODE=/.*/ +GLOBAL_DISABLE_REGISTRATION_CODE=/.*/ + +# CV Upload feature flag (optional, defaults to false if not set) +# This flag controls both frontend UI visibility and backend route registration +GLOBAL_ENABLE_CV_UPLOAD=/.*/ diff --git a/iac/templates/env.template b/iac/templates/env.template index f74d9a130..fd2be561a 100644 --- a/iac/templates/env.template +++ b/iac/templates/env.template @@ -49,6 +49,9 @@ BACKEND_EXPERIENCE_PIPELINE_CONFIG=/.*/ BACKEND_CV_MAX_UPLOADS_PER_USER=/^[0-9]*$/ BACKEND_CV_RATE_LIMIT_PER_MINUTE=/^[0-9]*$/ +# backend language configurations. +BACKEND_LANGUAGE_CONFIG= + ################### # Frontend ################### @@ -69,27 +72,19 @@ SENSITIVE_PERSONAL_DATA_RSA_ENCRYPTION_KEY_ID= # Invitation codes FRONTEND_LOGIN_CODE=/.*/ FRONTEND_REGISTRATION_CODE=/.*/ -GLOBAL_DISABLE_LOGIN_CODE=/.*/ -GLOBAL_DISABLE_REGISTRATION_CODE=/.*/ FRONTEND_DISABLE_REGISTRATION=/.*/ FRONTEND_DISABLE_SOCIAL_AUTH=/.*/ # Other enabled Features -# CV Upload feature flag (optional, defaults to false if not set) -# This flag controls both frontend UI visibility and backend route registration -GLOBAL_ENABLE_CV_UPLOAD=/.*/ - # JSON like configurations of the available features. FRONTEND_FEATURES=/.*/ -BACKEND_LANGUAGE_CONFIG={"default_locale":"","available_locales":[{"locale":"","date_format":""}]} FRONTEND_DEFAULT_LOCALE= FRONTEND_SUPPORTED_LOCALES=["",""] # Branding -GLOBAL_PRODUCT_NAME=/.*/ FRONTEND_BROWSER_TAB_TITLE=/.*/ FRONTEND_META_DESCRIPTION=/.*/ FRONTEND_LOGO_URL=/.*/ @@ -102,3 +97,15 @@ FRONTEND_SKILLS_REPORT_OUTPUT_CONFIG=/.*/ # Sensitive data fields FRONTEND_SENSITIVE_DATA_FIELDS=/.*/ + +################### +# Global (Both frontend & Backend) +################### +GLOBAL_PRODUCT_NAME=/.*/ + +GLOBAL_DISABLE_LOGIN_CODE=/.*/ +GLOBAL_DISABLE_REGISTRATION_CODE=/.*/ + +# CV Upload feature flag (optional, defaults to false if not set) +# This flag controls both frontend UI visibility and backend route registration +GLOBAL_ENABLE_CV_UPLOAD=/.*/ \ No newline at end of file