diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b278de8e..541c10d1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,26 +16,26 @@ jobs: uses: ./.github/workflows/frontend-ci.yml secrets: inherit with: - upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} backend-ci: uses: ./.github/workflows/backend-ci.yml secrets: inherit with: - upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} config-ci: uses: ./.github/workflows/config-ci.yml secrets: inherit - if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} deploy: needs: [ frontend-ci, backend-ci, config-ci ] - if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} uses: ./.github/workflows/deploy.yml secrets: inherit with: - env-name: dev-swahilipot + env-name: dev-njila env-type: dev target-git-sha: ${{ github.sha }} target-git-branch: ${{ github.ref_name }} diff --git a/backend/.env.example b/backend/.env.example index c1eb0b48..73b441a4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,6 +16,11 @@ BACKEND_ENABLE_METRICS=False USERDATA_MONGODB_URI=mongodb+srv://:@/?retryWrites=true&w=majority&appName=Compass-Dev USERDATA_DATABASE_NAME=compass-dev +# Career Explorer database settings (conversations + sector chunks) +CAREER_EXPLORER_MONGODB_URI=mongodb+srv://:@/?retryWrites=true&w=majority&appName=Compass-Dev +CAREER_EXPLORER_DATABASE_NAME=compass-career-explorer +# CAREER_EXPLORER_CONFIG={"sectors":[{"name":"Agriculture","description":"...","file":"agriculture.md"}],"country":"Zambia"} + # Google Cloud Platform settings GOOGLE_APPLICATION_CREDENTIALS=keys/credentials.json VERTEX_API_REGION=us-central1 diff --git a/backend/app/agent/agent_director/_llm_router.py b/backend/app/agent/agent_director/_llm_router.py index 87ca149c..f48025d9 100644 --- a/backend/app/agent/agent_director/_llm_router.py +++ b/backend/app/agent/agent_director/_llm_router.py @@ -91,16 +91,6 @@ def __init__(self, logger: Logger): "Help me understand what I'm looking for in a job.", "I'd like to explore career preferences."] ) - recommender_advisor_agent_tasks = AgentTasking( - agent_type_name=AgentType.RECOMMENDER_ADVISOR_AGENT.value, - tasks="Present personalized career, job, and training recommendations based on user's " - "preferences, skills, and labor market data. Help user explore and discuss " - "recommendations, compare options, and identify next steps.", - examples=["Show me my career recommendations.", - "What careers match my preferences?", - "I'd like to see job opportunities.", - "What training programs are available for me?"] - ) farewell_agent_tasks = AgentTasking( agent_type_name=AgentType.FAREWELL_AGENT.value, tasks="Ends the conversation with the user.", @@ -116,7 +106,7 @@ def __init__(self, logger: Logger): # Define the tasks for each phase self._agent_tasking_for_phase: dict[ConversationPhase, list[AgentTasking]] = { ConversationPhase.INTRO: [welcome_agent_tasks, default_agent_tasks], - ConversationPhase.COUNSELING: [welcome_agent_tasks, experiences_explorer_agent_tasks, preference_elicitation_agent_tasks, recommender_advisor_agent_tasks, default_agent_tasks], + ConversationPhase.COUNSELING: [welcome_agent_tasks, experiences_explorer_agent_tasks, preference_elicitation_agent_tasks, default_agent_tasks], ConversationPhase.CHECKOUT: [farewell_agent_tasks, default_agent_tasks] } # The default agent for each phase, if the model fails to respond or returns an invalid agent type, diff --git a/backend/app/agent/agent_director/llm_agent_director.py b/backend/app/agent/agent_director/llm_agent_director.py index efb7fcbe..5e124037 100644 --- a/backend/app/agent/agent_director/llm_agent_director.py +++ b/backend/app/agent/agent_director/llm_agent_director.py @@ -120,24 +120,16 @@ async def get_suitable_agent_type(self, *, if phase == ConversationPhase.INTRO: return AgentType.WELCOME_AGENT + # In the consulting phase, the agent type is determined by the user's intent. + # Matching and recommendation are detached from the conversation flow (Phase 1 simplification). if phase == ConversationPhase.COUNSELING: # Priority 1: Explicit phase skip overrides everything skip_phase = self._state.skip_to_phase - if skip_phase == JourneyPhase.RECOMMENDATION: - self._logger.info( - "Step-skip: routing to RECOMMENDER_ADVISOR_AGENT (skip_to_phase=RECOMMENDATION)" - ) - return AgentType.RECOMMENDER_ADVISOR_AGENT if skip_phase == JourneyPhase.PREFERENCE_ELICITATION: self._logger.info( "Step-skip: routing to PREFERENCE_ELICITATION_AGENT (skip_to_phase=PREFERENCE_ELICITATION)" ) return AgentType.PREFERENCE_ELICITATION_AGENT - if skip_phase == JourneyPhase.MATCHING: - self._logger.info( - "Step-skip: routing to RECOMMENDER_ADVISOR_AGENT (skip_to_phase=MATCHING)" - ) - return AgentType.RECOMMENDER_ADVISOR_AGENT # Priority 2: Deterministic sub-phase routing sub_phase = self._state.counseling_sub_phase @@ -145,8 +137,6 @@ async def get_suitable_agent_type(self, *, return AgentType.EXPLORE_EXPERIENCES_AGENT if sub_phase == CounselingSubPhase.PREFERENCE_ELICITATION: return AgentType.PREFERENCE_ELICITATION_AGENT - if sub_phase == CounselingSubPhase.RECOMMENDER_ADVISOR: - return AgentType.RECOMMENDER_ADVISOR_AGENT # Fallback: use the LLM router (should not normally reach here) self._logger.warning("Unexpected counseling sub-phase state, falling back to LLM router") @@ -176,19 +166,19 @@ def _get_new_phase(self, agent_output: AgentOutput) -> ConversationPhase: and agent_output.finished): return ConversationPhase.COUNSELING - if current_phase == ConversationPhase.COUNSELING and agent_output.finished: - if agent_output.agent_type == AgentType.EXPLORE_EXPERIENCES_AGENT: - self._state.counseling_sub_phase = CounselingSubPhase.PREFERENCE_ELICITATION - self._logger.info("COUNSELING sub-phase advanced: EXPLORE_EXPERIENCES -> PREFERENCE_ELICITATION") - return ConversationPhase.COUNSELING - - if agent_output.agent_type == AgentType.PREFERENCE_ELICITATION_AGENT: - self._state.counseling_sub_phase = CounselingSubPhase.RECOMMENDER_ADVISOR - self._logger.info("COUNSELING sub-phase advanced: PREFERENCE_ELICITATION -> RECOMMENDER_ADVISOR") - return ConversationPhase.COUNSELING + if (current_phase == ConversationPhase.COUNSELING + and agent_output.finished + and agent_output.agent_type == AgentType.EXPLORE_EXPERIENCES_AGENT): + self._state.counseling_sub_phase = CounselingSubPhase.PREFERENCE_ELICITATION + self._logger.info( + "ExploreExperiencesAgent finished, advancing to PREFERENCE_ELICITATION sub-phase" + ) + return ConversationPhase.COUNSELING - if agent_output.agent_type == AgentType.RECOMMENDER_ADVISOR_AGENT: - return ConversationPhase.CHECKOUT + if (current_phase == ConversationPhase.COUNSELING + and agent_output.finished + and agent_output.agent_type == AgentType.PREFERENCE_ELICITATION_AGENT): + return ConversationPhase.CHECKOUT if (current_phase == ConversationPhase.CHECKOUT and agent_output.agent_type == AgentType.FAREWELL_AGENT @@ -245,7 +235,7 @@ async def execute(self, user_input: AgentInput) -> AgentOutput: self._state.sticky_turn_counter = 1 else: self._state.sticky_turn_counter += 1 - + # Set agent_type in context for observability logging agent_type_ctx_var.set(suitable_agent_type.value if suitable_agent_type else ":none:") @@ -268,7 +258,7 @@ async def execute(self, user_input: AgentInput) -> AgentOutput: self._state.skip_to_phase = None self._logger.info("Step-skip: target agent finished, clearing skip_to_phase") - # Update the conversation phase + # Update the conversation phase (and counseling sub-phase when applicable) new_phase = self._get_new_phase(agent_output) self._logger.debug("Transitioned phase from %s --to-> %s", self._state.current_phase, new_phase) @@ -277,9 +267,13 @@ async def execute(self, user_input: AgentInput) -> AgentOutput: self._state.current_phase = new_phase phase_ctx_var.set(new_phase.value if new_phase else ":none:") + if (agent_output.finished + and agent_output.agent_type == AgentType.EXPLORE_EXPERIENCES_AGENT + and self._state.counseling_sub_phase == CounselingSubPhase.PREFERENCE_ELICITATION): + transitioned_to_new_phase = True + user_input = AgentInput(message="", is_artificial=True) + elif transitioned_to_new_phase: if get_application_config().inline_phase_transition: - # Inline transition: skip the (silence) loop re-invocation. - # The next user message will be routed deterministically via sub-phase. transitioned_to_new_phase = False else: user_input = AgentInput( diff --git a/backend/app/agent/agent_types.py b/backend/app/agent/agent_types.py index 7378b53d..a7b1e2af 100644 --- a/backend/app/agent/agent_types.py +++ b/backend/app/agent/agent_types.py @@ -19,6 +19,9 @@ class AgentType(Enum): RECOMMENDER_ADVISOR_AGENT = "RecommenderAdvisorAgent" FAREWELL_AGENT = "FarewellAgent" QNA_AGENT = "QnaAgent" + CAREER_READINESS_AGENT = "CareerReadinessAgent" + CAREER_EXPLORER_AGENT = "CareerExplorerAgent" + ASK_ME_ANYTHING_AGENT = "AskMeAnythingAgent" class AgentInput(BaseModel): diff --git a/backend/app/agent/ask_me_anything_agent.py b/backend/app/agent/ask_me_anything_agent.py new file mode 100644 index 00000000..18caf329 --- /dev/null +++ b/backend/app/agent/ask_me_anything_agent.py @@ -0,0 +1,208 @@ +""" +Ask Me Anything agent — acts as a platform receptionist. + +Composition-based (not inheriting from Agent), operating outside the AgentDirector pipeline. +Knows all platform modules and returns navigation suggestions alongside its response. +""" +import logging +import time +from textwrap import dedent + +from pydantic import BaseModel + +from app.agent.agent_types import AgentInput, AgentOutput, AgentType, LLMStats, AgentOutputWithReasoning +from app.agent.constants import get_localized_platform_modules +from app.agent.llm_caller import LLMCaller +from app.agent.prompt_template.locale_style import get_language_style +from app.agent.simple_llm_agent.prompt_response_template import get_json_response_instructions +from app.ask_me_anything.types import SuggestedAction +from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter +from app.conversation_memory.conversation_memory_types import ConversationContext +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG +from common_libs.llm.schema_builder import with_response_schema + + +class AMAModelResponse(BaseModel): + """ + Structured LLM response for the AMA agent. + + Field order is intentional: reasoning first (context), finished next, then + message and suggested_actions last (depend on the above). + """ + reasoning: str + """Chain of Thought reasoning""" + + finished: bool + """Always False for this agent — the conversation is open-ended""" + + message: str + """The agent's message to the user""" + + suggested_actions: list[SuggestedAction] + """1–3 platform navigation suggestions relevant to the user's question""" + + class Config: + extra = "forbid" + + +def _build_modules_description() -> str: + """Build localized module descriptions based on current locale.""" + lines = [] + for mod in get_localized_platform_modules(): + lines.append(f' - "{mod["name"]}" (route: {mod["route"]}): {mod["description"]}') + return "\n".join(lines) + + +def _build_system_instructions() -> str: + """Build locale-aware system instructions dynamically based on current locale.""" + response_part = get_json_response_instructions() + language_style = get_language_style(with_locale=True, for_json_output=True) + modules_desc = _build_modules_description() + + # Extend the standard response instructions with suggested_actions + extended_response_part = dedent("""\ + {response_part} + + In addition to the standard fields, your JSON response MUST also include: + - suggested_actions: A JSON array of 1 to 3 navigation suggestions. + Each item must have exactly two fields: + - label: A short, friendly button label (e.g. "Explore Career Readiness") + - route: The exact frontend route string (e.g. "/career-readiness") + Only suggest routes from the platform modules listed above. + Choose the most relevant modules based on what the user is asking about. + Always include at least one suggestion. + """).format(response_part=response_part) + + template = dedent("""\ + You are a friendly platform guide for the Compass career platform. + Your role is to help users understand what the platform offers and direct them + to the most relevant section for their needs. + + {language_style} + + You do NOT need to know the exact content of each module you only need to understand + what each module does and guide users there when relevant. + + # Platform Modules + The platform has the following modules. These are the ONLY routes you may suggest: + {modules_desc} + + # Your Behaviour + - Greet new users warmly and briefly describe what you can help with. + - Listen to what the user wants to do or learn, then explain which module(s) are most relevant. + - Always include 1 to 3 suggested_actions pointing to the most relevant modules. + - Keep responses short and conversational (under 150 words). + - Do not invent features that do not exist on the platform. + - Do not format your message with markdown. + - The "finished" flag must always be false — this conversation is open-ended. + + {extended_response_part} + """) + + return template.format( + modules_desc=modules_desc, + language_style=language_style, + extended_response_part=extended_response_part, + ) + + +class AskMeAnythingAgent: + """ + Platform receptionist agent. + + Uses composition — wraps GeminiGenerativeLLM + LLMCaller. Operates outside the + AgentDirector pipeline, similar to CareerReadinessAgent. + """ + + def __init__(self) -> None: + self._logger = logging.getLogger(AskMeAnythingAgent.__name__) + self._llm_caller: LLMCaller[AMAModelResponse] = LLMCaller[AMAModelResponse]( + model_response_type=AMAModelResponse + ) + + def _get_llm(self) -> GeminiGenerativeLLM: + """Get LLM instance with locale-aware system instructions.""" + system_instructions = _build_system_instructions() + config = LLMConfig( + generation_config=( + LOW_TEMPERATURE_GENERATION_CONFIG + | JSON_GENERATION_CONFIG + | with_response_schema(AMAModelResponse) + ) + ) + return GeminiGenerativeLLM(system_instructions=system_instructions, config=config) + + @property + def system_instructions(self) -> str: + """Get current system instructions (locale-aware).""" + return _build_system_instructions() + + async def execute(self, user_input: AgentInput, context: ConversationContext) -> AgentOutput: + """ + Process user input and return the agent's response with navigation suggestions. + + :param user_input: The user's message + :param context: Conversation context built from prior history + :return: AgentOutput carrying the message; suggested_actions stored in metadata + """ + agent_start_time = time.time() + + msg = user_input.message.strip() or "(silence)" + + model_response: AMAModelResponse | None + llm_stats_list: list[LLMStats] + + try: + llm = self._get_llm() + model_response, llm_stats_list = await self._llm_caller.call_llm( + llm=llm, + llm_input=ConversationHistoryFormatter.format_for_agent_generative_prompt( + model_response_instructions=get_json_response_instructions(), + context=context, + user_input=msg, + ), + logger=self._logger, + ) + except Exception as e: + self._logger.exception("Error calling LLM: %s", e) + model_response = None + llm_stats_list = [] + + if model_response is None: + from app.i18n.translation_service import t + model_response = AMAModelResponse( + reasoning="LLM call failed", + finished=False, + message=t("messages", "askMeAnything.errorRetry", fallback_message="I'm having some trouble right now. Please try again in a moment."), + suggested_actions=[ + SuggestedAction( + label=t("messages", "askMeAnything.platformModules.home.name", fallback_message="Go to Home"), + route="/" + ), + ], + ) + + agent_end_time = time.time() + return AgentOutputWithReasoning( + message_for_user=model_response.message.strip('"'), + finished=False, # AMA conversation is always open-ended + reasoning=model_response.reasoning, + agent_type=AgentType.ASK_ME_ANYTHING_AGENT, + agent_response_time_in_sec=round(agent_end_time - agent_start_time, 2), + llm_stats=llm_stats_list, + metadata={"suggested_actions": [a.model_dump() for a in model_response.suggested_actions]}, + ) + + async def generate_intro_message(self, context: ConversationContext) -> AgentOutput: + """ + Generate the opening greeting shown when the page loads. + + :param context: An empty conversation context + :return: The agent's introductory output + """ + artificial_input = AgentInput( + message="(silence)", + is_artificial=True, + ) + return await self.execute(artificial_input, context) diff --git a/backend/app/agent/career_explorer_agent/__init__.py b/backend/app/agent/career_explorer_agent/__init__.py new file mode 100644 index 00000000..5c987ba3 --- /dev/null +++ b/backend/app/agent/career_explorer_agent/__init__.py @@ -0,0 +1,4 @@ +from .agent import CareerExplorerAgent +from .sector_search_service import SectorChunkEntity, SectorSearchService + +__all__ = ["CareerExplorerAgent", "SectorChunkEntity", "SectorSearchService"] diff --git a/backend/app/agent/career_explorer_agent/agent.py b/backend/app/agent/career_explorer_agent/agent.py new file mode 100644 index 00000000..170af19b --- /dev/null +++ b/backend/app/agent/career_explorer_agent/agent.py @@ -0,0 +1,109 @@ +import logging +import time + +from app.agent.agent import Agent +from app.agent.agent_types import AgentInput, AgentOutput, AgentType, LLMStats, AgentOutputWithReasoning +from app.app_config import get_application_config +from app.i18n.translation_service import t + +from .sector_relevance_classifier import SectorRelevance, SectorRelevanceClassifier +from .priority_sector_explorer import PrioritySectorExplorer +from .non_priority_sector_explorer import NonPrioritySectorExplorer +from .sector_search_service import SectorSearchService + + +def _get_sectors_list() -> str: + config = get_application_config() + sectors = config.career_explorer_config.sectors + if not sectors: + return "" + return "\n".join(f"{s['name']} — {s.get('description', '')}" for s in sectors) + + +def _get_welcome_message() -> str: + config = get_application_config() + sectors = config.career_explorer_config.sectors + sectors_list = _get_sectors_list() + country_name = config.career_explorer_config.country + return t( + "messages", + "careerExplorer.welcomeMessage", + "Welcome to the Career Explorer! Based on {country}'s development priorities, there are {sector_count} key sectors where TEVET graduates are in high demand:\n\n{sectors_list}\n\nBesides these priority sectors, we can also discuss any careers you want to explore. Which sector interests you most?", + country=country_name, + sector_count=len(sectors) if sectors else 0, + sectors_list=sectors_list, + ) + + +def _construct_output( + message: str, + finished: bool, + agent_start: float, + reasoning: str = "", + llm_stats: list[LLMStats] | None = None, + metadata: dict | None = None, +) -> AgentOutput: + return AgentOutputWithReasoning( + message_for_user=message, + finished=finished, + reasoning=reasoning, + agent_type=AgentType.CAREER_EXPLORER_AGENT, + agent_response_time_in_sec=round(time.time() - agent_start, 2), + llm_stats=llm_stats or [], + metadata=metadata, + ) + + +class CareerExplorerAgent(Agent): + def __init__(self, sector_search_service: SectorSearchService): + super().__init__( + agent_type=AgentType.CAREER_EXPLORER_AGENT, + is_responsible_for_conversation_history=False, + ) + self._sector_search = sector_search_service + self._classifier = SectorRelevanceClassifier() + self._priority_explorer = PrioritySectorExplorer(sector_search_service) + self._non_priority_explorer = NonPrioritySectorExplorer() + self._logger = logging.getLogger(self.__class__.__name__) + + async def execute(self, user_input: AgentInput, context) -> AgentOutput: + agent_start = time.time() + + msg = (user_input.message or "").strip() + if msg in ("", "(silence)"): + return _construct_output( + _get_welcome_message(), + finished=False, + agent_start=agent_start, + ) + + relevance, reasoning, classifier_stats = await self._classifier.classify(user_input=msg, context=context) + + self._logger.info( + "Routing to %s explorer (reasoning: %s)", + relevance.value, + reasoning or "(none)", + ) + + if relevance == SectorRelevance.PRIORITY_SECTOR: + message, finished, reasoning, explorer_stats = await self._priority_explorer.explore(msg, context) + grounding_metadata = None + else: + message, finished, reasoning, explorer_stats, grounding_metadata = await self._non_priority_explorer.explore( + msg, context + ) + + all_stats = classifier_stats + explorer_stats + + metadata = None + if grounding_metadata: + metadata = {"grounding_metadata": grounding_metadata.model_dump()} + + return _construct_output( + message, + finished=finished, + agent_start=agent_start, + reasoning=reasoning, + llm_stats=all_stats, + metadata=metadata, + ) diff --git a/backend/app/agent/career_explorer_agent/non_priority_sector_explorer.py b/backend/app/agent/career_explorer_agent/non_priority_sector_explorer.py new file mode 100644 index 00000000..f9e5cade --- /dev/null +++ b/backend/app/agent/career_explorer_agent/non_priority_sector_explorer.py @@ -0,0 +1,186 @@ +""" +Explorer for non-priority sectors using Google Search grounding. Answers about careers outside priority sectors. +Uses google-genai SDK (Tool with google_search) as vertexai's google_search_retrieval is deprecated. +""" + +import logging +import os +from textwrap import dedent + +from google import genai +from google.genai import types +from google.genai.types import GenerateContentConfig, GoogleSearch, HttpOptions, Tool, GroundingMetadata +from common_libs.text_formatters import extract_json +from common_libs.text_formatters.extract_json import NoJSONFound + +from app.agent.agent_types import LLMStats +from app.agent.prompt_template.locale_style import get_language_style +from app.agent.prompt_template.agent_prompt_template import STD_AGENT_CHARACTER +from app.agent.simple_llm_agent.llm_response import ModelResponse +from app.agent.simple_llm_agent.prompt_response_template import get_conversation_finish_instructions, get_json_response_instructions +from app.app_config import get_application_config +from app.i18n.translation_service import t +from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter +from app.agent.config import AgentsConfig +from common_libs.llm.utils import extract_grounding_metadata_from_genai_response +from common_libs.llm.models_utils import DEFAULT_VERTEX_API_REGION + + +def _build_non_priority_instructions() -> str: + config = get_application_config() + sectors = config.career_explorer_config.sectors + sector_names = [s["name"] for s in sectors] if sectors else [] + sector_list_str = ", ".join(sector_names) if sector_names else "the priority sectors" + country_name = config.career_explorer_config.country + language_style = get_language_style(with_locale=True) + finish_instructions = get_conversation_finish_instructions( + "When the user explicitly indicates they are done or want to exit" + ) + return dedent(f"""\ + + # Role + You are a career exploration counselor helping people in {country_name} explore careers and sectors. + You answer questions about ANY career, sector, or employment field using your knowledge and web search. + + {language_style} + + {STD_AGENT_CHARACTER} + + # Guard Rails (stay on topic) + - Only answer questions related to: careers, sectors, employment, jobs, industries, or work in {country_name} + - If the user asks about something completely unrelated (e.g. sports, recipes, entertainment, + politics, personal advice), politely redirect: "I'm here to help with career exploration and + sector information. Would you like to know about careers in {sector_list_str} or another field?" + - Keep answers focused on career and employment context + - Prefer {country_name}-relevant information when available + + # Instructions + - You answer questions about ANY career or sector, not just TEVET-related or priority sectors + - CRITICAL: When asked about careers outside priority sectors ({sector_list_str}), you MUST use the Google Search tool that is available to you + - The Google Search tool will search the web and provide you with current information - use it for every non-priority sector question + - After the search tool provides results, answer the question using those search results and your knowledge of the topic + - DO NOT say "I'll search", "let me search", "I need to search", or "bear with me" - just use the tool silently and answer + - DO NOT ask permission - use the search tool immediately and provide the answer + - DO NOT deflect or redirect when asked about non-priority sectors - always use search and answer + - Always use the search tool for non-priority sectors - never assume you have current information + - Be encouraging and conversational + - If asked about priority sectors ({sector_list_str}), suggest the user can get detailed info there + + {finish_instructions} + + """).format(sector_list_str=sector_list_str) + + +def _llm_input_to_contents(llm_input) -> list[types.Content]: + contents = [] + for turn in llm_input.turns: + role = "user" if turn.role == "user" else "model" + part = types.Part.from_text(text=turn.content) + contents.append(types.Content(role=role, parts=[part])) + return contents + + +class NonPrioritySectorExplorer: + def __init__(self): + self._logger = logging.getLogger(self.__class__.__name__) + + async def explore( + self, + user_input: str, + context, + ) -> tuple[str, bool, str, list[LLMStats], GroundingMetadata | None]: + full_instructions = _build_non_priority_instructions() + example_response = ModelResponse( + reasoning="The user asked about a non-priority sector, so I used Google Search to find current information and provided a substantive answer.", + finished=False, + message="Software development is a growing field in Zambia with opportunities in web development, mobile apps, and enterprise software. According to recent job postings, roles include Software Developer, Full-Stack Developer, and Mobile App Developer.", + ) + llm_input = ConversationHistoryFormatter.format_for_agent_generative_prompt( + model_response_instructions=get_json_response_instructions(examples=[example_response]), + context=context, + user_input=user_input, + ) + contents = _llm_input_to_contents(llm_input) + + project = os.getenv("GOOGLE_CLOUD_PROJECT") + location = os.getenv("VERTEX_API_REGION") or DEFAULT_VERTEX_API_REGION + client = genai.Client( + vertexai=True, + project=project, + location=location, + http_options=HttpOptions(api_version="v1"), + ) + + config = GenerateContentConfig( + system_instruction=full_instructions, + tools=[Tool(google_search=GoogleSearch())], + temperature=1.0, + ) + + llm_stats: list[LLMStats] = [] + grounding_metadata: GroundingMetadata | None = None + model_response: ModelResponse | None = None + + try: + response = await client.aio.models.generate_content( + model=AgentsConfig.default_model, + contents=contents, + config=config, + ) + llm_stats.append( + LLMStats( + prompt_token_count=getattr(response.usage_metadata, "prompt_token_count", 0) or 0, + response_token_count=getattr(response.usage_metadata, "candidates_token_count", 0) or 0, + response_time_in_sec=0, + ) + ) + raw_text = response.text + if raw_text: + try: + model_response = extract_json.extract_json(raw_text, ModelResponse) + except NoJSONFound: + model_response = ModelResponse( + reasoning="", + finished=False, + message=raw_text.strip(), + ) + + grounding_metadata = extract_grounding_metadata_from_genai_response(response) + except Exception as e: + self._logger.exception("Non-priority explorer LLM call failed: %s", e) + llm_stats.append( + LLMStats(error=str(e), prompt_token_count=0, response_token_count=0, response_time_in_sec=0) + ) + + if grounding_metadata: + self._logger.info( + "Web search for query '%s': search_queries=%s, sources_count=%d", + user_input, + grounding_metadata.web_search_queries, + len(grounding_metadata.grounding_chunks), + ) + for i, chunk in enumerate(grounding_metadata.grounding_chunks[:5]): + if chunk.web: + uri = chunk.web.uri + title = chunk.web.title + self._logger.info(" Source %d: %s | %s", i + 1, title or "(no title)", uri[:80] + "..." if len(uri) > 80 else uri) + else: + self._logger.info("Web search for query '%s': no grounding metadata returned", user_input) + + if model_response is None: + error_msg = t("messages", "careerExplorer.errorRetry", "I'm having trouble right now. Could you try again?") + return error_msg, False, "", llm_stats, None + + message = model_response.message.strip('"').strip() if model_response.message else "" + if not message: + self._logger.warning("Model returned empty message, using fallback") + error_msg = t("messages", "careerExplorer.errorRetry", "I'm having trouble right now. Could you try again?") + return error_msg, False, "", llm_stats, None + + return ( + message, + model_response.finished, + model_response.reasoning, + llm_stats, + grounding_metadata, + ) diff --git a/backend/app/agent/career_explorer_agent/priority_sector_explorer.py b/backend/app/agent/career_explorer_agent/priority_sector_explorer.py new file mode 100644 index 00000000..f624a8f2 --- /dev/null +++ b/backend/app/agent/career_explorer_agent/priority_sector_explorer.py @@ -0,0 +1,124 @@ +""" +Explorer for priority sectors using RAG (vector search). Answers based on retrieved content only. +""" + +import logging +from textwrap import dedent + +from app.agent.agent_types import LLMStats +from app.agent.llm_caller import LLMCaller +from app.agent.prompt_template.locale_style import get_language_style +from app.agent.prompt_template.agent_prompt_template import STD_AGENT_CHARACTER +from app.agent.simple_llm_agent.llm_response import ModelResponse +from app.agent.simple_llm_agent.prompt_response_template import get_conversation_finish_instructions, get_json_response_instructions +from app.app_config import get_application_config +from app.i18n.translation_service import t +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG +from common_libs.llm.schema_builder import with_response_schema +from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter + +from .sector_search_service import SectorChunkEntity, SectorSearchService + + +def _build_base_instructions(retrieved_content: str) -> str: + config = get_application_config() + sectors = config.career_explorer_config.sectors + sector_names = [s["name"] for s in sectors] if sectors else [] + sector_list_str = ", ".join(sector_names) if sector_names else "the priority sectors" + country_name = config.default_country_of_user.value + language_style = get_language_style(with_locale=True) + finish_instructions = get_conversation_finish_instructions( + "When the user explicitly indicates they are done or want to exit" + ) + return dedent(f"""\ + + # Role + You are a career exploration counselor helping TEVET graduates in {country_name} discover career opportunities + in priority sectors. You start by suggesting topics and wait for the user to choose or ask questions. + + {language_style} + + {STD_AGENT_CHARACTER} + + # Instructions + - Start by suggesting the priority sectors ({sector_list_str}) and ask which interests the user most + - Answer questions based ONLY on the retrieved content provided below + - Stay on topic: focus on the priority sectors ({sector_list_str}) + - If asked about something outside this scope, politely redirect to the priority sectors + - Be encouraging and conversational + - Do not invent information not in the provided content + + # Retrieved Content + Use the following content to answer user questions. Cite specifics when relevant. + + {{retrieved_content}} + + {finish_instructions} + + """).format(sector_list_str=sector_list_str, retrieved_content=retrieved_content) + + +def _format_chunks(chunks: list[SectorChunkEntity]) -> str: + if not chunks: + config = get_application_config() + sectors = config.career_explorer_config.sectors + sector_names = [s["name"] for s in sectors] if sectors else [] + sector_list_str = ", ".join(sector_names) if sector_names else "the priority sectors" + return f"(No relevant content found. Suggest the user pick a sector or ask a question related to {sector_list_str}.)" + parts = [] + for c in chunks: + parts.append(f"[{c.sector}]\n{c.text}") + return "\n\n---\n\n".join(parts) + + +class PrioritySectorExplorer: + def __init__(self, sector_search_service: SectorSearchService): + self._sector_search = sector_search_service + self._llm_config = LLMConfig( + generation_config=LOW_TEMPERATURE_GENERATION_CONFIG + | JSON_GENERATION_CONFIG + | with_response_schema(ModelResponse) + ) + self._llm_caller = LLMCaller[ModelResponse](model_response_type=ModelResponse) + self._logger = logging.getLogger(self.__class__.__name__) + + async def explore( + self, + user_input: str, + context, + ) -> tuple[str, bool, str, list[LLMStats]]: + chunks = await self._sector_search.search(query=user_input, k=5) + retrieved = _format_chunks(chunks) + full_instructions = _build_base_instructions(retrieved).format(retrieved_content=retrieved) + + self._logger.info( + "Priority sector RAG for query '%s': found %d chunks", + user_input, + len(chunks), + ) + for i, chunk in enumerate(chunks): + preview = chunk.text[:200] + "..." if len(chunk.text) > 200 else chunk.text + self._logger.info(" Chunk %d [%s] (score=%.4f): %s", i + 1, chunk.sector, chunk.score, preview.replace("\n", " ")) + + llm = GeminiGenerativeLLM(system_instructions=full_instructions, config=self._llm_config) + model_response, llm_stats = await self._llm_caller.call_llm( + llm=llm, + llm_input=ConversationHistoryFormatter.format_for_agent_generative_prompt( + model_response_instructions=get_json_response_instructions(), + context=context, + user_input=user_input, + ), + logger=self._logger, + ) + + if model_response is None: + error_msg = t("messages", "careerExplorer.errorRetry", "I'm having trouble right now. Could you try again?") + return error_msg, False, "", llm_stats + + return ( + model_response.message.strip('"'), + model_response.finished, + model_response.reasoning, + llm_stats, + ) diff --git a/backend/app/agent/career_explorer_agent/sector_relevance_classifier.py b/backend/app/agent/career_explorer_agent/sector_relevance_classifier.py new file mode 100644 index 00000000..09a9325a --- /dev/null +++ b/backend/app/agent/career_explorer_agent/sector_relevance_classifier.py @@ -0,0 +1,95 @@ +""" +LLM-based classifier that decides whether user input relates to priority sectors +or non-priority sectors. Replaces RAG score threshold to avoid embedding bias. +""" + +import logging +from enum import Enum +from textwrap import dedent + +from pydantic import BaseModel, Field + +from app.agent.config import AgentsConfig +from app.agent.llm_caller import LLMCaller +from app.app_config import get_application_config +from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter +from app.conversation_memory.conversation_memory_manager import ConversationContext +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, ZERO_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG +from common_libs.llm.schema_builder import with_response_schema + +MAX_REASONING_LENGTH = 150 + +class SectorRelevance(str, Enum): + PRIORITY_SECTOR = "PRIORITY_SECTOR" + NON_PRIORITY_SECTOR = "NON_PRIORITY_SECTOR" + + +class SectorRelevanceClassification(BaseModel): + relevance: SectorRelevance + reasoning: str = Field(default="") + + class Config: + extra = "forbid" + + +def _build_classifier_instructions() -> str: + config = get_application_config() + sectors = config.career_explorer_config.sectors + sector_names = [s["name"] for s in sectors] if sectors else [] + sector_list_str = ", ".join(sector_names) if sector_names else "the priority sectors" + country_name = config.career_explorer_config.country + + return dedent(f"""\ + Classify whether the user's message is about careers in one of {country_name}'s priority sectors or about careers outside them. + + Priority sectors: {sector_list_str} + + Return PRIORITY_SECTOR if the user is asking about, expressing interest in, or discussing careers in any of these sectors (or closely related sub-fields, e.g. irrigation within Agriculture, solar within Energy). + Return NON_PRIORITY_SECTOR if the user is asking about careers in other sectors (e.g. aeronautical engineering, IT, healthcare) or general career topics not covered by the priority sectors. + + Be inclusive: if the user mentions multiple sectors and at least one is a priority sector, return PRIORITY_SECTOR. + Keep reasoning under {MAX_REASONING_LENGTH} characters. + """) + + +class SectorRelevanceClassifier: + def __init__(self): + self._llm_config = LLMConfig( + language_model_name=AgentsConfig.deep_reasoning_model, + generation_config=ZERO_TEMPERATURE_GENERATION_CONFIG + | JSON_GENERATION_CONFIG + | with_response_schema(SectorRelevanceClassification), + ) + self._llm_caller = LLMCaller[SectorRelevanceClassification](model_response_type=SectorRelevanceClassification) + self._logger = logging.getLogger(self.__class__.__name__) + + async def classify( + self, + user_input: str, + context: ConversationContext, + ) -> tuple[SectorRelevance, str, list]: + llm = GeminiGenerativeLLM( + system_instructions=_build_classifier_instructions(), + config=self._llm_config, + ) + result, stats = await self._llm_caller.call_llm( + llm=llm, + llm_input=ConversationHistoryFormatter.format_for_agent_generative_prompt( + model_response_instructions="Classify the user's message. Return JSON with relevance and reasoning.", + context=context, + user_input=user_input, + ), + logger=self._logger, + ) + if result is None: + self._logger.warning("Sector relevance classification failed, defaulting to NON_PRIORITY_SECTOR") + return SectorRelevance.NON_PRIORITY_SECTOR, "", stats + reasoning = (result.reasoning or "")[:MAX_REASONING_LENGTH] + self._logger.info( + "Sector relevance for '%s': %s (%s)", + user_input[:50], + result.relevance.value, + reasoning, + ) + return result.relevance, reasoning, stats diff --git a/backend/app/agent/career_explorer_agent/sector_search_service.py b/backend/app/agent/career_explorer_agent/sector_search_service.py new file mode 100644 index 00000000..78f32e0f --- /dev/null +++ b/backend/app/agent/career_explorer_agent/sector_search_service.py @@ -0,0 +1,89 @@ +import logging +import time +from typing import Optional + +from motor.motor_asyncio import AsyncIOMotorCollection +from pydantic import BaseModel + +from app.vector_search.embeddings_model import EmbeddingService +from app.vector_search.similarity_search_service import FilterSpec, SimilaritySearchService + + +class SectorChunkEntity(BaseModel): + chunk_id: str + sector: str + text: str + metadata: dict = {} + score: float = 0.0 + + +class SectorSearchService(SimilaritySearchService[SectorChunkEntity]): + def __init__( + self, + collection: AsyncIOMotorCollection, + embedding_service: EmbeddingService, + embedding_key: str = "embedding", + index_name: str = "sector_chunks_embedding_index", + ): + self._collection = collection + self._embedding_service = embedding_service + self._embedding_key = embedding_key + self._index_name = index_name + self._logger = logging.getLogger(self.__class__.__name__) + + async def search( + self, + *, + query: str | list[float], + filter_spec: FilterSpec | None = None, + k: int = 5, + sector: Optional[str] = None, + ) -> list[SectorChunkEntity]: + if isinstance(query, str): + q = query.strip() + if not q: + self._logger.warning("Empty text query; returning no results") + return [] + embedding = await self._embedding_service.embed(q) + else: + embedding = query + + search_start = time.time() + filter_dict: dict = {} + if sector: + filter_dict["sector"] = sector + if filter_spec: + filter_dict.update(filter_spec.to_query_filter()) + + params = { + "queryVector": embedding, + "path": self._embedding_key, + "numCandidates": k * 10, + "limit": k, + "index": self._index_name, + "filter": filter_dict, + } + + pipeline = [ + {"$vectorSearch": params}, + {"$set": {"score": {"$meta": "vectorSearchScore"}}}, + {"$sort": {"score": -1}}, + {"$limit": k}, + ] + + results = await self._collection.aggregate(pipeline).to_list(length=k) + self._logger.debug( + "Sector search took %.2fs, found %d chunks", + time.time() - search_start, + len(results), + ) + return [self._to_entity(doc) for doc in results] + + def _to_entity(self, doc: dict) -> SectorChunkEntity: + return SectorChunkEntity( + chunk_id=doc.get("chunk_id", ""), + sector=doc.get("sector", ""), + text=doc.get("text", ""), + metadata=doc.get("metadata", {}), + score=doc.get("score", 0.0), + ) diff --git a/backend/app/agent/collect_experiences_agent/_conversation_llm.py b/backend/app/agent/collect_experiences_agent/_conversation_llm.py index 6b520c3a..43a5e281 100644 --- a/backend/app/agent/collect_experiences_agent/_conversation_llm.py +++ b/backend/app/agent/collect_experiences_agent/_conversation_llm.py @@ -357,7 +357,7 @@ def _get_system_instructions(*, You should ONLY provide a recap of all experiences when we have finished exploring ALL work types. Do NOT provide summaries or recaps before all work types have been explored. Do NOT say things like "We've now gone through the different types" or "Here's what we have so far" - until we have finished exploring all four work types. + until we have finished exploring all work types. #Do not repeat information unnecessarily Review your previous questions and my answers. Avoid repeating the same question or restating collected details. @@ -389,7 +389,7 @@ def _get_system_instructions(*, Do NOT ask me to confirm or review each individual experience. Simply move on to asking if I have more experiences of the current type. When you have finished collecting information for an experience and there are no incomplete experiences remaining for the current work type, - ask me: "Do you have any other [work type description] experiences?" (e.g., "Do you have any other paid employment experiences?") + ask me: "Do you have any other [work type description] experiences?" (e.g., "Do you have any other unpaid trainee work experiences?") Only at the very end, after we have explored all work types, will you provide a recap of all experiences and ask if I want to add or change anything. @@ -490,6 +490,7 @@ def _get_first_time_generative_prompt(*, # Ideally, we want to include the language style in the prompt. # However, doing so seems to break the prompt. # We include it to ensure the model sticks to the conversation language. + experience_type_description = _get_experience_type(exploring_type) first_time_generative_prompt = dedent("""\ #Role You are a counselor working for an employment agency helping me, a young person{country_of_user_segment}, @@ -500,17 +501,20 @@ def _get_first_time_generative_prompt(*, {persona_guidance} Respond with something similar to this: - Explain that during this step you will only gather basic information about all my work experiences, + Explain that during this step you will only gather basic information about {experience_type_description}, later we will move to the next step and explore each work experience separately in detail. Add new line to separate explanation from the question. - {question_to_ask}. + {question_to_ask}. + + Do NOT mention paid work, paid jobs, formal employment, or self-employment. We only collect unpaid experiences. """) return replace_placeholders_with_indent(first_time_generative_prompt, country_of_user_segment=_get_country_of_user_segment(country_of_user), language_style=get_language_style(), persona_guidance=get_persona_prompt_section(persona_type), + experience_type_description=experience_type_description, question_to_ask=_ask_experience_type_question(exploring_type)) @@ -594,12 +598,8 @@ def _get_not_missing_fields(collected_data: list[CollectedData], index: int) -> def _get_experience_type(work_type: WorkType | None) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return t("messages", "collectExperiences.workType.formalWagedDescription") - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return t("messages", "collectExperiences.workType.unpaidTraineeDescription") - elif work_type == WorkType.SELF_EMPLOYMENT: - return t("messages", "collectExperiences.workType.selfEmploymentDescription") elif work_type == WorkType.UNSEEN_UNPAID: return t("messages", "collectExperiences.workType.unseenUnpaidDescription") elif work_type is None: @@ -614,18 +614,10 @@ def _get_experience_types(work_type: list[WorkType]) -> str: def _get_excluding_experiences(work_type: WorkType) -> str: excluding_experience_types: list[WorkType] = [] - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - excluding_experience_types = [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID] - # return "unpaid trainee work, self-employment, or unpaid work such as community volunteering work etc." - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: - excluding_experience_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID] - # return "wage employment, self-employment, or unpaid work such as community volunteering work etc." - elif work_type == WorkType.SELF_EMPLOYMENT: - excluding_experience_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID] - # return "wage employment, unpaid trainee work, or unpaid work such as community volunteering work etc." + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + excluding_experience_types = [WorkType.UNSEEN_UNPAID] elif work_type == WorkType.UNSEEN_UNPAID: - excluding_experience_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.SELF_EMPLOYMENT] - # return "wage employment, unpaid trainee work, or self-employment" + excluding_experience_types = [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK] else: raise ValueError("The work type is not supported") @@ -634,12 +626,8 @@ def _get_excluding_experiences(work_type: WorkType) -> str: def _ask_experience_type_question(work_type: WorkType) -> str: question_to_ask: str - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - question_to_ask = "Have I been employed in a company or someone else's business for money?" - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: question_to_ask = "Have I worked as an unpaid trainee for a company or organization?" - elif work_type == WorkType.SELF_EMPLOYMENT: - question_to_ask = "Have I run my own business or done freelance or contract work?" elif work_type == WorkType.UNSEEN_UNPAID: question_to_ask = "Have I done unpaid work such as community volunteering, caregiving for my own or another family, or helping in a household?" else: @@ -687,14 +675,14 @@ def _get_explore_experiences_instructions(*, For each work experience, ask me questions to gather information following the '#Gather Details' instructions. When you have finished collecting all required information for an experience and there are no incomplete experiences remaining, - ask me: "Do you have any other {experiences_in_type}?" (e.g., "Do you have any other paid employment experiences?") + ask me: "Do you have any other {experiences_in_type}?" (e.g., "Do you have any other unpaid trainee work experiences?") Do NOT ask me to confirm or review each individual experience. Simply ask if I have more experiences of this type. Only move to the next work type after I have explicitly stated I have no more experiences of the current type. IMPORTANT: Do NOT provide a recap or summary of all experiences until we have explored ALL work types. Do NOT say things like "We've now gone through the different types" or "Here's what we have so far" - until we have finished exploring all four work types (paid employment, self-employment, unpaid trainee work, and unpaid work). + until we have finished exploring all work types (unpaid trainee work and unpaid work such as volunteering). """) return replace_placeholders_with_indent(instructions_template, questions_to_ask=questions_to_ask, diff --git a/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py b/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py index c59bc44a..b6ee77eb 100644 --- a/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py +++ b/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py @@ -7,7 +7,7 @@ from app.agent.agent_types import AgentInput, AgentOutput from app.agent.agent_types import AgentType from app.agent.collect_experiences_agent._conversation_llm import _ConversationLLM, ConversationLLMAgentOutput, \ - _get_experience_type, fill_incomplete_fields_as_declined + fill_incomplete_fields_as_declined from app.agent.persona_detector import PersonaType from app.agent.collect_experiences_agent._dataextraction_llm import _DataExtractionLLM from app.agent.collect_experiences_agent._transition_decision_tool import TransitionDecisionTool, TransitionDecision @@ -19,7 +19,6 @@ from app.agent.linking_and_ranking_pipeline.infer_occupation_tool import InferOccupationTool from app.conversation_memory.conversation_memory_types import ConversationContext from app.countries import Country -from app.i18n.translation_service import t from app.vector_search.esco_entities import OccupationSkillEntity from app.vector_search.vector_search_dependencies import SearchServices @@ -83,9 +82,7 @@ class CollectExperiencesAgentState(BaseModel): The data collected during the conversation. """ - unexplored_types: list[WorkType] = Field(default_factory=lambda: [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types: list[WorkType] = Field(default_factory=lambda: [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID]) """ The types of work experiences that have not been explored yet. @@ -308,27 +305,13 @@ async def execute(self, user_input: AgentInput, self._state.collected_data, reasoning_text ) -# exit if no unexplored types left - if not did_update and not self._state.unexplored_types: + if not self._state.unexplored_types: conversation_llm_output.finished = True self.logger.info( "Transition decision: END_WORKTYPE with no unexplored types - treating as END_CONVERSATION" ) return conversation_llm_output - next_exploring_type = self._state.unexplored_types[0] if self._state.unexplored_types else None - if next_exploring_type is not None: - transition_text = t( - 'messages', 'collectExperiences.askAboutType', - experience_type=_get_experience_type(next_exploring_type) - ) - else: - transition_text = t('messages', 'collectExperiences.recapPrompt') - - conversation_llm_output.message_for_user = ( - f"{conversation_llm_output.message_for_user}\n\n{transition_text}" - ) - elif transition_decision == TransitionDecision.END_CONVERSATION: conversation_llm_output.finished = True self.logger.info( @@ -336,7 +319,7 @@ async def execute(self, user_input: AgentInput, "\n - all work types explored: %s" "\n - discovered experiences: %s" "\n - reasoning: %s", - len(self._state.explored_types) == 4, + len(self._state.explored_types) == 2, self._state.collected_data, reasoning_text ) 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 1065ff3b..346c47ae 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 @@ -289,8 +289,7 @@ async def _internal_execute(self, - work_type_classification_reasoning: A detailed, step-by-step explanation of how the information collected until now, is evaluated based on the instructions of 'work_type', to classify the type of work of the experience. Formatted as a JSON string. - - work_type: type of work of the experience, 'FORMAL_SECTOR_WAGED_EMPLOYMENT', - 'FORMAL_SECTOR_UNPAID_TRAINEE_WORK', 'SELF_EMPLOYMENT', 'UNSEEN_UNPAID' or 'None'. + - work_type: type of work of the experience, 'FORMAL_SECTOR_UNPAID_TRAINEE_WORK', 'UNSEEN_UNPAID' or 'None'. Other values are not permitted. - dates_mentioned: The experience dates mentioned in the conversation. Empty string "" If you could not find any. diff --git a/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py b/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py index e5d77947..5383f4ae 100644 --- a/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py +++ b/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py @@ -33,7 +33,7 @@ def _create_collected_data( start_date=start_date, end_date=end_date, paid_work=paid_work, - work_type=work_type or WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=work_type or WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) @@ -59,7 +59,7 @@ def create_experience_data( start_date=start_date, end_date=end_date, paid_work=paid_work, - work_type=work_type or WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + work_type=work_type or WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, data_operation=data_operation, data_extraction_references="", dates_mentioned="", @@ -100,7 +100,7 @@ def test_add_single_new_experience(self, processor, mock_logger): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ] @@ -127,7 +127,7 @@ def test_add_single_new_experience(self, processor, mock_logger): assert experience.start_date == "2020" assert experience.end_date == "2022" assert experience.paid_work is True - assert experience.work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + assert experience.work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name assert experience.defined_at_turn_number == 2 # AND should log the addition @@ -153,7 +153,7 @@ def test_add_multiple_new_experiences(self, processor, mock_logger): experience_title="Freelance Designer", company="Self", location="Johannesburg", - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), create_experience_data( data_operation="ADD", diff --git a/backend/app/agent/constants.py b/backend/app/agent/constants.py new file mode 100644 index 00000000..94ee2647 --- /dev/null +++ b/backend/app/agent/constants.py @@ -0,0 +1,139 @@ +""" +Constants for the agent module. + +Platform modules and pages the agent knows about. +Use the most specific route available when a user asks about something specific. +""" +from typing import Final + + +class PlatformRoute: + """Constants for platform routes that the AMA agent can suggest.""" + + HOME = "/" + SKILLS_INTERESTS = "/skills-interests" + CAREER_READINESS = "/career-readiness" + CAREER_READINESS_CV_DEVELOPMENT = "/career-readiness/cv-development" + CAREER_READINESS_COVER_LETTER = "/career-readiness/cover-letter" + CAREER_READINESS_INTERVIEW_PREPARATION = "/career-readiness/interview-preparation" + CAREER_READINESS_PROFESSIONAL_IDENTITY = "/career-readiness/professional-identity" + CAREER_READINESS_WORKPLACE_READINESS = "/career-readiness/workplace-readiness" + CAREER_READINESS_ENTREPRENEURSHIP = "/career-readiness/entrepreneurship" + CAREER_EXPLORER = "/career-explorer" + KNOWLEDGE_HUB = "/knowledge-hub" + KNOWLEDGE_HUB_MINING_PATHWAY = "/knowledge-hub/mining-pathway" + KNOWLEDGE_HUB_ENERGY_PATHWAY = "/knowledge-hub/energy-pathway" + KNOWLEDGE_HUB_HOSPITALITY_PATHWAY = "/knowledge-hub/hospitality-pathway" + KNOWLEDGE_HUB_AGRICULTURE_PATHWAY = "/knowledge-hub/agriculture-pathway" + KNOWLEDGE_HUB_WATER_PATHWAY = "/knowledge-hub/water-pathway" + +VALID_PLATFORM_ROUTES: Final[list[str]] = [ + value + for key, value in vars(PlatformRoute).items() + if isinstance(value, str) and not key.startswith("_") +] +""" +All valid platform routes that the AMA agent can suggest. +Automatically derived from PlatformRoute constants to ensure consistency. +""" + +PLATFORM_MODULES_CONFIG = [ + { + "name_key": "askMeAnything.platformModules.skillsInterests.name", + "route": PlatformRoute.SKILLS_INTERESTS, + "description_key": "askMeAnything.platformModules.skillsInterests.description", + }, + { + "name_key": "askMeAnything.platformModules.careerReadiness.name", + "route": PlatformRoute.CAREER_READINESS, + "description_key": "askMeAnything.platformModules.careerReadiness.description", + }, + { + "name_key": "askMeAnything.platformModules.cvDevelopment.name", + "route": PlatformRoute.CAREER_READINESS_CV_DEVELOPMENT, + "description_key": "askMeAnything.platformModules.cvDevelopment.description", + }, + { + "name_key": "askMeAnything.platformModules.coverLetter.name", + "route": PlatformRoute.CAREER_READINESS_COVER_LETTER, + "description_key": "askMeAnything.platformModules.coverLetter.description", + }, + { + "name_key": "askMeAnything.platformModules.interviewPreparation.name", + "route": PlatformRoute.CAREER_READINESS_INTERVIEW_PREPARATION, + "description_key": "askMeAnything.platformModules.interviewPreparation.description", + }, + { + "name_key": "askMeAnything.platformModules.professionalIdentity.name", + "route": PlatformRoute.CAREER_READINESS_PROFESSIONAL_IDENTITY, + "description_key": "askMeAnything.platformModules.professionalIdentity.description", + }, + { + "name_key": "askMeAnything.platformModules.workplaceReadiness.name", + "route": PlatformRoute.CAREER_READINESS_WORKPLACE_READINESS, + "description_key": "askMeAnything.platformModules.workplaceReadiness.description", + }, + { + "name_key": "askMeAnything.platformModules.entrepreneurship.name", + "route": PlatformRoute.CAREER_READINESS_ENTREPRENEURSHIP, + "description_key": "askMeAnything.platformModules.entrepreneurship.description", + }, + { + "name_key": "askMeAnything.platformModules.careerExplorer.name", + "route": PlatformRoute.CAREER_EXPLORER, + "description_key": "askMeAnything.platformModules.careerExplorer.description", + }, + { + "name_key": "askMeAnything.platformModules.knowledgeHub.name", + "route": PlatformRoute.KNOWLEDGE_HUB, + "description_key": "askMeAnything.platformModules.knowledgeHub.description", + }, + { + "name_key": "askMeAnything.platformModules.miningPathway.name", + "route": PlatformRoute.KNOWLEDGE_HUB_MINING_PATHWAY, + "description_key": "askMeAnything.platformModules.miningPathway.description", + }, + { + "name_key": "askMeAnything.platformModules.energyPathway.name", + "route": PlatformRoute.KNOWLEDGE_HUB_ENERGY_PATHWAY, + "description_key": "askMeAnything.platformModules.energyPathway.description", + }, + { + "name_key": "askMeAnything.platformModules.hospitalityPathway.name", + "route": PlatformRoute.KNOWLEDGE_HUB_HOSPITALITY_PATHWAY, + "description_key": "askMeAnything.platformModules.hospitalityPathway.description", + }, + { + "name_key": "askMeAnything.platformModules.agriculturePathway.name", + "route": PlatformRoute.KNOWLEDGE_HUB_AGRICULTURE_PATHWAY, + "description_key": "askMeAnything.platformModules.agriculturePathway.description", + }, + { + "name_key": "askMeAnything.platformModules.waterPathway.name", + "route": PlatformRoute.KNOWLEDGE_HUB_WATER_PATHWAY, + "description_key": "askMeAnything.platformModules.waterPathway.description", + }, + { + "name_key": "askMeAnything.platformModules.home.name", + "route": PlatformRoute.HOME, + "description_key": "askMeAnything.platformModules.home.description", + }, +] + +def get_localized_platform_modules(): + """ + Get platform modules with localized names and descriptions based on current locale. + + Returns: + List of dictionaries with 'name', 'route', and 'description' keys. + """ + from app.i18n.translation_service import t + + modules = [] + for module_config in PLATFORM_MODULES_CONFIG: + modules.append({ + "name": t("messages", module_config["name_key"], fallback_message=module_config["name_key"]), + "route": module_config["route"], + "description": t("messages", module_config["description_key"], fallback_message=module_config["description_key"]), + }) + return modules diff --git a/backend/app/agent/experience/work_type.py b/backend/app/agent/experience/work_type.py index 47d1bb6c..dedd1c02 100644 --- a/backend/app/agent/experience/work_type.py +++ b/backend/app/agent/experience/work_type.py @@ -10,18 +10,14 @@ class WorkType(Enum): See https://docs.tabiya.org/overview/projects/inclusive-livelihoods-taxonomy/methodology for more information. - 1. Formal sector/Wage employment (link to vanilla esco) - 2. Formal sector/Unpaid trainee work (link to vanilla esco) - 3. Self-employment (link to vanilla esco + micro entrepreneurship) - 4. Unseen (link to esco + unseen) + 1. Formal sector/Unpaid trainee work (link to vanilla esco) + 2. Unseen (link to esco + unseen) a. Unpaid domestic services for household and family members (Div 3) b. Unpaid caregiving services for household and family members (Div 4) c. Unpaid direct volunteering for other households (Div 51) d. Unpaid community- and organization-based volunteering (Div 52) """ - FORMAL_SECTOR_WAGED_EMPLOYMENT = "Formal sector/Wage employment" FORMAL_SECTOR_UNPAID_TRAINEE_WORK = "Formal sector/Unpaid trainee work" - SELF_EMPLOYMENT = "Self-employment" UNSEEN_UNPAID = "Unpaid other" # All unseen work is grouped under this category @staticmethod @@ -32,12 +28,8 @@ def from_string_key(key: Optional[str]) -> Optional[WorkType]: @staticmethod def work_type_short(work_type: WorkType) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return "Wage Employment" - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return "Trainee" - elif work_type == WorkType.SELF_EMPLOYMENT: - return "Self-Employed" elif work_type == WorkType.UNSEEN_UNPAID: return "Volunteer/Unpaid" else: @@ -45,12 +37,8 @@ def work_type_short(work_type: WorkType) -> str: @staticmethod def work_type_short_i18n_key(work_type: WorkType) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return "experience.workType.short.formalSectorWagedEmployment" - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return "experience.workType.short.formalSectorUnpaidTraineeWork" - elif work_type == WorkType.SELF_EMPLOYMENT: - return "experience.workType.short.selfEmployment" elif work_type == WorkType.UNSEEN_UNPAID: return "experience.workType.short.unseenUnpaid" else: @@ -58,12 +46,8 @@ def work_type_short_i18n_key(work_type: WorkType) -> str: @staticmethod def work_type_long(work_type: WorkType | None) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return "Waged work or paid work as an employee. Working for someone else, for a company or an organization, in exchange for a salary or wage." - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return "Unpaid Trainee Work." - elif work_type == WorkType.SELF_EMPLOYMENT: - return "Self-employment, micro entrepreneurship, contract based work, freelancing, running own business, paid work but not work as an employee." elif work_type == WorkType.UNSEEN_UNPAID: return dedent("""\ Represents all unpaid work, including: @@ -81,9 +65,7 @@ def work_type_long(work_type: WorkType | None) -> str: WORK_TYPE_DEFINITIONS_FOR_PROMPT = dedent(f"""\ -- None: {WorkType.work_type_long(None)} -- {WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name}:{WorkType.work_type_long(WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT)} +- None: {WorkType.work_type_long(None)} - {WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name}: {WorkType.work_type_long(WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK)} -- {WorkType.SELF_EMPLOYMENT.name}: {WorkType.work_type_long(WorkType.SELF_EMPLOYMENT)} - {WorkType.UNSEEN_UNPAID.name}: {WorkType.work_type_long(WorkType.UNSEEN_UNPAID)} """) diff --git a/backend/app/agent/explore_experiences_agent_director.py b/backend/app/agent/explore_experiences_agent_director.py index fe62f9f0..02bff3e7 100644 --- a/backend/app/agent/explore_experiences_agent_director.py +++ b/backend/app/agent/explore_experiences_agent_director.py @@ -276,13 +276,18 @@ async def _dive_into_experiences(self, *, # then we are done _next_experience = _pick_next_experience_to_process(state.experiences_state) if not _next_experience: - return AgentOutput( + transition_message = AgentOutput( message_for_user=t("messages", "exploreExperiences.transitionToPreferences"), finished=True, agent_type=self._agent_type, agent_response_time_in_sec=0, llm_stats=[] ) + await self._conversation_manager.update_history( + AgentInput(message="", is_artificial=True), + transition_message, + ) + return transition_message # Otherwise, we have more experiences to process return await self._dive_into_experiences(user_input=user_input, context=context, state=state) diff --git a/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py b/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py index d7350995..defa92a5 100644 --- a/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py +++ b/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py @@ -128,11 +128,6 @@ async def execute(self, *, for title in titles] tasks.extend(unseen_tasks) - if work_type == WorkType.SELF_EMPLOYMENT or work_type is None: - # since there is only one taxonomy domain for self-employment, it is not necessary to search, instead can retrieve the occupations directly - self_employment_tasks = [self._occupation_skill_search_service.get_by_esco_code(code="5221_2")] - tasks.extend(self_employment_tasks) - # Use return_exceptions=True to handle individual task failures gracefully list_of_occupation_list = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/backend/app/agent/llm_caller.py b/backend/app/agent/llm_caller.py index 76b0fd84..add4d130 100644 --- a/backend/app/agent/llm_caller.py +++ b/backend/app/agent/llm_caller.py @@ -39,7 +39,8 @@ def __init__(self, model_response_type: Type[RESPONSE_T]): async def call_llm(self, *, llm: GeminiGenerativeLLM, llm_input: LLMInput | str, - logger: logging.Logger + logger: logging.Logger, + output_metadata: dict | None = None, ) -> Tuple[RESPONSE_T | None, list[LLMStats]]: """ Call the LLM to generate a specific response. @@ -50,6 +51,8 @@ async def call_llm(self, *, :param llm: The LLM to call :param llm_input: The input to the LLM :param logger: The logger to log messages. + :param output_metadata: Optional mutable dict. If provided, extra metadata from the LLM response (e.g. grounding) + is merged into it, e.g. under key "grounding_metadata". :return: The model response and the statistics of the LLM calls. """ @@ -95,7 +98,11 @@ async def call_llm(self, *, llm_stats = LLMStats(prompt_token_count=llm_response.prompt_token_count, response_token_count=llm_response.response_token_count, response_time_in_sec=round(llm_end_time - llm_start_time, 2)) - + if output_metadata is not None: + gm = getattr(llm_response, "grounding_metadata", None) + if gm is not None: + output_metadata["grounding_metadata"] = gm + # Set LLM duration in context for observability logging duration_ms = round((llm_end_time - llm_start_time) * 1000, 2) llm_call_duration_ms_ctx_var.set(duration_ms) diff --git a/backend/app/agent/preference_elicitation_agent/agent.py b/backend/app/agent/preference_elicitation_agent/agent.py index a7b4d56d..7097d81b 100644 --- a/backend/app/agent/preference_elicitation_agent/agent.py +++ b/backend/app/agent/preference_elicitation_agent/agent.py @@ -1282,7 +1282,7 @@ async def _handle_wrapup_phase( {summary} - This will help me suggest opportunities that are a good fit for you. Does this sound about right?""" + Thank you for sharing your preferences with me.""" response = ConversationResponse( reasoning="Wrapping up preference elicitation with summary", diff --git a/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py b/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py index 3021006e..02667e3e 100644 --- a/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py +++ b/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py @@ -30,7 +30,7 @@ def create_experiences(): experience_title="Contract Software Developer", company="Tabiya Organization", timeline=Timeline(start="11/2025", end="Present"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py b/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py index ad5cf329..c6840f77 100644 --- a/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py +++ b/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py @@ -401,7 +401,7 @@ async def test_agent_adaptive_phase_not_skipped(): experience_title="Contract Software Developer", company="Tabiya Organization", timeline=Timeline(start="11/2025", end="Present"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ], use_db6_for_fresh_data=False, diff --git a/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py b/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py index b78ec1a6..cb8d3f82 100644 --- a/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py +++ b/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py @@ -33,7 +33,7 @@ def create_sample_experiences(): company="Alliance High School", location="Kikuyu", timeline=Timeline(start="2018", end="2023"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), ExperienceEntity( uuid="exp-2", @@ -41,7 +41,7 @@ def create_sample_experiences(): company="Self-employed", location="Nairobi", timeline=Timeline(start="2023", end="Present"), - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ) ] diff --git a/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py b/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py index 7af31d33..867acb53 100644 --- a/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py +++ b/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py @@ -248,7 +248,7 @@ def sample_experiences(self): company="TechCorp", location="Nairobi", timeline=Timeline(start="2020", end="2022"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), ExperienceEntity( uuid="exp-2", @@ -256,7 +256,7 @@ def sample_experiences(self): company="Self", location="Mombasa", timeline=Timeline(start="2022", end="2023"), - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ) ] @@ -375,7 +375,7 @@ async def test_get_experiences_from_snapshot_when_db6_disabled(self): ExperienceEntity( uuid="exp-1", experience_title="Developer", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -404,7 +404,7 @@ async def test_get_experiences_from_db6_when_enabled(self): ExperienceEntity( uuid="exp-db6", experience_title="DB6 Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] profile = YouthProfile( @@ -420,7 +420,7 @@ async def test_get_experiences_from_db6_when_enabled(self): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -458,7 +458,7 @@ async def test_fallback_to_snapshot_when_db6_empty(self): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -488,7 +488,7 @@ async def test_fallback_to_snapshot_when_db6_profile_not_found(self): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -528,7 +528,7 @@ async def delete_youth_profile(self, youth_id): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -679,7 +679,7 @@ def test_state_creation_with_db6_fields(self): ExperienceEntity( uuid="exp-1", experience_title="Developer", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] @@ -713,7 +713,7 @@ def test_state_from_document_with_db6_fields(self): { "uuid": "exp-1", "experience_title": "Developer", - "work_type": "FORMAL_SECTOR_WAGED_EMPLOYMENT" + "work_type": "FORMAL_SECTOR_UNPAID_TRAINEE_WORK" } ], "use_db6_for_fresh_data": True @@ -764,7 +764,7 @@ def mock_experiences(self): start="2022-06", end="2023-12" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, responsibilities=ResponsibilitiesData( responsibilities=["Taking orders", "Operating cash register"] ), @@ -782,7 +782,7 @@ def mock_experiences(self): start="2021-03", end="2022-05" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, responsibilities=ResponsibilitiesData( responsibilities=["Customer service", "Inventory management"] ), @@ -909,7 +909,7 @@ def sample_experiences(self): company="TechCorp Kenya", location="Nairobi", timeline=Timeline(start="2022-01", end="2024-11"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py b/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py index 7720ebf2..3c380b20 100644 --- a/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py +++ b/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py @@ -42,7 +42,7 @@ def create_test_experiences(): experience_title="Contract Software Developer", company="Tabiya Organization", timeline=Timeline(start="11/2025", end="Present"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/backend/app/agent/skill_explorer_agent/_conversation_llm.py b/backend/app/agent/skill_explorer_agent/_conversation_llm.py index ad140fa0..983a9dcc 100644 --- a/backend/app/agent/skill_explorer_agent/_conversation_llm.py +++ b/backend/app/agent/skill_explorer_agent/_conversation_llm.py @@ -368,14 +368,8 @@ def _get_country_of_user_segment(country_of_user: Country) -> str: def _get_question_c(work_type: WorkType) -> str: - """ - Get the question for the specific work type - """ - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return t("messages", "exploreSkills.question.formalWaged") - elif work_type == WorkType.SELF_EMPLOYMENT: - return t("messages", "exploreSkills.question.selfEmployment") + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + return t("messages", "exploreSkills.question.unpaidTrainee") elif work_type == WorkType.UNSEEN_UNPAID: return t("messages", "exploreSkills.question.unseenUnpaid") - else: - return "" + return "" diff --git a/backend/app/app_config.py b/backend/app/app_config.py index f9a2e369..df76305c 100644 --- a/backend/app/app_config.py +++ b/backend/app/app_config.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, Field, model_validator +from app.career_explorer.config import CareerExplorerConfig from app.countries import Country from app.i18n.language_config import LanguageConfig from app.users.cv.constants import DEFAULT_MAX_UPLOADS_PER_USER, DEFAULT_RATE_LIMIT_PER_MINUTE @@ -109,6 +110,12 @@ class ApplicationConfig(BaseModel): Corresponds to the COMPASS_INLINE_PHASE_TRANSITION environment variable. """ + career_explorer_config: CareerExplorerConfig = Field(default_factory=CareerExplorerConfig) + """ + Unified Career Explorer configuration (sectors, country). + Loaded from CAREER_EXPLORER_CONFIG JSON env var. + """ + @model_validator(mode='after') def check_cv_upload_configurations(self) -> "ApplicationConfig": # Check that the CV upload configurations are valid. diff --git a/backend/app/ask_me_anything/__init__.py b/backend/app/ask_me_anything/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/ask_me_anything/routes.py b/backend/app/ask_me_anything/routes.py new file mode 100644 index 00000000..a4c0c83a --- /dev/null +++ b/backend/app/ask_me_anything/routes.py @@ -0,0 +1,92 @@ +""" +Routes for the Ask Me Anything module. +""" +import asyncio +import logging +from http import HTTPStatus +from typing import Annotated, Optional + +from fastapi import FastAPI, APIRouter, Depends, HTTPException, Path + +from app.agent.ask_me_anything_agent import AskMeAnythingAgent +from app.ask_me_anything.service import AskMeAnythingService +from app.ask_me_anything.types import AMAConversationInput, AMAConversationResponse +from app.app_config import get_application_config +from app.constants.errors import HTTPErrorResponse +from app.conversations.constants import MAX_MESSAGE_LENGTH +from app.i18n.translation_service import get_i18n_manager +from app.users.auth import Authentication, UserInfo + +logger = logging.getLogger(__name__) + +_ama_service_lock = asyncio.Lock() +_ama_service_singleton: Optional[AskMeAnythingService] = None + + +async def _get_ama_service() -> AskMeAnythingService: + """Get or create the AMA service singleton (thread-safe).""" + global _ama_service_singleton + if _ama_service_singleton is None: + async with _ama_service_lock: + if _ama_service_singleton is None: + _ama_service_singleton = AskMeAnythingService(agent=AskMeAnythingAgent()) + return _ama_service_singleton + + +def add_ask_me_anything_routes(app: FastAPI, authentication: Authentication) -> None: + """ + Register all Ask Me Anything routes on the FastAPI app. + + :param app: The FastAPI application instance. + :param authentication: The authentication dependency. + """ + router = APIRouter(prefix="/ask-me-anything", tags=["ask-me-anything"]) + + @router.post( + path="/messages", + status_code=HTTPStatus.CREATED, + response_model=AMAConversationResponse, + responses={ + HTTPStatus.REQUEST_ENTITY_TOO_LARGE: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description=( + "Send a message in the AMA conversation or get the initial greeting. " + "If user_input is None or empty and history is empty, returns the agent's greeting. " + "Otherwise processes the user's message. " + "The client must supply the full prior history in the request body." + ), + ) + async def _send_message( + body: AMAConversationInput, + _user_info: UserInfo = Depends(authentication.get_user_info()), + service: AskMeAnythingService = Depends(_get_ama_service), + ) -> AMAConversationResponse: + app_config = get_application_config() + get_i18n_manager().set_locale(app_config.language_config.default_locale) + + if body.user_input and len(body.user_input) > MAX_MESSAGE_LENGTH: + logger.warning("AMA user input exceeded max length of %d chars", MAX_MESSAGE_LENGTH) + raise HTTPException( + status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + detail="Too long user input", + ) + try: + return await service.send_message( + user_input=body.user_input, + history=body.history, + ) + except ValueError as e: + logger.warning("Invalid AMA request: %s", e) + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(e), + ) from e + except Exception as e: + logger.exception("Error processing AMA message: %s", e) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Unexpected error", + ) from e + + app.include_router(router) diff --git a/backend/app/ask_me_anything/service.py b/backend/app/ask_me_anything/service.py new file mode 100644 index 00000000..68970bbe --- /dev/null +++ b/backend/app/ask_me_anything/service.py @@ -0,0 +1,173 @@ +""" +Service layer for the Ask Me Anything module. + +Stateless — no database persistence. The client holds conversation history and +sends it with each request. This service builds ConversationContext from the +client-supplied history, calls the agent, and returns the updated message list. +""" +import logging +from datetime import datetime, timezone + +from bson import ObjectId + +from app.agent.agent_types import AgentInput, AgentOutput +from app.agent.ask_me_anything_agent import AskMeAnythingAgent +from app.ask_me_anything.types import ( + AMAConversationResponse, + AMAMessage, + AMAMessageSender, + SuggestedAction, +) +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) + + +def _build_conversation_context(history: list[AMAMessage]) -> ConversationContext: + """ + Build a ConversationContext from the client-supplied message history. + + Pairs USER+AGENT messages into ConversationTurns. Unpaired messages + (e.g. the agent's intro greeting) are skipped. + """ + turns: list[ConversationTurn] = [] + index = 0 + i = 0 + + while i < len(history): + msg = history[i] + + # Skip standalone AGENT messages (e.g. intro message with no prior user turn) + if msg.sender == AMAMessageSender.AGENT: + i += 1 + continue + + # Pair USER message with the following AGENT message + if ( + msg.sender == AMAMessageSender.USER + and i + 1 < len(history) + and history[i + 1].sender == AMAMessageSender.AGENT + ): + user_msg = msg + agent_msg = history[i + 1] + turns.append( + ConversationTurn( + index=index, + input=AgentInput( + message_id=user_msg.message_id, + message=user_msg.message, + sent_at=user_msg.sent_at, + ), + output=AgentOutput( + message_id=agent_msg.message_id, + message_for_user=agent_msg.message, + finished=False, + agent_response_time_in_sec=0, + llm_stats=[], + sent_at=agent_msg.sent_at, + ), + ) + ) + index += 1 + i += 2 + else: + i += 1 + + history_obj = ConversationHistory(turns=turns) + return ConversationContext(all_history=history_obj, history=history_obj) + + +def _extract_suggested_actions(agent_output: AgentOutput) -> list[SuggestedAction]: + """Extract suggested_actions from agent output metadata.""" + if not agent_output.metadata: + return [] + raw = agent_output.metadata.get("suggested_actions", []) + actions = [] + for item in raw: + try: + actions.append(SuggestedAction(label=item["label"], route=item["route"])) + except (KeyError, TypeError): + pass + return actions + + +class AskMeAnythingService: + """ + Stateless service for the AMA agent. + + No repository — conversation history is owned by the client and supplied + on every request. + """ + + def __init__(self, agent: AskMeAnythingAgent | None = None) -> None: + self._agent = agent or AskMeAnythingAgent() + self._logger = logging.getLogger(AskMeAnythingService.__name__) + + async def send_message( + self, + user_input: str | None, + history: list[AMAMessage], + ) -> AMAConversationResponse: + """ + Process a user message and return the updated conversation. + + If user_input is None or empty and history is empty, returns the agent's greeting. + Otherwise processes the user's message and returns the agent's response. + + :param user_input: The user's latest message (None for initial greeting) + :param history: Full prior conversation history supplied by the client + :return: Updated message list including the new user message (if any) and agent response + """ + # If no user input and no history, return the initial greeting + if not user_input and not history: + empty_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + intro_output = await self._agent.generate_intro_message(empty_context) + now = datetime.now(timezone.utc) + + intro_message = AMAMessage( + message_id=intro_output.message_id or str(ObjectId()), + message=intro_output.message_for_user, + sender=AMAMessageSender.AGENT, + sent_at=intro_output.sent_at or now, + suggested_actions=_extract_suggested_actions(intro_output), + ) + + return AMAConversationResponse( + messages=[intro_message], + ) + + # Process user message + if not user_input: + raise ValueError("user_input is required when history is not empty") + + now = datetime.now(timezone.utc) + + user_message = AMAMessage( + message_id=str(ObjectId()), + message=user_input, + sender=AMAMessageSender.USER, + sent_at=now, + ) + + all_messages = list(history) + [user_message] + context = _build_conversation_context(all_messages) + + agent_input = AgentInput(message=user_input, sent_at=now) + agent_output = await self._agent.execute(agent_input, context) + + agent_message = AMAMessage( + message_id=agent_output.message_id or str(ObjectId()), + message=agent_output.message_for_user, + sender=AMAMessageSender.AGENT, + sent_at=agent_output.sent_at or now, + suggested_actions=_extract_suggested_actions(agent_output), + ) + + return AMAConversationResponse( + messages=all_messages + [agent_message], + ) diff --git a/backend/app/ask_me_anything/types.py b/backend/app/ask_me_anything/types.py new file mode 100644 index 00000000..f17f5ce8 --- /dev/null +++ b/backend/app/ask_me_anything/types.py @@ -0,0 +1,99 @@ +""" +Types for the Ask Me Anything module. +""" +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, Field, field_serializer, field_validator + + +class SuggestedAction(BaseModel): + """A navigation action suggested by the AMA agent.""" + + label: str + """Button text shown to the user, e.g. 'Explore Career Readiness'""" + + route: str + """Frontend route to navigate to, e.g. '/career-readiness'""" + + class Config: + extra = "forbid" + + +class AMAMessageSender(str, Enum): + USER = "USER" + AGENT = "AGENT" + + +class AMAMessage(BaseModel): + """A single message in an AMA conversation.""" + + message_id: str + """Unique identifier for the message""" + + message: str + """The message content""" + + sent_at: datetime + """When the message was sent, in UTC""" + + sender: AMAMessageSender + """Who sent the message""" + + suggested_actions: list[SuggestedAction] = Field(default_factory=list) + """Navigation actions suggested by the agent (only populated on AGENT messages)""" + + @field_serializer("sent_at") + def _serialize_sent_at(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @field_serializer("sender") + def _serialize_sender(self, sender: AMAMessageSender, _info) -> str: + return sender.name + + @classmethod + @field_validator("sender", mode="before") + def _deserialize_sender(cls, value: str | AMAMessageSender) -> AMAMessageSender: + if isinstance(value, str): + return AMAMessageSender[value] + elif isinstance(value, AMAMessageSender): + return value + else: + raise ValueError(f"Invalid message sender: {value}") + + @classmethod + @field_validator("sent_at", mode="before") + def _deserialize_sent_at(cls, value: str | datetime) -> datetime: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + elif isinstance(value, datetime): + dt = value + else: + raise ValueError(f"Invalid datetime value: {value}") + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + class Config: + extra = "forbid" + + +class AMAConversationInput(BaseModel): + """Input for sending a message in an AMA conversation.""" + + user_input: str | None = None + """The user's message (None for initial greeting request)""" + + history: list[AMAMessage] = Field(default_factory=list) + """Full prior conversation history, sent by the client on each request""" + + class Config: + extra = "forbid" + + +class AMAConversationResponse(BaseModel): + """Response for an AMA conversation, including all messages.""" + + messages: list[AMAMessage] + """All messages in the conversation (including the new ones)""" + + class Config: + extra = "forbid" diff --git a/backend/app/career_explorer/__init__.py b/backend/app/career_explorer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/career_explorer/config.py b/backend/app/career_explorer/config.py new file mode 100644 index 00000000..8642c6b6 --- /dev/null +++ b/backend/app/career_explorer/config.py @@ -0,0 +1,22 @@ +import json +from typing import Any + +from pydantic import BaseModel, Field + + +def parse_career_explorer_config(raw: str | None) -> "CareerExplorerConfig": + if not raw or not raw.strip(): + return CareerExplorerConfig() + try: + data: dict[str, Any] = json.loads(raw) + return CareerExplorerConfig(**data) + except (json.JSONDecodeError, TypeError) as e: + raise ValueError(f"Invalid CAREER_EXPLORER_CONFIG JSON: {e}") from e + + +class CareerExplorerConfig(BaseModel): + sectors: list[dict[str, str]] = Field(default_factory=list) + country: str = "Zambia" + + class Config: + extra = "forbid" diff --git a/backend/app/career_explorer/context_builder.py b/backend/app/career_explorer/context_builder.py new file mode 100644 index 00000000..0c6e5110 --- /dev/null +++ b/backend/app/career_explorer/context_builder.py @@ -0,0 +1,105 @@ +from app.agent.agent_types import AgentInput, AgentOutput +from app.career_explorer.types import CareerExplorerMessage, CareerExplorerMessageSender +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) +from app.conversation_memory.summarizer import Summarizer +from app.server_config import UNSUMMARIZED_WINDOW_SIZE, TO_BE_SUMMARIZED_WINDOW_SIZE + + +def _messages_to_turns(messages: list[CareerExplorerMessage]) -> list[ConversationTurn]: + turns: list[ConversationTurn] = [] + idx = 0 + i = 0 + while i < len(messages): + msg = messages[i] + if msg.sender == CareerExplorerMessageSender.AGENT and ( + i + 1 >= len(messages) or messages[i + 1].sender != CareerExplorerMessageSender.USER + ): + i += 1 + continue + if ( + msg.sender == CareerExplorerMessageSender.USER + and i + 1 < len(messages) + and messages[i + 1].sender == CareerExplorerMessageSender.AGENT + ): + agent_msg = messages[i + 1] + turns.append( + ConversationTurn( + index=idx, + input=AgentInput( + message_id=msg.message_id, + message=msg.message, + sent_at=msg.sent_at, + ), + output=AgentOutput( + message_id=agent_msg.message_id, + message_for_user=agent_msg.message, + finished=False, + agent_response_time_in_sec=0, + llm_stats=[], + sent_at=agent_msg.sent_at, + ), + ) + ) + idx += 1 + i += 2 + else: + i += 1 + return turns + + +async def build_windowed_context( + messages: list[CareerExplorerMessage], + summary: str = "", + num_turns_summarized: int = 0, + unsummarized_window_size: int = UNSUMMARIZED_WINDOW_SIZE, + to_be_summarized_window_size: int = TO_BE_SUMMARIZED_WINDOW_SIZE, +) -> tuple[ConversationContext, str, int]: + turns = _messages_to_turns(messages) + total_window = unsummarized_window_size + to_be_summarized_window_size + + if len(turns) <= total_window: + history = ConversationHistory(turns=turns) + return ( + ConversationContext(all_history=history, history=history, summary=""), + summary, + num_turns_summarized, + ) + + to_summarize_count = len(turns) - total_window + to_summarize_turns = turns[0:to_summarize_count] + recent_turns = turns[to_summarize_count:] + + summarizer = Summarizer() + turns_already_in_summary = min(num_turns_summarized, len(to_summarize_turns)) + new_turns_to_summarize = to_summarize_turns[turns_already_in_summary:] + + if new_turns_to_summarize: + current_summary = summary + for batch_start in range(0, len(new_turns_to_summarize), to_be_summarized_window_size): + batch = new_turns_to_summarize[ + batch_start : batch_start + to_be_summarized_window_size + ] + ctx = ConversationContext( + all_history=ConversationHistory(turns=[]), + history=ConversationHistory(turns=batch), + summary=current_summary, + ) + current_summary = await summarizer.summarize(ctx) + summary = current_summary + num_turns_summarized = len(to_summarize_turns) + + history = ConversationHistory(turns=recent_turns) + all_history = ConversationHistory(turns=turns) + return ( + ConversationContext( + all_history=all_history, + history=history, + summary=summary, + ), + summary, + num_turns_summarized, + ) diff --git a/backend/app/career_explorer/repository.py b/backend/app/career_explorer/repository.py new file mode 100644 index 00000000..856bb4aa --- /dev/null +++ b/backend/app/career_explorer/repository.py @@ -0,0 +1,139 @@ +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo.errors import DuplicateKeyError + +from app.career_explorer.types import ( + CareerExplorerConversationDocument, + CareerExplorerConversationResponse, + CareerExplorerMessage, + CareerExplorerMessageSender, +) +from app.server_dependencies.database_collections import Collections + + +class ICareerExplorerConversationRepository(ABC): + @abstractmethod + async def get_or_create_conversation(self, user_id: str, welcome_message: str) -> CareerExplorerConversationResponse: + raise NotImplementedError() + + @abstractmethod + async def create(self, document: CareerExplorerConversationDocument) -> None: + raise NotImplementedError() + + @abstractmethod + async def find_by_user(self, user_id: str) -> CareerExplorerConversationDocument | None: + raise NotImplementedError() + + @abstractmethod + async def append_message(self, user_id: str, message: CareerExplorerMessage) -> None: + raise NotImplementedError() + + @abstractmethod + async def update_memory_state( + self, + user_id: str, + summary: str, + num_turns_summarized: int, + ) -> None: + raise NotImplementedError() + + @abstractmethod + async def delete_by_user(self, user_id: str) -> bool: + raise NotImplementedError() + + @abstractmethod + async def find_response_by_user(self, user_id: str) -> CareerExplorerConversationResponse | None: + raise NotImplementedError() + + +class CareerExplorerConversationRepository(ICareerExplorerConversationRepository): + def __init__(self, db: AsyncIOMotorDatabase): + self._collection = db.get_collection(Collections.CAREER_EXPLORER_CONVERSATIONS) + self._logger = logging.getLogger(self.__class__.__name__) + + @classmethod + def _from_db_doc(cls, document: CareerExplorerConversationDocument, finished: bool = False) -> CareerExplorerConversationResponse: + """ + Convert a CareerExplorerConversationDocument to a CareerExplorerConversationResponse. + Strips metadata from messages as it should not be exposed to the frontend. + """ + messages_without_metadata = [m.model_copy(update={"metadata": None}) for m in document.messages] + return CareerExplorerConversationResponse( + messages=messages_without_metadata, + finished=finished, + ) + + async def create(self, document: CareerExplorerConversationDocument) -> None: + await self._collection.insert_one(document.model_dump()) + + async def find_by_user(self, user_id: str) -> CareerExplorerConversationDocument | None: + result = await self._collection.find_one({"user_id": user_id}) + if result is None: + return None + return CareerExplorerConversationDocument.from_dict(result) + + async def append_message(self, user_id: str, message: CareerExplorerMessage) -> None: + now = datetime.now(timezone.utc) + await self._collection.update_one( + {"user_id": user_id}, + {"$push": {"messages": message.model_dump()}, "$set": {"updated_at": now}}, + ) + + async def update_memory_state( + self, + user_id: str, + summary: str, + num_turns_summarized: int, + ) -> None: + now = datetime.now(timezone.utc) + await self._collection.update_one( + {"user_id": user_id}, + {"$set": { + "summary": summary, + "num_turns_summarized": num_turns_summarized, + "updated_at": now, + }}, + ) + + async def delete_by_user(self, user_id: str) -> bool: + result = await self._collection.delete_one({"user_id": user_id}) + return result.deleted_count > 0 + + async def find_response_by_user(self, user_id: str) -> CareerExplorerConversationResponse | None: + document = await self.find_by_user(user_id) + if document is None: + return None + return self._from_db_doc(document, finished=False) + + async def get_or_create_conversation(self, user_id: str, welcome_message: str) -> CareerExplorerConversationResponse: + existing = await self.find_response_by_user(user_id) + if existing: + return existing + + now = datetime.now(timezone.utc) + intro = CareerExplorerMessage( + message_id=str(ObjectId()), + message=welcome_message, + sent_at=now, + sender=CareerExplorerMessageSender.AGENT, + ) + doc = CareerExplorerConversationDocument( + user_id=user_id, + messages=[intro], + created_at=now, + updated_at=now, + ) + try: + await self.create(doc) + except DuplicateKeyError: + self._logger.debug("Race condition: conversation already exists for user %s, fetching existing", user_id) + existing = await self.find_response_by_user(user_id) + if existing: + return existing + raise + + return self._from_db_doc(doc, finished=False) diff --git a/backend/app/career_explorer/routes.py b/backend/app/career_explorer/routes.py new file mode 100644 index 00000000..947a1483 --- /dev/null +++ b/backend/app/career_explorer/routes.py @@ -0,0 +1,126 @@ +import asyncio +import logging +from http import HTTPStatus +from typing import Optional + +from fastapi import APIRouter, FastAPI, Depends, HTTPException +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.app_config import get_application_config +from app.career_explorer.repository import CareerExplorerConversationRepository +from app.career_explorer.service import CareerExplorerService +from app.career_explorer.types import ( + CareerExplorerConversationInput, + CareerExplorerConversationResponse, +) +from app.constants.errors import HTTPErrorResponse +from app.conversations.constants import MAX_MESSAGE_LENGTH +from app.i18n.translation_service import get_i18n_manager +from app.server_dependencies.db_dependencies import CompassDBProvider +from app.server_dependencies.database_collections import Collections +from app.users.auth import Authentication, UserInfo +from app.agent.career_explorer_agent.agent import CareerExplorerAgent +from app.agent.career_explorer_agent.sector_search_service import SectorSearchService +from app.vector_search.vector_search_dependencies import get_embeddings_service +from app.vector_search.embeddings_model import EmbeddingService + +logger = logging.getLogger(__name__) + +_lock = asyncio.Lock() +_service_singleton: Optional[CareerExplorerService] = None + + +async def get_career_explorer_service( + career_explorer_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_career_explorer_db), + embedding_service: EmbeddingService = Depends(get_embeddings_service), +) -> CareerExplorerService: + global _service_singleton + if _service_singleton is None: + async with _lock: + if _service_singleton is None: + collection = career_explorer_db.get_collection(Collections.CAREER_EXPLORER_SECTOR_CHUNKS) + sector_search = SectorSearchService( + collection=collection, + embedding_service=embedding_service, + embedding_key="embedding", + index_name="sector_chunks_embedding_index", + ) + agent_factory = lambda: CareerExplorerAgent(sector_search_service=sector_search) + _service_singleton = CareerExplorerService( + repository=CareerExplorerConversationRepository(career_explorer_db), + agent_factory=agent_factory, + ) + return _service_singleton + + +def add_career_explorer_routes(app: FastAPI, authentication: Authentication): + router = APIRouter(prefix="/career-explorer", tags=["career-explorer"]) + + @router.post( + "/conversation", + status_code=HTTPStatus.CREATED, + response_model=CareerExplorerConversationResponse, + responses={HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}}, + ) + async def _get_or_create_conversation( + user_info: UserInfo = Depends(authentication.get_user_info()), + service: CareerExplorerService = Depends(get_career_explorer_service), + ): + try: + app_config = get_application_config() + get_i18n_manager().set_locale(app_config.language_config.default_locale) + return await service.get_or_create_conversation(user_info.user_id) + except Exception as e: + logger.exception("Error in career explorer: %s", e) + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected error") from e + + @router.post( + "/conversation/messages", + status_code=HTTPStatus.CREATED, + response_model=CareerExplorerConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.REQUEST_ENTITY_TOO_LARGE: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _send_message( + body: CareerExplorerConversationInput, + user_info: UserInfo = Depends(authentication.get_user_info()), + service: CareerExplorerService = Depends(get_career_explorer_service), + ): + if len(body.user_input) > MAX_MESSAGE_LENGTH: + raise HTTPException(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Message too long") + try: + app_config = get_application_config() + get_i18n_manager().set_locale(app_config.language_config.default_locale) + return await service.send_message(user_info.user_id, body.user_input) + except ValueError as e: + raise HTTPException(HTTPStatus.NOT_FOUND, str(e)) from e + except Exception as e: + logger.exception("Error in career explorer: %s", e) + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected error") from e + + @router.get( + "/conversation", + response_model=CareerExplorerConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _get_conversation( + user_info: UserInfo = Depends(authentication.get_user_info()), + service: CareerExplorerService = Depends(get_career_explorer_service), + ): + try: + app_config = get_application_config() + get_i18n_manager().set_locale(app_config.language_config.default_locale) + return await service.get_conversation(user_info.user_id) + except ValueError as e: + raise HTTPException(HTTPStatus.NOT_FOUND, str(e)) from e + except Exception as e: + logger.exception("Error in career explorer: %s", e) + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected error") from e + + app.include_router(router) diff --git a/backend/app/career_explorer/service.py b/backend/app/career_explorer/service.py new file mode 100644 index 00000000..8ed7bcad --- /dev/null +++ b/backend/app/career_explorer/service.py @@ -0,0 +1,80 @@ +import logging +from datetime import datetime, timezone +from typing import Callable + +from bson import ObjectId + +from app.agent.agent_types import AgentInput +from app.agent.career_explorer_agent.agent import CareerExplorerAgent, _get_welcome_message +from app.career_explorer.context_builder import build_windowed_context +from app.career_explorer.repository import ICareerExplorerConversationRepository +from app.career_explorer.types import ( + CareerExplorerConversationResponse, + CareerExplorerMessage, + CareerExplorerMessageSender, +) + + +class CareerExplorerService: + def __init__( + self, + repository: ICareerExplorerConversationRepository, + agent_factory: Callable[[], CareerExplorerAgent], + ): + self._repository = repository + self._agent_factory = agent_factory + self._logger = logging.getLogger(self.__class__.__name__) + + async def get_or_create_conversation(self, user_id: str) -> CareerExplorerConversationResponse: + return await self._repository.get_or_create_conversation(user_id, _get_welcome_message()) + + async def send_message(self, user_id: str, user_input: str) -> CareerExplorerConversationResponse: + conv = await self._repository.find_by_user(user_id) + if conv is None: + raise ValueError("Conversation not found") + + now = datetime.now(timezone.utc) + user_msg = CareerExplorerMessage( + message_id=str(ObjectId()), + message=user_input, + sent_at=now, + sender=CareerExplorerMessageSender.USER, + ) + await self._repository.append_message(user_id, user_msg) + + context, new_summary, new_num_turns = await build_windowed_context( + conv.messages, + summary=conv.summary, + num_turns_summarized=conv.num_turns_summarized, + ) + if new_summary != conv.summary or new_num_turns != conv.num_turns_summarized: + await self._repository.update_memory_state( + user_id, + new_summary, + new_num_turns, + ) + + agent = self._agent_factory() + agent_input = AgentInput(message=user_input, sent_at=now) + agent_output = await agent.execute(agent_input, context) + + agent_msg = CareerExplorerMessage( + message_id=agent_output.message_id or str(ObjectId()), + message=agent_output.message_for_user, + sent_at=datetime.now(timezone.utc), + sender=CareerExplorerMessageSender.AGENT, + metadata=agent_output.metadata, + ) + await self._repository.append_message(user_id, agent_msg) + + updated_response = await self._repository.find_response_by_user(user_id) + if updated_response is None: + raise ValueError("Conversation not found after appending message") + updated_response.finished = agent_output.finished + return updated_response + + async def get_conversation(self, user_id: str) -> CareerExplorerConversationResponse: + response = await self._repository.find_response_by_user(user_id) + if response is None: + raise ValueError("Conversation not found") + return response diff --git a/backend/app/career_explorer/types.py b/backend/app/career_explorer/types.py new file mode 100644 index 00000000..70abfe5f --- /dev/null +++ b/backend/app/career_explorer/types.py @@ -0,0 +1,88 @@ +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, Field, field_serializer, field_validator + + +class CareerExplorerMessageSender(str, Enum): + USER = "USER" + AGENT = "AGENT" + + +class CareerExplorerMessage(BaseModel): + message_id: str + message: str + sent_at: datetime + sender: CareerExplorerMessageSender + metadata: dict | None = None + + @field_serializer("sent_at") + def _serialize_sent_at(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @field_serializer("sender") + def _serialize_sender(self, sender: CareerExplorerMessageSender, _info) -> str: + return sender.name + + @classmethod + @field_validator("sender", mode="before") + def _deserialize_sender(cls, value: str | CareerExplorerMessageSender) -> CareerExplorerMessageSender: + if isinstance(value, str): + return CareerExplorerMessageSender[value] + return value + + class Config: + extra = "forbid" + + +class CareerExplorerConversationResponse(BaseModel): + messages: list[CareerExplorerMessage] + finished: bool = False + + class Config: + extra = "forbid" + + +class CareerExplorerConversationInput(BaseModel): + user_input: str + + class Config: + extra = "forbid" + + +class CareerExplorerConversationDocument(BaseModel): + user_id: str + messages: list[CareerExplorerMessage] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + summary: str = "" + num_turns_summarized: int = 0 + + @field_serializer("created_at", "updated_at") + def _serialize_datetime(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @classmethod + @field_validator("created_at", "updated_at", mode="before") + def _deserialize_datetime(cls, value: str | datetime) -> datetime: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + elif isinstance(value, datetime): + dt = value + else: + raise ValueError(f"Invalid datetime: {value}") + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + @staticmethod + def from_dict(d: dict) -> "CareerExplorerConversationDocument": + return CareerExplorerConversationDocument( + user_id=str(d["user_id"]), + messages=[CareerExplorerMessage(**m) for m in d.get("messages", [])], + created_at=d["created_at"], + updated_at=d["updated_at"], + summary=d.get("summary", ""), + num_turns_summarized=d.get("num_turns_summarized", 0), + ) + + class Config: + extra = "forbid" diff --git a/backend/app/career_readiness/__init__.py b/backend/app/career_readiness/__init__.py new file mode 100644 index 00000000..a2bae8cd --- /dev/null +++ b/backend/app/career_readiness/__init__.py @@ -0,0 +1 @@ +from .routes import add_career_readiness_routes diff --git a/backend/app/career_readiness/agent.py b/backend/app/career_readiness/agent.py new file mode 100644 index 00000000..1eda9567 --- /dev/null +++ b/backend/app/career_readiness/agent.py @@ -0,0 +1,301 @@ +""" +Career readiness agent that chats with users grounded in module content. + +Uses composition to wrap a SimpleLLMAgent — this agent is standalone and is +NOT part of the AgentDirector pipeline. +""" +import logging +import time +from dataclasses import dataclass, field +from textwrap import dedent + +from pydantic import BaseModel, ConfigDict + +from app.agent.agent_types import AgentInput, AgentOutput, AgentType, LLMStats, AgentOutputWithReasoning +from app.agent.llm_caller import LLMCaller +from app.agent.prompt_template.locale_style import get_language_style +from app.agent.simple_llm_agent.prompt_response_template import ( + get_json_response_instructions, +) +from app.career_readiness.types import ConversationMode +from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter +from app.conversation_memory.conversation_memory_types import ConversationContext +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG +from common_libs.llm.schema_builder import with_response_schema + + +class CareerReadinessModelResponse(BaseModel): + """ + Custom response schema for the career readiness agent with topic tracking. + + Order matters for model prediction quality: + 1. reasoning — sets context + 2. finished — depends on reasoning + 3. message — depends on reasoning + finished + 4. topics_covered — depends on the conversation turn + """ + + model_config = ConfigDict(extra="forbid") + + reasoning: str + """Chain of Thought reasoning behind the response""" + + finished: bool + """Whether the agent judges all topics have been sufficiently covered""" + + message: str + """Message for the user""" + + topics_covered: list[str] = [] + """Topics from the module's topic list that were addressed in this turn""" + + +@dataclass +class CareerReadinessAgentOutput: + """Wraps AgentOutput with topic tracking data from the custom response schema.""" + + agent_output: AgentOutput + """The standard agent output (message, finished, stats, etc.)""" + + topics_covered: list[str] = field(default_factory=list) + """Topics reported as covered in this turn""" + + +def _build_instruction_mode_instructions(module_title: str, module_content: str, topics: list[str]) -> str: + """Build system instructions for instruction mode (scaffolded Socratic tutoring).""" + topics_list = "\n".join(f"- {topic}" for topic in topics) + language_style = get_language_style(with_locale=False, for_json_output=True) + + response_instructions = dedent("""\ + # Response Format + You must respond with valid JSON matching this exact schema: + { + "reasoning": "Your internal chain-of-thought reasoning (not shown to the user)", + "finished": true/false, + "message": "Your message to the student", + "topics_covered": ["Topic Name 1", "Topic Name 2"] + } + + - "reasoning": Explain your pedagogical reasoning — what the student knows, what to cover next, which scaffolding level to use. + - "finished": Set to true ONLY when you judge that ALL topics have been sufficiently covered and the student has demonstrated understanding. Do not set finished to true prematurely. + - "message": Your response to the student. Do not format with markdown. Keep under 200 words. + - "topics_covered": List ONLY topic names from the module topic list that you meaningfully addressed in THIS turn. Use exact topic names from the list above. If you only briefly mentioned a topic, do not include it.""") + + template = dedent("""\ + You are a career readiness tutor specialising in "{module_title}". + Your role is to guide the student through this topic using scaffolded Socratic tutoring. + + # Teaching Method: Scaffolded Socratic Tutoring + Follow this graduated assistance model for EACH topic: + 1. ASSESS — Begin by asking what the student already knows about the topic. Do not assume prior knowledge. + 2. GUIDE — Ask leading questions that help the student reason through the material themselves. + 3. HINT — If the student struggles (wrong answer, "I don't know", confusion), provide partial information or worked examples. + 4. EXPLAIN — Give direct explanations ONLY as a last resort, after hints have not helped. + 5. FADE — As the student demonstrates understanding, reduce your support and encourage independent reasoning. + + # Comprehension Checks + Embed checks throughout the conversation: + - Ask the student to explain concepts back in their own words + - Ask application questions (e.g., "How would this apply to your situation?") + - Ask prediction questions (e.g., "What do you think would happen if...?") + - Periodically return to earlier topics to reinforce retention + + # Handling Minimal Responses + If the student gives a minimal response like "yes", "yeah", "okay", "sure", "definitely", + or any other one-word agreement, do NOT treat this as evidence of understanding. + Instead, ask a follow-up question that requires them to demonstrate comprehension, such as: + - "Can you explain that back to me in your own words?" + - "Can you give me an example from your own experience?" + - "How would you apply this in a real situation?" + Never ask "Does that make sense?" or similar yes/no questions as comprehension checks. + Only mark a topic in topics_covered when the student has given a substantive response + that shows genuine understanding — not just agreement. + + # Module Topics + You must cover ALL of the following topics before setting "finished" to true: + {topics_list} + + # Grounding Content + Use the following content as your primary knowledge base. Always ground your responses + in this material. Do not invent facts or techniques not supported by this content. + + {module_content} + + # Rules + - Be encouraging, supportive, and practical. + - Keep responses concise and focused (under 200 words per message). + - Do not format or style your response with markdown. + - Do not discuss, mention, or reveal anything about the quiz. + - If the user asks something outside the scope of the grounding content, + politely redirect them to the topics you can help with. + - Cover topics in a natural conversational order, not necessarily the order listed above. + - Do not rush through topics. Spend adequate time on each one based on the student's responses. + + {language_style} + + {response_instructions} + """) + + return template.format( + module_title=module_title, + module_content=module_content, + topics_list=topics_list, + language_style=language_style, + response_instructions=response_instructions, + ) + + +def _build_support_mode_instructions(module_title: str, module_content: str) -> str: + """Build system instructions for support mode (post-quiz follow-up Q&A).""" + language_style = get_language_style(with_locale=False, for_json_output=True) + + response_instructions = dedent("""\ + # Response Format + You must respond with valid JSON matching this exact schema: + { + "reasoning": "Your internal reasoning (not shown to the user)", + "finished": false, + "message": "Your response to the student", + "topics_covered": [] + } + + - "finished": Always set to false in support mode. + - "topics_covered": Always set to an empty list in support mode. + - "message": Your response to the student. Do not format with markdown. Keep under 200 words.""") + + template = dedent("""\ + You are a career readiness support assistant for "{module_title}". + The student has already completed the lesson and passed the quiz for this module. + + Your role is to answer follow-up questions about the module's topics. + + # Grounding Content + Use the following content as your primary knowledge base: + + {module_content} + + # Rules + - Answer questions grounded in the module content above. + - Be helpful, encouraging, and concise. + - Do not re-initiate the lesson plan or deliver a quiz. + - Do not format or style your response with markdown. + - Keep responses under 200 words. + - If the user asks something outside the scope of this module, + politely redirect them. + + {language_style} + + {response_instructions} + """) + + return template.format( + module_title=module_title, + module_content=module_content, + language_style=language_style, + response_instructions=response_instructions, + ) + + +class CareerReadinessAgent: + """ + AI agent that coaches users on a specific career readiness module. + + Uses composition — wraps a GeminiGenerativeLLM + LLMCaller rather than + inheriting from Agent/SimpleLLMAgent, because this agent operates outside + the AgentDirector pipeline. + """ + + def __init__(self, module_title: str, module_content: str, + mode: ConversationMode = ConversationMode.INSTRUCTION, + topics: list[str] | None = None): + self._logger = logging.getLogger(CareerReadinessAgent.__name__) + + if mode == ConversationMode.INSTRUCTION: + self._system_instructions = _build_instruction_mode_instructions( + module_title, module_content, topics or []) + else: + self._system_instructions = _build_support_mode_instructions( + module_title, module_content) + + config = LLMConfig( + generation_config=LOW_TEMPERATURE_GENERATION_CONFIG | JSON_GENERATION_CONFIG | with_response_schema( + CareerReadinessModelResponse) + ) + self._llm = GeminiGenerativeLLM(system_instructions=self._system_instructions, config=config) + self._llm_caller: LLMCaller[CareerReadinessModelResponse] = LLMCaller[CareerReadinessModelResponse]( + model_response_type=CareerReadinessModelResponse) + + @property + def system_instructions(self) -> str: + return self._system_instructions + + async def execute(self, user_input: AgentInput, context: ConversationContext) -> CareerReadinessAgentOutput: + """ + Process user input and return the agent's response with topic tracking. + + :param user_input: The user's message + :param context: The conversation context with history + :return: The agent's output with topics_covered + """ + agent_start_time = time.time() + + msg = user_input.message.strip() + if msg == "": + msg = "(silence)" + + model_response: CareerReadinessModelResponse | None + llm_stats_list: list[LLMStats] + + try: + model_response, llm_stats_list = await self._llm_caller.call_llm( + llm=self._llm, + llm_input=ConversationHistoryFormatter.format_for_agent_generative_prompt( + model_response_instructions=get_json_response_instructions(), + context=context, + user_input=msg, + ), + logger=self._logger, + ) + except Exception as e: + self._logger.exception("An error occurred while calling the LLM: %s", e) + model_response = None + llm_stats_list = [] + + if model_response is None: + model_response = CareerReadinessModelResponse( + reasoning="Failed to get a response", + message="I am facing some difficulties right now, could you please repeat what you said?", + finished=False, + topics_covered=[], + ) + + agent_end_time = time.time() + agent_output = AgentOutputWithReasoning( + message_for_user=model_response.message.strip('"'), + finished=model_response.finished, + reasoning=model_response.reasoning, + agent_type=AgentType.CAREER_READINESS_AGENT, + agent_response_time_in_sec=round(agent_end_time - agent_start_time, 2), + llm_stats=llm_stats_list, + ) + + return CareerReadinessAgentOutput( + agent_output=agent_output, + topics_covered=model_response.topics_covered, + ) + + async def generate_intro_message(self, context: ConversationContext) -> CareerReadinessAgentOutput: + """ + Generate the introductory message for a new conversation. + + Sends an artificial "(silence)" input to trigger the agent's greeting. + + :param context: An empty conversation context + :return: The agent's introductory output with topics_covered + """ + artificial_input = AgentInput( + message="(silence)", + is_artificial=True, + ) + return await self.execute(artificial_input, context) diff --git a/backend/app/career_readiness/errors.py b/backend/app/career_readiness/errors.py new file mode 100644 index 00000000..6b2780d6 --- /dev/null +++ b/backend/app/career_readiness/errors.py @@ -0,0 +1,59 @@ +""" +Domain-specific exceptions for the career readiness module. +""" + + +class CareerReadinessModuleNotFoundError(Exception): + """Raised when a career readiness module is not found.""" + + def __init__(self, module_id: str): + super().__init__(f"Career readiness module not found: {module_id}") + + +class ConversationNotFoundError(Exception): + """Raised when a career readiness conversation is not found.""" + + def __init__(self, conversation_id: str): + super().__init__(f"Career readiness conversation not found: {conversation_id}") + + +class ConversationAlreadyExistsError(Exception): + """Raised when a conversation already exists for a module and user.""" + + def __init__(self, module_id: str, user_id: str): + super().__init__(f"A conversation already exists for module {module_id} and user {user_id}") + + +class ConversationAccessDeniedError(Exception): + """Raised when a user attempts to access a conversation they do not own.""" + + def __init__(self, conversation_id: str, user_id: str): + super().__init__(f"User {user_id} does not have access to conversation {conversation_id}") + + +class ConversationModuleMismatchError(Exception): + """Raised when a conversation does not belong to the specified module.""" + + def __init__(self, conversation_id: str, module_id: str): + super().__init__(f"Conversation {conversation_id} does not belong to module {module_id}") + + +class ModuleNotUnlockedError(Exception): + """Raised when attempting to start a conversation for a locked module.""" + + def __init__(self, module_id: str): + super().__init__(f"Module {module_id} is not yet unlocked") + + +class QuizNotAvailableError(Exception): + """Raised when the quiz is not available for this conversation.""" + + def __init__(self, conversation_id: str): + super().__init__(f"Quiz is not available for conversation {conversation_id}") + + +class QuizAlreadyPassedError(Exception): + """Raised when quiz was already passed.""" + + def __init__(self, conversation_id: str): + super().__init__(f"Quiz already passed for conversation {conversation_id}") diff --git a/backend/app/career_readiness/module_loader.py b/backend/app/career_readiness/module_loader.py new file mode 100644 index 00000000..6c7d455e --- /dev/null +++ b/backend/app/career_readiness/module_loader.py @@ -0,0 +1,293 @@ +""" +This module loads career readiness module definitions from markdown files with frontmatter. +""" +import logging +import re +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +logger = logging.getLogger(__name__) + +_MODULES_DIR = Path(__file__).parent / "modules" + + +class QuizQuestion(BaseModel): + """A single multiple-choice quiz question.""" + + model_config = ConfigDict(extra="forbid") + + question: str + """The question text""" + + options: list[str] + """The answer options, e.g. ["A. Resume", "B. Letter", "C. Form", "D. Report"]""" + + correct_answer: str + """The correct answer letter, e.g. "A" """ + + +class QuizConfig(BaseModel): + """Configuration for a module's quiz section.""" + + model_config = ConfigDict(extra="forbid") + + pass_threshold: float = 0.7 + """Fraction of correct answers required to pass (0.0–1.0)""" + + questions: list[QuizQuestion] + """The list of quiz questions""" + + +class ModuleConfig(BaseModel): + """ + Represents a career readiness module definition loaded from a markdown file. + """ + + model_config = ConfigDict(extra="forbid") + + id: str + """The unique identifier (slug) of the module""" + + title: str + """The display title of the module""" + + description: str + """A short description of what the module covers""" + + icon: str + """Icon identifier for the module""" + + sort_order: int + """Display order of the module in the list""" + + input_placeholder: str + """Placeholder text shown in the chat input for this module""" + + content: str + """The markdown body content used as grounding for the agent""" + + topics: list[str] + """The list of topics the agent must cover before the quiz becomes available""" + + quiz: QuizConfig | None = None + """The quiz configuration, parsed from the ## Quiz section. None if no quiz.""" + + +def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + """ + Parse a markdown file with ---delimited frontmatter. + Returns a tuple of (frontmatter dict, markdown body). + """ + if not text.startswith("---"): + raise ValueError("Markdown file must start with --- frontmatter delimiter") + + # Find the closing --- delimiter + end_index = text.index("---", 3) + frontmatter_text = text[3:end_index].strip() + body = text[end_index + 3:].strip() + + # Parse key: value pairs + metadata = {} + for line in frontmatter_text.splitlines(): + line = line.strip() + if not line: + continue + if ":" not in line: + raise ValueError(f"Invalid frontmatter line (missing colon): {line}") + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip() + + return metadata, body + + +def _split_quiz_section(body: str) -> tuple[str, str | None]: + """ + Split the markdown body on the '## Quiz' heading. + Returns (content_before_quiz, quiz_section_text_or_none). + """ + # Match ## Quiz at the start of a line (with optional trailing whitespace) + pattern = r"(?m)^## Quiz\s*$" + match = re.search(pattern, body) + if match is None: + return body, None + + content = body[:match.start()].rstrip() + quiz_text = body[match.end():].strip() + return content, quiz_text + + +def _parse_quiz_section(text: str) -> QuizConfig: + """ + Parse a quiz section into a QuizConfig. + + Expected format: + pass_threshold: 0.7 (optional, defaults to 0.7) + + 1. Question text here? + A. Option A text + B. Option B text + C. Option C text + D. Option D text + Answer: B + + 2. Another question? + ... + """ + lines = text.strip().splitlines() + + # Parse optional pass_threshold from the first non-empty line + pass_threshold = 0.7 + start_index = 0 + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("pass_threshold:"): + try: + pass_threshold = float(stripped.split(":", 1)[1].strip()) + except ValueError as e: + raise ValueError(f"Invalid pass_threshold value: {stripped}") from e + start_index = i + 1 + break + # First non-empty line is not pass_threshold — start parsing questions from here + start_index = i + break + + # Parse questions + questions: list[QuizQuestion] = [] + current_question: str | None = None + current_options: list[str] = [] + current_answer: str | None = None + + question_pattern = re.compile(r"^\d+\.\s+(.+)") + option_pattern = re.compile(r"^([A-D])\.\s+(.+)") + answer_pattern = re.compile(r"^Answer:\s+([A-Da-d])") + + for line in lines[start_index:]: + stripped = line.strip() + if not stripped: + continue + + question_match = question_pattern.match(stripped) + option_match = option_pattern.match(stripped) + answer_match = answer_pattern.match(stripped) + + if question_match: + # Save previous question if exists + if current_question is not None: + if current_answer is None: + raise ValueError(f"Quiz question missing Answer: '{current_question}'") + questions.append(QuizQuestion( + question=current_question, + options=current_options, + correct_answer=current_answer, + )) + current_question = question_match.group(1) + current_options = [] + current_answer = None + elif option_match: + current_options.append(f"{option_match.group(1)}. {option_match.group(2)}") + elif answer_match: + current_answer = answer_match.group(1).upper() + + # Save the last question + if current_question is not None: + if current_answer is None: + raise ValueError(f"Quiz question missing Answer: '{current_question}'") + questions.append(QuizQuestion( + question=current_question, + options=current_options, + correct_answer=current_answer, + )) + + if not questions: + raise ValueError("Quiz section contains no questions") + + return QuizConfig(pass_threshold=pass_threshold, questions=questions) + + +def _load_module_from_file(file_path: Path) -> ModuleConfig: + """ + Load a single module configuration from a markdown file. + """ + text = file_path.read_text(encoding="utf-8") + metadata, body = _parse_frontmatter(text) + + # Parse topics from comma-separated frontmatter value + topics_raw = metadata.get("topics", "") + topics = [t.strip() for t in topics_raw.split(",") if t.strip()] if topics_raw else [] + + # Split quiz section from content + content, quiz_text = _split_quiz_section(body) + + # Parse quiz if present + quiz = _parse_quiz_section(quiz_text) if quiz_text is not None else None + + return ModuleConfig( + id=metadata["id"], + title=metadata["title"], + description=metadata["description"], + icon=metadata["icon"], + sort_order=int(metadata["sort_order"]), + input_placeholder=metadata["input_placeholder"], + content=content, + topics=topics, + quiz=quiz, + ) + + +class ModuleRegistry: + """ + Registry of all available career readiness modules. + Loads modules from markdown files in the modules directory. + """ + + def __init__(self, modules_dir: Path = _MODULES_DIR): + self._modules: dict[str, ModuleConfig] = {} + self._load_modules(modules_dir) + + def _load_modules(self, modules_dir: Path) -> None: + """ + Load all markdown files from the modules directory. + """ + if not modules_dir.exists(): + logger.warning("Modules directory does not exist: %s", modules_dir) + return + + for file_path in sorted(modules_dir.glob("*.md")): + if file_path.name.startswith("_") or file_path.name == "README.md": + continue + try: + module = _load_module_from_file(file_path) + self._modules[module.id] = module + logger.info("Loaded career readiness module: %s", module.id) + except Exception as e: + logger.error("Failed to load module from %s: %s", file_path, e) + raise + + def get_all_modules(self) -> list[ModuleConfig]: + """ + Get all modules sorted by sort_order. + """ + return sorted(self._modules.values(), key=lambda m: m.sort_order) + + def get_module(self, module_id: str) -> ModuleConfig | None: + """ + Get a specific module by its ID. Returns None if not found. + """ + return self._modules.get(module_id) + + +# Module-level singleton +_registry: ModuleRegistry | None = None + + +def get_module_registry() -> ModuleRegistry: + """ + Get the singleton module registry instance. + """ + global _registry + if _registry is None: + _registry = ModuleRegistry() + return _registry diff --git a/backend/app/career_readiness/modules/README.md b/backend/app/career_readiness/modules/README.md new file mode 100644 index 00000000..6287f9d5 --- /dev/null +++ b/backend/app/career_readiness/modules/README.md @@ -0,0 +1,87 @@ +# Career Readiness Modules — Authoring Guide + +This guide explains how to add a new career readiness module. + +## File Structure + +Each module is a single Markdown file in this directory (e.g., `my_new_module.md`). +Files starting with `_` are ignored by the loader. + +## Required Frontmatter + +Every module file must start with a YAML frontmatter block: + +```yaml +--- +id: my-new-module +title: My New Module +description: A short description shown in the module list. +icon: module-icon +sort_order: 6 +input_placeholder: Ask about this topic... +topics: Topic One, Topic Two, Topic Three +--- +``` + +| Field | Description | +|-------|-------------| +| `id` | Unique slug identifier (kebab-case). Must not conflict with existing modules. | +| `title` | Display name shown to the user. | +| `description` | Short summary shown in the module list. | +| `icon` | Icon identifier used by the frontend. | +| `sort_order` | Integer controlling module progression order. Modules unlock sequentially by this value. | +| `input_placeholder` | Placeholder text in the chat input box. | +| `topics` | Comma-separated list of topics the AI tutor must cover before the quiz is triggered. | + +## Grounding Content + +After the frontmatter, write the educational content using Markdown headings. +Organize content with H2 sections (`##`) that correspond to your topics list. +This content is what the AI tutor uses as its knowledge base — it will not invent facts beyond this material. + +## Quiz Section + +Add a `## Quiz` section at the bottom of the file. This section is **not** shown to the AI tutor. + +Format: + +```markdown +## Quiz +pass_threshold: 0.7 + +1. Question text? +A. Option A +B. Option B +C. Option C +D. Option D +Answer: B + +2. Another question? +A. Option A +B. Option B +C. Option C +D. Option D +Answer: C +``` + +- `pass_threshold` (optional, defaults to `0.7`) — fraction of correct answers needed to pass. +- Include at least 5 questions; we recommend 10. +- Each question has exactly 4 options (A–D) and one correct `Answer:` line. + +## Module Ordering & Sequential Unlock + +Modules unlock sequentially based on `sort_order`. The first module (lowest `sort_order`) is always unlocked. Subsequent modules unlock only after the previous one is completed (quiz passed). + +## Checklist Before Deploying + +1. Frontmatter has all required fields +2. `id` is unique and uses kebab-case +3. `sort_order` does not conflict with existing modules +4. `topics` list matches the content sections +5. `## Quiz` section has correctly formatted questions with `Answer:` lines +6. Content is factually accurate and appropriate for the target audience +7. Run `poetry run pytest app/career_readiness/test_module_loader.py -v` to verify parsing + +## Example + +See `_example_module.md` for a complete template. diff --git a/backend/app/career_readiness/modules/_example_module.md b/backend/app/career_readiness/modules/_example_module.md new file mode 100644 index 00000000..f40ad81a --- /dev/null +++ b/backend/app/career_readiness/modules/_example_module.md @@ -0,0 +1,74 @@ +--- +id: example-module +title: Example Module Title +description: A short description of what this module covers. +icon: example +sort_order: 99 +input_placeholder: Ask about this topic... +topics: First Topic, Second Topic, Third Topic +--- + +# Example Module Title + +## Overview + +Introduce the module here. Explain what the learner will gain from completing it. + +## First Topic + +Write educational content about the first topic here. Use lists, examples, and practical advice. + +Key points: +- Point one +- Point two +- Point three + +## Second Topic + +Content for the second topic. Include concrete examples relevant to the Zambian job market. + +### Subtopic + +You can use H3 headings for subtopics within a main topic. + +## Third Topic + +Content for the third topic. Keep language clear and accessible. + +## Quiz +pass_threshold: 0.7 + +1. What is the main purpose of this module? +A. To teach cooking skills +B. To introduce the module's core topics +C. To prepare for a driving test +D. To learn a musical instrument +Answer: B + +2. Which of the following is a key point from the first topic? +A. Point one +B. Unrelated fact +C. Random statement +D. None of the above +Answer: A + +3. What should you include in educational content? +A. Only theory +B. Concrete examples relevant to the job market +C. Personal opinions only +D. Unverified claims +Answer: B + +4. How are subtopics organized? +A. Using H1 headings +B. Using H3 headings under the main topic +C. Using bold text only +D. They are not organized +Answer: B + +5. What is the default pass threshold? +A. 50% +B. 60% +C. 70% +D. 80% +Answer: C diff --git a/backend/app/career_readiness/modules/cover_letter.md b/backend/app/career_readiness/modules/cover_letter.md new file mode 100644 index 00000000..6a890349 --- /dev/null +++ b/backend/app/career_readiness/modules/cover_letter.md @@ -0,0 +1,142 @@ +--- +id: cover-letter +title: Cover Letter & Motivation Statement +description: Learn to write compelling cover letters and motivation statements tailored to specific roles. +icon: letter +sort_order: 3 +input_placeholder: Ask about cover letters... +topics: What is a Cover Letter, Cover Letter Structure, Motivation Statement vs Cover Letter, Writing Tips, Common Mistakes to Avoid +--- + +# Cover Letter & Motivation Statement + +## Overview + +This module teaches you how to write effective cover letters and motivation statements that complement your CV and persuade employers to invite you for an interview. + +## What is a Cover Letter? + +A cover letter is a one-page document that accompanies your CV. It explains why you are applying for a specific role and why you are a strong candidate. + +## Cover Letter Structure + +### 1. Header +- Your contact information +- Date +- Employer's contact information + +### 2. Opening Paragraph +- State the position you are applying for +- Mention how you learned about the opportunity +- Include a compelling hook that grabs attention + +### 3. Body Paragraphs (1-2) +- Highlight your most relevant skills and experience +- Provide specific examples that demonstrate your qualifications +- Show that you understand the company and the role +- Explain how your background aligns with the job requirements + +### 4. Closing Paragraph +- Restate your interest in the position +- Thank the reader for their consideration +- Include a call to action (e.g., "I look forward to discussing this opportunity") + +## Motivation Statement vs Cover Letter + +A **motivation statement** is similar but focuses more on: +- Your personal motivation for applying +- Your long-term career goals +- Why this specific organization or program appeals to you +- What you hope to contribute and gain + +Motivation statements are commonly used for academic programs, scholarships, and NGO positions. + +## Writing Tips + +- **Research the employer** — reference their mission, values, or recent projects +- **Be specific** — use concrete examples, not generic claims +- **Match keywords** from the job description +- **Show enthusiasm** without being excessive +- **Keep it to one page** — concise and focused +- **Address it to a specific person** when possible (avoid "To Whom It May Concern") + +## Common Mistakes to Avoid + +- Repeating your CV word for word +- Using a generic template without customization +- Focusing only on what you want (instead of what you offer) +- Forgetting to proofread +- Not following the application instructions + +## Quiz +pass_threshold: 0.7 + +1. What is the primary purpose of a cover letter? +A. To repeat your CV word for word +B. To explain why you are applying for a specific role and why you are a strong candidate +C. To list your references +D. To provide your salary expectations +Answer: B + +2. How long should a cover letter typically be? +A. 3-4 pages +B. One page +C. Half a page +D. As long as needed +Answer: B + +3. What should the opening paragraph of a cover letter include? +A. Your salary requirements +B. The position you are applying for and how you learned about it +C. A detailed work history +D. Your personal hobbies +Answer: B + +4. What is the difference between a cover letter and a motivation statement? +A. They are exactly the same +B. A motivation statement focuses more on personal motivation and long-term career goals +C. A cover letter is only for academic programs +D. A motivation statement replaces a CV +Answer: B + +5. Which is a recommended writing tip for cover letters? +A. Use a generic template without customization +B. Research the employer and reference their mission or values +C. Focus only on what you want from the company +D. Address it "To Whom It May Concern" +Answer: B + +6. What should the body paragraphs of a cover letter highlight? +A. Your personal life details +B. Your most relevant skills and experience with specific examples +C. Every job you have ever had +D. General statements about being a hard worker +Answer: B + +7. What is a common cover letter mistake? +A. Tailoring the letter to the specific role +B. Showing enthusiasm for the position +C. Repeating your CV word for word +D. Addressing it to a specific person +Answer: C + +8. What should the closing paragraph include? +A. A demand for the job +B. A restatement of interest and a call to action +C. Your full work history +D. Criticism of other candidates +Answer: B + +9. Motivation statements are commonly used for: +A. Retail job applications +B. Academic programs, scholarships, and NGO positions +C. Social media profiles +D. Reference letters +Answer: B + +10. When addressing a cover letter, what is preferred? +A. "Dear Sir or Madam" always +B. A specific person's name when possible +C. No greeting at all +D. "Hey there" +Answer: B diff --git a/backend/app/career_readiness/modules/cv_development.md b/backend/app/career_readiness/modules/cv_development.md new file mode 100644 index 00000000..e721852f --- /dev/null +++ b/backend/app/career_readiness/modules/cv_development.md @@ -0,0 +1,147 @@ +--- +id: cv-development +title: CV Development +description: Learn to build and tailor a professional CV that highlights your skills and experience for different employers. +icon: cv +sort_order: 2 +input_placeholder: Ask about CVs... +topics: What is a CV, CV Structure, CV Writing Tips, Common Mistakes to Avoid +--- + +# CV Development + +## Overview + +This module guides you through creating a professional CV (Curriculum Vitae) that effectively showcases your qualifications to potential employers. + +## What is a CV? + +A CV is a document that summarizes your education, work experience, skills, and achievements. It is your primary tool for making a first impression with employers. + +## CV Structure + +### 1. Contact Information +- Full name +- Phone number +- Email address +- Location (city, country) + +### 2. Personal Statement / Professional Summary +A brief paragraph (3-4 sentences) that summarizes: +- Who you are professionally +- Your key skills and experience +- What you are looking for + +### 3. Work Experience +For each position, include: +- Job title +- Company name +- Dates of employment +- Key responsibilities and achievements (use bullet points) +- Quantify results where possible (e.g., "Increased sales by 20%") + +### 4. Education +- Degree or qualification +- Institution name +- Dates attended +- Relevant coursework or achievements + +### 5. Skills +- Group skills by category (technical, language, etc.) +- Include proficiency levels where appropriate + +### 6. Additional Sections (optional) +- Certifications +- Volunteer experience +- Languages +- References + +## CV Writing Tips + +- **Tailor your CV** for each application by emphasizing relevant experience +- **Use action verbs** to describe achievements (managed, developed, implemented) +- **Keep it concise** — ideally 1-2 pages +- **Use consistent formatting** — same fonts, spacing, and bullet style throughout +- **Proofread carefully** — spelling and grammar errors create a poor impression +- **Avoid personal information** that is not relevant (age, marital status, photo — unless required) + +## Common Mistakes to Avoid + +- Including irrelevant work experience +- Using vague descriptions instead of specific achievements +- Having gaps in employment without explanation +- Using an unprofessional email address +- Making the CV too long or too short + +## Quiz +pass_threshold: 0.7 + +1. What is the primary purpose of a CV? +A. To list your hobbies and interests +B. To summarize your education, experience, skills, and achievements for employers +C. To provide personal information like age and marital status +D. To replace a cover letter +Answer: B + +2. What should be included in the Personal Statement section? +A. Your full biography +B. A brief summary of who you are professionally, your key skills, and what you seek +C. Your salary expectations +D. A list of references +Answer: B + +3. When describing work experience, what is recommended? +A. Write long paragraphs about each role +B. Use bullet points with quantified achievements and action verbs +C. Only list the company names +D. Include every task you ever performed +Answer: B + +4. Why should you tailor your CV for each application? +A. Employers like longer CVs +B. To emphasize the experience most relevant to the specific role +C. It is a legal requirement +D. Generic CVs are too short +Answer: B + +5. What is a common mistake to avoid on a CV? +A. Using action verbs +B. Keeping it to 1-2 pages +C. Including irrelevant work experience +D. Using consistent formatting +Answer: C + +6. How long should a CV ideally be? +A. 5 or more pages +B. 1-2 pages +C. Exactly half a page +D. There is no recommended length +Answer: B + +7. Which of these should NOT typically be included on a CV? +A. Contact information +B. Work experience +C. Unprofessional email address +D. Education details +Answer: C + +8. What is the best way to describe achievements on a CV? +A. Use vague statements like "did many things" +B. Use specific, quantified results like "Increased sales by 20%" +C. Let the employer guess what you achieved +D. Only describe responsibilities, not results +Answer: B + +9. Which section of a CV is optional? +A. Contact information +B. Work experience +C. Certifications and volunteer experience +D. Education +Answer: C + +10. What should you always do before submitting your CV? +A. Add a photo +B. Proofread carefully for spelling and grammar errors +C. Make it at least 5 pages long +D. Remove your contact information +Answer: B diff --git a/backend/app/career_readiness/modules/entrepreneurship.md b/backend/app/career_readiness/modules/entrepreneurship.md new file mode 100644 index 00000000..5d6b6b56 --- /dev/null +++ b/backend/app/career_readiness/modules/entrepreneurship.md @@ -0,0 +1,294 @@ +--- +id: entrepreneurship +title: Entrepreneurship & Enterprise Development +description: Learn how to develop an entrepreneurial mindset, start and manage a business, design products, and pitch your ideas. +icon: entrepreneurship +sort_order: 6 +input_placeholder: Ask about starting a business... +topics: Developing an Entrepreneurial Mindset, Establishing an Enterprise, Managing an Enterprise, Designing a Product and Production Process, Business Pitching +--- + +# Entrepreneurship & Enterprise Development + +## Overview + +This module is based on the TEVETA-approved Entrepreneurship and Enterprise Development for Startups (EEDS) syllabus. It covers the essential knowledge and skills you need to start, run, and grow your own business in Zambia. Whether you have a business idea already or are just exploring the possibility, this module will guide you through the key steps. + +## Developing an Entrepreneurial Mindset + +### What is Entrepreneurship? + +Entrepreneurship is the process of identifying opportunities and creating value by starting and running a business. An entrepreneur takes on risk in exchange for the potential of building something meaningful and profitable. + +### Entrepreneurial Identity and Values + +Before starting a business, it helps to understand yourself: +- **Self-awareness**: Know your strengths, weaknesses, and what motivates you +- **Values**: What matters to you? Honesty, community impact, innovation? Your values shape how you run your business +- **Vision**: Have a clear picture of what you want to achieve + +### Key Entrepreneurial Competencies + +Successful entrepreneurs tend to share certain traits: +- **Creativity and innovation** — seeing problems as opportunities +- **Resilience** — bouncing back from setbacks and learning from failure +- **Initiative** — taking action without waiting to be told +- **Risk management** — taking calculated risks, not reckless ones +- **Leadership** — inspiring and guiding others toward a shared goal + +### Innovation and Opportunity Recognition + +Innovation does not always mean inventing something new. It can mean: +- Improving an existing product or service +- Delivering something in a more convenient way +- Serving an underserved market or community +- Combining existing ideas in a new way + +Look around your community — what problems do people face? What needs are not being met? These are potential business opportunities. + +## Establishing an Enterprise + +### Generating Business Ideas + +Good business ideas often come from: +- **Personal experience** — problems you have faced yourself +- **Community needs** — gaps in local products or services +- **Skills and hobbies** — things you are good at or enjoy doing +- **Market trends** — growing demand in certain sectors + +### Evaluating Opportunities + +Not every idea is a good business opportunity. Evaluate your ideas by asking: +- Is there real demand for this product or service? +- Can I reach the customers who need it? +- Can I deliver it at a price customers will pay, while still making a profit? +- What makes my offering different from what already exists? + +### Business Models + +A business model describes how your enterprise creates, delivers, and captures value. Key elements include: +- **Value proposition** — what problem you solve and why customers should choose you +- **Customer segments** — who your customers are +- **Revenue streams** — how you will earn money +- **Cost structure** — what it costs to run the business +- **Key resources and activities** — what you need and what you do + +### Legal Requirements in Zambia + +To operate legally, you may need to: +- Register your business with PACRA (Patents and Companies Registration Agency) +- Obtain a tax identification number from ZRA (Zambia Revenue Authority) +- Get relevant licences and permits for your industry +- Comply with local council regulations +- Register for NAPSA (National Pension Scheme Authority) if you have employees + +## Managing an Enterprise + +### Resource Management + +Effective management means making the best use of your limited resources: +- **Human resources** — hiring the right people, defining roles clearly, and treating employees fairly +- **Financial resources** — tracking income and expenses carefully +- **Physical resources** — equipment, inventory, and workspace +- **Time** — prioritizing tasks and avoiding distractions + +### Financial Management Basics + +Every business owner needs to understand: +- **Record-keeping** — keep accurate records of all transactions (sales, purchases, expenses) +- **Budgeting** — plan your spending in advance and stick to it +- **Cash flow** — make sure money coming in covers money going out +- **Separating business and personal finances** — do not mix them + +### Budgeting and Financial Planning + +A simple budget includes: +1. **Expected income** — realistic estimates of what you will earn +2. **Fixed costs** — rent, salaries, loan repayments (costs that stay the same) +3. **Variable costs** — materials, transport, utilities (costs that change) +4. **Savings and reinvestment** — money set aside for growth and emergencies + +### Ethics and Anti-Corruption + +Running an ethical business builds trust and long-term success: +- Be honest with customers, suppliers, and employees +- Avoid paying or accepting bribes +- Report corruption when you encounter it +- Follow fair pricing and fair labour practices +- Pay your taxes + +### Building Business Networks + +No business succeeds in isolation. Build relationships with: +- Other entrepreneurs — share knowledge and support each other +- Mentors — learn from those with more experience +- Suppliers — negotiate fair terms and reliable delivery +- Customers — listen to their feedback and build loyalty +- Industry associations and cooperatives — access resources and opportunities + +## Designing a Product and Production Process + +### Understanding the Value Chain + +The value chain covers all the steps from raw materials to the final customer: +1. **Sourcing** — where you get your materials or inputs +2. **Production** — how you make or prepare your product or service +3. **Distribution** — how you get it to the customer +4. **After-sales** — support and follow-up with customers + +Understanding each step helps you find ways to reduce costs and improve quality. + +### Identifying Customer Needs + +Before designing your product: +- Talk to potential customers about their needs and preferences +- Observe how they currently solve the problem your product addresses +- Test your ideas with a small group before investing heavily +- Be willing to adapt based on feedback + +### Prototyping and Testing + +A prototype is an early version of your product. It does not need to be perfect: +- Start with a simple version that demonstrates the core idea +- Get feedback from real users +- Improve based on what you learn +- Repeat the process until the product meets customer needs + +### Product Lifecycle + +Products go through stages: +1. **Introduction** — launching the product, building awareness +2. **Growth** — increasing sales, expanding your customer base +3. **Maturity** — sales stabilize, competition increases +4. **Decline** — demand drops, time to innovate or pivot + +Plan for each stage so you are not caught off guard. + +### Costing and Pricing + +To set the right price: +- **Calculate your costs** — materials, labour, overhead, transport +- **Add your profit margin** — the amount you need to earn above costs +- **Research the market** — what are competitors charging? +- **Consider your customers** — what are they willing and able to pay? + +Common pricing mistakes to avoid: +- Pricing too low to attract customers (you may not cover costs) +- Ignoring hidden costs like transport, packaging, or your own time +- Not adjusting prices as costs change + +## Business Pitching + +### What is a Business Pitch? + +A business pitch is a short, persuasive presentation that explains your business idea to potential investors, partners, or customers. A good pitch tells a compelling story about the problem you solve and why your solution works. + +### Building a Pitch Deck + +A pitch deck typically includes: +1. **The problem** — what issue are you addressing? +2. **Your solution** — how does your product or service solve it? +3. **Target market** — who are your customers and how big is the opportunity? +4. **Business model** — how will you make money? +5. **Traction** — what progress have you made so far? +6. **The team** — who is involved and what makes you capable? +7. **The ask** — what do you need (funding, partnership, support)? + +### Market Segmentation and Customer Discovery + +Understand your market by breaking it into segments: +- **Demographics** — age, gender, income level, location +- **Needs** — what specific problem does each group face? +- **Behaviour** — how do they currently spend their money? + +Customer discovery means going out and talking to real people to validate your assumptions. Do not assume you know what customers want — ask them. + +### Financial Projections for Your Pitch + +Investors want to see realistic numbers: +- Projected revenue for the first 1-3 years +- Expected costs and when you expect to break even +- How much funding you need and how you will use it +- Your plan for becoming profitable + +### Tips for a Successful Pitch + +- Keep it short and focused — aim for 5-10 minutes +- Tell a story — make it memorable and relatable +- Know your numbers — be prepared to answer financial questions +- Practice — rehearse until you are confident and natural +- Be honest — do not exaggerate or make promises you cannot keep +- Show passion — investors back people as much as ideas + +## Quiz +pass_threshold: 0.7 + +1. Which of the following best describes an entrepreneurial mindset? +A. Avoiding all risk to stay safe +B. Seeing problems as opportunities and taking initiative to create value +C. Waiting for someone to give you a business idea +D. Only working for established companies +Answer: B + +2. What is the first thing you should do when evaluating a business idea? +A. Immediately register the business +B. Determine whether there is real demand for your product or service +C. Design a logo and brand name +D. Hire employees +Answer: B + +3. What does PACRA stand for in Zambia? +A. People's Association for Community Resource Allocation +B. Patents and Companies Registration Agency +C. Provincial Agency for Corporate Regulatory Affairs +D. Public Authority for Commerce and Revenue Administration +Answer: B + +4. Why is it important to separate business and personal finances? +A. It is not important — mixing them saves time +B. It helps you accurately track business performance and manage cash flow +C. Banks require it by law for all businesses +D. It makes your business look bigger than it is +Answer: B + +5. What is a value chain? +A. A chain of shops in a shopping centre +B. The steps from sourcing materials to delivering the final product to the customer +C. A list of your most valuable customers +D. The price history of your product +Answer: B + +6. What is the main purpose of creating a prototype? +A. To create a perfect final product immediately +B. To test your core idea, get feedback, and improve before investing heavily +C. To impress investors with a finished product +D. To avoid talking to customers +Answer: B + +7. Which of the following is a common pricing mistake? +A. Researching what competitors charge +B. Setting prices too low and failing to cover your costs +C. Calculating all your costs before setting a price +D. Considering what customers are willing to pay +Answer: B + +8. What is the purpose of a business pitch? +A. To entertain an audience with a long presentation +B. To persuasively explain your business idea to investors, partners, or customers +C. To show off your technical skills +D. To avoid having to write a business plan +Answer: B + +9. What does customer discovery involve? +A. Guessing what customers want based on your own preferences +B. Going out and talking to real people to validate your business assumptions +C. Reading only online reviews of competitor products +D. Hiring a marketing agency to decide for you +Answer: B + +10. Which of the following is good advice for managing a business ethically? +A. Pay bribes to speed up permits and licences +B. Be honest with customers, avoid corruption, and follow fair labour practices +C. Hide some income to reduce your tax bill +D. Charge different prices based on how wealthy a customer looks +Answer: B diff --git a/backend/app/career_readiness/modules/interview_preparation.md b/backend/app/career_readiness/modules/interview_preparation.md new file mode 100644 index 00000000..bb518adf --- /dev/null +++ b/backend/app/career_readiness/modules/interview_preparation.md @@ -0,0 +1,161 @@ +--- +id: interview-preparation +title: Interview Preparation +description: Practice common interview questions, learn the STAR method, and build confidence for job interviews. +icon: interview +sort_order: 4 +input_placeholder: Ask about interviews... +topics: Types of Interviews, The STAR Method, Common Interview Questions, Preparation Checklist, During the Interview +--- + +# Interview Preparation + +## Overview + +This module prepares you for job interviews by teaching proven techniques, common question types, and strategies to present yourself effectively. + +## Types of Interviews + +### Structured Interviews +- Predetermined questions asked to all candidates +- Easy to prepare for with practice + +### Behavioral Interviews +- Focus on past behavior as a predictor of future performance +- Questions typically start with "Tell me about a time when..." + +### Situational Interviews +- Hypothetical scenarios to assess problem-solving +- Questions like "What would you do if..." + +### Panel Interviews +- Multiple interviewers at once +- Address all panel members, not just the one asking + +## The STAR Method + +Use the STAR method to structure your answers to behavioral questions: + +- **S — Situation**: Describe the context or background +- **T — Task**: Explain what you needed to accomplish +- **A — Action**: Detail the specific steps you took +- **R — Result**: Share the outcome and what you learned + +### Example + +**Question**: "Tell me about a time you solved a difficult problem." + +**Answer using STAR**: +- **Situation**: "In my previous role at a retail store, we experienced a sudden 30% drop in customer satisfaction scores." +- **Task**: "I was asked to identify the root cause and propose solutions." +- **Action**: "I analyzed customer feedback, identified long wait times as the main issue, and proposed a new queue management system." +- **Result**: "Within two months, satisfaction scores improved by 25%, and the system was adopted across all branches." + +## Common Interview Questions + +### About You +- Tell me about yourself +- What are your strengths and weaknesses? +- Where do you see yourself in five years? + +### About the Role +- Why do you want this job? +- What do you know about our company? +- How does your experience prepare you for this role? + +### Behavioral +- Describe a time you worked in a team +- Tell me about a challenge you overcame +- Give an example of when you showed leadership + +## Preparation Checklist + +1. Research the company and role thoroughly +2. Prepare answers for common questions using the STAR method +3. Prepare thoughtful questions to ask the interviewer +4. Practice with a friend or in front of a mirror +5. Plan your outfit and route to the interview location +6. Bring copies of your CV and any required documents +7. Arrive 10-15 minutes early + +## During the Interview + +- Make eye contact and offer a firm handshake +- Listen carefully before responding +- Take a moment to think before answering difficult questions +- Be honest — do not exaggerate or fabricate +- Show enthusiasm for the role and organization +- Thank the interviewer at the end + +## Quiz +pass_threshold: 0.7 + +1. What does STAR stand for in the interview context? +A. Skills, Training, Aptitude, Results +B. Situation, Task, Action, Result +C. Summary, Technique, Assessment, Review +D. Strengths, Talents, Abilities, Resources +Answer: B + +2. Which type of interview uses questions like "Tell me about a time when..."? +A. Structured interview +B. Panel interview +C. Behavioral interview +D. Situational interview +Answer: C + +3. What is a key characteristic of a structured interview? +A. Only one question is asked +B. Predetermined questions are asked to all candidates +C. The interviewer follows no plan +D. It happens over email +Answer: B + +4. How early should you arrive for an interview? +A. Exactly on time +B. 30 minutes early +C. 10-15 minutes early +D. Timing does not matter +Answer: C + +5. When answering a behavioral question, what should you include in the "Result" part? +A. Only what went wrong +B. The outcome and what you learned +C. Your salary expectations +D. A list of your skills +Answer: B + +6. What should you bring to an interview? +A. Nothing — the employer has everything +B. Copies of your CV and any required documents +C. Gifts for the interviewer +D. Your personal diary +Answer: B + +7. In a panel interview, who should you address? +A. Only the most senior-looking person +B. Only the person who asked the question +C. All panel members +D. No one in particular +Answer: C + +8. Which of these is a good practice during an interview? +A. Exaggerate your experience to impress +B. Take a moment to think before answering difficult questions +C. Avoid making eye contact +D. Interrupt the interviewer to show enthusiasm +Answer: B + +9. What is the purpose of preparing questions to ask the interviewer? +A. To waste time +B. To show interest in the role and gather useful information +C. To challenge the interviewer +D. It is not necessary to prepare questions +Answer: B + +10. What type of interview presents hypothetical scenarios? +A. Behavioral interview +B. Structured interview +C. Situational interview +D. Panel interview +Answer: C diff --git a/backend/app/career_readiness/modules/professional_identity.md b/backend/app/career_readiness/modules/professional_identity.md new file mode 100644 index 00000000..4f8c24a9 --- /dev/null +++ b/backend/app/career_readiness/modules/professional_identity.md @@ -0,0 +1,135 @@ +--- +id: professional-identity +title: Professional Identity & Skills Mapping +description: Understand your professional identity and categorize your skills into technical, knowledge-based, and transferable types. +icon: identity +sort_order: 1 +input_placeholder: Ask about skills and professional identity... +topics: What is Professional Identity, Types of Skills, How to Identify Your Skills, Articulating Your Professional Identity +--- + +# Professional Identity & Skills Mapping + +## Overview + +This module helps you understand and articulate your professional identity. You will learn to identify, categorize, and communicate your skills effectively. + +## What is Professional Identity? + +Your professional identity is how you see yourself in the context of work. It encompasses your values, skills, experiences, and aspirations. A clear professional identity helps you: + +- Communicate your value to employers +- Make informed career decisions +- Build confidence in professional settings + +## Types of Skills + +### Technical Skills +These are specific, teachable abilities that can be measured. Examples include: +- Computer programming +- Data analysis +- Machine operation +- Accounting + +### Knowledge-Based Skills +These come from education and experience in specific domains: +- Industry regulations +- Subject matter expertise +- Research methodologies +- Language proficiency + +### Transferable Skills +These are portable skills that apply across many jobs and industries: +- Communication +- Problem-solving +- Leadership +- Time management +- Teamwork + +## How to Identify Your Skills + +1. **Review your experiences**: Think about jobs, volunteer work, education, and personal projects. +2. **Ask others**: Colleagues, mentors, and friends can help identify strengths you might overlook. +3. **Use action verbs**: Describe what you did using verbs like managed, created, organized, analyzed. +4. **Consider results**: What outcomes did your skills produce? Quantify where possible. + +## Articulating Your Professional Identity + +When describing yourself professionally: +- Lead with your strongest, most relevant skills +- Use specific examples to illustrate your abilities +- Tailor your identity to the context (job application, networking, etc.) +- Be authentic and honest about your experience level + +## Quiz +pass_threshold: 0.7 + +1. What best describes your professional identity? +A. Your job title alone +B. Your unique combination of values, skills, experiences, and aspirations +C. The company you work for +D. Your salary and benefits +Answer: B + +2. Which of the following is a technical skill? +A. Teamwork +B. Leadership +C. Data analysis +D. Time management +Answer: C + +3. Which type of skill is "communication"? +A. Technical skill +B. Knowledge-based skill +C. Transferable skill +D. Digital skill +Answer: C + +4. What is the first step to identifying your skills? +A. Ask your employer to list them +B. Review your experiences including jobs, volunteer work, and education +C. Copy skills from a job posting +D. Take an online personality test +Answer: B + +5. When articulating your professional identity, you should: +A. Use vague descriptions to keep options open +B. Lead with your strongest, most relevant skills +C. List every skill you have ever learned +D. Avoid mentioning specific examples +Answer: B + +6. Which of these is a knowledge-based skill? +A. Problem-solving +B. Industry regulations expertise +C. Communication +D. Adaptability +Answer: B + +7. Why is it important to use action verbs when describing your skills? +A. They make your CV longer +B. They clearly describe what you did and can do +C. Employers only read verbs +D. They are required by law +Answer: B + +8. Which approach helps others identify strengths you might overlook? +A. Reading job descriptions +B. Asking colleagues, mentors, and friends for feedback +C. Watching tutorial videos +D. Taking standardized tests +Answer: B + +9. When tailoring your professional identity, you should: +A. Use the same description for every situation +B. Adjust your identity to the context, such as job applications or networking +C. Avoid mentioning your experience level +D. Only describe technical skills +Answer: B + +10. What should you consider when identifying skills from your experiences? +A. Only paid work experience counts +B. The outcomes and results your skills produced +C. Only skills learned in formal education +D. Skills that are trending on social media +Answer: B diff --git a/backend/app/career_readiness/modules/workplace_readiness.md b/backend/app/career_readiness/modules/workplace_readiness.md new file mode 100644 index 00000000..57f5d973 --- /dev/null +++ b/backend/app/career_readiness/modules/workplace_readiness.md @@ -0,0 +1,163 @@ +--- +id: workplace-readiness +title: Workplace Readiness Skills +description: Develop essential workplace skills including communication, problem-solving, teamwork, and professional etiquette. +icon: workplace +sort_order: 5 +input_placeholder: Ask about workplace skills... +topics: Communication Skills, Problem-Solving, Teamwork, Time Management, Professional Etiquette, Adaptability and Resilience +--- + +# Workplace Readiness Skills + +## Overview + +This module helps you develop the essential soft skills and professional behaviors that employers value in the workplace. + +## Communication Skills + +### Verbal Communication +- Speak clearly and concisely +- Adjust your language to your audience +- Practice active listening — let others finish before responding +- Ask clarifying questions when you do not understand + +### Written Communication +- Use professional language in emails and messages +- Be clear about the purpose of your communication +- Proofread before sending +- Respond to messages in a timely manner + +### Non-Verbal Communication +- Maintain appropriate eye contact +- Be aware of your body language +- Dress appropriately for your workplace + +## Problem-Solving + +### A Structured Approach +1. **Identify the problem** — clearly define what is wrong +2. **Gather information** — collect relevant facts and data +3. **Generate options** — brainstorm possible solutions +4. **Evaluate options** — consider pros and cons of each +5. **Choose and implement** — select the best option and act +6. **Review the outcome** — assess whether the problem is resolved + +### Tips +- Do not panic when problems arise — stay calm and analytical +- Ask for help when you need it +- Learn from mistakes — they are opportunities for growth + +## Teamwork + +### Working Effectively with Others +- Respect different perspectives and working styles +- Communicate openly and honestly +- Share credit for team achievements +- Take responsibility for your part of the work +- Be reliable — meet deadlines and keep commitments + +### Handling Conflict +- Address issues early before they escalate +- Focus on the problem, not the person +- Listen to understand, not to respond +- Seek compromise or a mutually acceptable solution +- Involve a supervisor if needed + +## Time Management + +- Prioritize tasks by importance and deadline +- Break large tasks into smaller, manageable steps +- Use calendars and to-do lists +- Avoid multitasking — focus on one task at a time +- Learn to say no to non-essential tasks when overloaded + +## Professional Etiquette + +- Be punctual — arrive on time or early +- Respect workplace rules and policies +- Maintain a positive attitude +- Accept feedback gracefully and use it to improve +- Keep personal matters separate from work +- Show initiative — do not wait to be told what to do + +## Adaptability and Resilience + +- Be open to change and new ways of working +- Stay positive during difficult times +- Seek learning opportunities in every situation +- Build a support network of colleagues and mentors +- Take care of your physical and mental well-being + +## Quiz +pass_threshold: 0.7 + +1. Which is an example of good verbal communication in the workplace? +A. Speaking as quickly as possible +B. Adjusting your language to your audience and practicing active listening +C. Using jargon that only you understand +D. Interrupting colleagues to share your ideas +Answer: B + +2. What is the first step in a structured problem-solving approach? +A. Generate options +B. Choose and implement a solution +C. Clearly identify the problem +D. Review the outcome +Answer: C + +3. When handling conflict in a team, you should: +A. Ignore the issue and hope it goes away +B. Focus on the problem, not the person +C. Blame the other person publicly +D. Avoid communicating about it +Answer: B + +4. Which time management strategy is recommended? +A. Multitask as much as possible +B. Prioritize tasks by importance and deadline +C. Work on whatever feels easiest first +D. Skip using calendars or to-do lists +Answer: B + +5. What is an important aspect of professional etiquette? +A. Arriving late to show you are busy +B. Being punctual and respecting workplace rules +C. Keeping personal matters visible at work +D. Waiting to be told what to do +Answer: B + +6. What does adaptability in the workplace mean? +A. Never changing your routine +B. Being open to change and new ways of working +C. Resisting new ideas from colleagues +D. Only doing tasks you are comfortable with +Answer: B + +7. Which of these is an example of good teamwork? +A. Taking all the credit for team achievements +B. Sharing credit and taking responsibility for your part of the work +C. Working alone to avoid disagreements +D. Missing deadlines because you were busy with personal tasks +Answer: B + +8. What should you do when you receive feedback at work? +A. Argue with the person giving feedback +B. Accept it gracefully and use it to improve +C. Ignore it completely +D. Complain to other colleagues +Answer: B + +9. Why is written communication important in the workplace? +A. It is not important — verbal is enough +B. Professional language in emails ensures clear and effective communication +C. It allows you to use informal language +D. It replaces the need for meetings +Answer: B + +10. What is the best response when problems arise at work? +A. Panic and escalate immediately +B. Stay calm, gather information, and follow a structured approach +C. Ignore the problem until someone else notices +D. Blame a colleague +Answer: B diff --git a/backend/app/career_readiness/pedagogical-approach.md b/backend/app/career_readiness/pedagogical-approach.md new file mode 100644 index 00000000..d7f94501 --- /dev/null +++ b/backend/app/career_readiness/pedagogical-approach.md @@ -0,0 +1,180 @@ +# Career Readiness Modules — Pedagogical Approach + +This document describes the evidence-based pedagogical approach used in the AI-led career readiness tutoring modules. It is intended for review by TEVET, the World Bank, and other stakeholders. + +## Executive Summary + +The career readiness modules use **scaffolded Socratic tutoring** — an approach where the AI agent guides students to build understanding through questions and hints rather than giving answers directly. Research shows this is critical: a Wharton/UPenn study (Bastani et al., 2024, published in PNAS) found that unrestricted AI access **actively harmed** student learning, while a Socratic-guardrailed version significantly improved it. A Harvard study (Kestin et al., 2025, published in Nature) confirmed that scaffolded AI tutoring produced learning gains more than double those of traditional classrooms. + +In practice, the agent: (1) assesses what the student already knows, (2) asks guiding questions, (3) provides hints when the student struggles, and (4) gives direct explanations only as a last resort. Every conversation turn doubles as a comprehension check — the agent asks students to explain concepts back, apply them to their situation, and predict outcomes. The quiz only becomes available after all lesson plan topics have been covered and the student has demonstrated understanding through these checks. + +After passing the quiz, the module stays open in support mode so students can return for follow-up questions — reinforcing retention through spaced practice. + +This approach is aligned with World Bank, UNESCO-UNEVOC, and OECD recommendations for AI integration in technical and vocational education (TVET), and builds on 40 years of research showing that one-to-one tutoring with mastery learning produces the largest known effect on student achievement (Bloom, 1984). + +## Overview + +Each career readiness module is powered by an AI conversational agent that acts as a personal tutor. The agent guides students through structured lesson plan content using techniques grounded in published research on intelligent tutoring systems, AI-led pedagogy, and vocational education. + +The approach is designed around three core principles: + +1. **Scaffolded Socratic tutoring** — guide students to construct understanding rather than passively receive information +2. **Continuous formative assessment** — treat every dialogue turn as an opportunity to check comprehension +3. **Mastery-based progression** — students must demonstrate topic coverage before advancing to the quiz + +--- + +## 1. Teaching Method: Scaffolded Socratic Tutoring + +The agent uses a graduated assistance model that combines Socratic questioning with adaptive scaffolding: + +1. **Assess** — begin each topic by probing what the student already knows +2. **Guide** — ask leading questions that help the student reason through the material +3. **Hint** — if the student struggles, provide partial information or worked examples +4. **Explain** — give direct explanations only as a last resort +5. **Fade** — as the student demonstrates understanding, reduce support and encourage independent reasoning + +This approach avoids two failure modes identified in research: unguarded AI access (which harms learning by giving answers too easily) and naive Socratic questioning without scaffolding (which frustrates students without producing measurable learning gains). + +### Supporting Evidence + +- **Bastani, H., Bastani, O., Sungu, A., Ge, H., Kabakci, O., & Mariman, R. (2024).** "Generative AI Can Harm Learning." *Proceedings of the National Academy of Sciences (PNAS).* Wharton School, University of Pennsylvania. — In a randomized controlled trial with ~1,000 high school students, unrestricted ChatGPT access improved practice scores by 48% but **reduced subsequent test scores by 17%** when access was removed. A pedagogically designed "GPT Tutor" with Socratic guardrails (refusing to give direct answers, requiring students to show work) improved practice by 127% and mitigated the negative transfer effects. This is the strongest causal evidence that unguided AI access harms learning, while scaffolded Socratic design protects it. + - Paper: https://www.pnas.org/doi/10.1073/pnas.2422633122 + - Pre-print: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4895486 + +- **Kestin, G., Miller, K., et al. (2025).** "AI Tutoring Outperforms Active Learning." *Scientific Reports* (Nature). Harvard University, Department of Physics. — In a randomized controlled trial, an AI tutor designed with scaffolding and Socratic questioning produced learning gains **more than double** those of active learning classrooms (median post-test 4.5 vs. 3.5). Students also reported higher engagement and motivation. + - Paper: https://www.nature.com/articles/s41598-025-97652-6 + +- **Blasco, A. & Charisi, V. (2024).** "AI Chatbots in K-12 Education: Socratic vs. Non-Socratic Approaches." Harvard University / European Commission Joint Research Centre. — RCT with 122 students (ages 14-18). A Socratic GPT-4 tutor produced **no measurable learning gains** compared to a direct-help tutor. Demonstrates that naive Socratic questioning alone is insufficient — it must be combined with proper scaffolding and adaptive difficulty. + - Paper: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5040921 + +- **Beale, R. (2025).** "Dialogic Pedagogy for Large Language Models." University of Birmingham, UK. — Synthesizes Vygotsky's Zone of Proximal Development, Socratic method, and Laurillard's Conversational Framework into practical LLM design recommendations. Recommends graduated assistance, tiered Socratic prompting, and fading scaffolds. + - Paper: https://arxiv.org/html/2506.19484v1 + +- **Burns, M. (2026).** "What the Research Shows About Generative AI in Tutoring." Brookings Institution. — Review of 400+ articles. Concludes the most effective AI tutoring platforms "teach, not tell" with Socratic approaches, managing cognitive load through sequential, scaffolded content. + - Article: https://www.brookings.edu/articles/what-the-research-shows-about-generative-ai-in-tutoring/ + +--- + +## 2. Checking Understanding: Formative Assessment Through Dialogue + +The agent embeds comprehension checks throughout the conversation rather than relying solely on the end-of-module quiz. Techniques include: + +- **Explain-back prompts** — asking the student to summarize a concept in their own words +- **Application questions** — asking the student to apply a concept to their own situation (e.g., "What transferable skills do you think you have from your training?") +- **Prediction prompts** — asking the student to predict an outcome before the agent reveals the answer +- **Retrieval practice** — periodically returning to earlier topics to reinforce retention + +Each dialogue turn is treated as a formative assessment opportunity — the agent evaluates whether the student's responses indicate understanding before advancing. + +### Supporting Evidence + +- **Scarlatos, A., Baker, R.S., & Lan, A. (2024).** "Exploring Knowledge Tracing in Tutor-Student Dialogues." University of Massachusetts Amherst / University of Pennsylvania. Published at LAK '25. — Introduces a framework for estimating student knowledge from conversational turns. GPT-4o achieved 0.93/1.0 accuracy in evaluating student response correctness from dialogue. Demonstrates that every dialogue turn can serve as formative assessment. + - Paper: https://arxiv.org/html/2409.16490v2 + +- **Bloom, B.S. (1984).** "The 2 Sigma Problem: The Search for Methods of Group Instruction as Effective as One-to-One Tutoring." *Educational Researcher*, 13(6), 4-16. University of Chicago. — The foundational study establishing that one-to-one tutoring with mastery learning produces a 2-standard-deviation improvement in student achievement. Key to the approach: students must demonstrate mastery (80-90% criterion) on formative assessments before proceeding. + - Paper: https://journals.sagepub.com/doi/10.3102/0013189X013006004 + +- **Vanacore, K., Baker, R.S., Closser, A.H., & Roschelle, J. (2025).** "The Path to Conversational AI Tutors." Cornell University / University of Adelaide / University of Florida / Digital Promise. — Recommends conditioning LLM responses on knowledge tracing outputs and distinguishing between slips (student knows but erred — provide encouragement), non-mastery (use diagnostic questioning), and multiple misconceptions (provide worked examples). + - Paper: https://arxiv.org/html/2602.19303v1 + +--- + +## 3. Quiz Readiness: Topic Coverage as Mastery Proxy + +The quiz becomes available only after the agent determines that all lesson plan topics have been sufficiently covered. + +### How It Works + +Each module's markdown file contains clearly delineated topic sections. The agent uses these as a checklist: + +1. The agent covers each topic through guided conversation +2. For each topic, the agent embeds comprehension checks (as described above) +3. Once all topics have been addressed and the student has responded satisfactorily, the agent transitions to quiz delivery + +This is a simplified mastery model appropriate for the pilot phase. Full Bayesian Knowledge Tracing (as used in research-grade ITS) is out of scope but could be added in future iterations. + +### Supporting Evidence + +- **VanLehn, K. (2011).** "The Relative Effectiveness of Human Tutoring, Intelligent Tutoring Systems, and Other Tutoring Systems." *Educational Psychologist*, 46(4), 197-221. Arizona State University. — Meta-analysis of ~50 studies. Step-based ITS that track mastery at the knowledge-component level produce effect sizes of d=0.76, nearly matching human tutoring (d=0.79). The system advances students to assessment when knowledge tracing indicates mastery across requisite knowledge components. + - Paper: https://www.tandfonline.com/doi/abs/10.1080/00461520.2011.611369 + +--- + +## 4. Quiz Delivery: Conversational Format, Deterministic Evaluation + +The quiz is delivered within the chat conversation (not as a separate UI component) to maintain engagement and continuity. However, questions are static (predefined per module) and evaluation is deterministic (handled by the service layer, not the LLM) to ensure reliability. + +- 10 multiple-choice questions per module +- 70% pass threshold (placeholder — to be confirmed by TEVET) +- On pass: module marked completed, next module unlocked, module remains accessible in support mode +- On fail: module stays in instruction mode, student can retry + +### Supporting Evidence + +- **Ruan, S., Jiang, L., Xu, J., et al. (2019).** "QuizBot: A Dialogue-based Adaptive Learning System for Factual Knowledge." *Proceedings of CHI '19*, ACM. Stanford University. — Users of a conversational quiz format recalled **20% more correct answers** than flashcard users and spent **2.6x more time** learning. Students strongly preferred the conversational format for engagement. + - Paper: https://dl.acm.org/doi/fullHtml/10.1145/3290605.3300587 + +--- + +## 5. Conversation Modes + +### Instruction Mode (Default) + +Active during the teaching phase. The agent: +- Follows the lesson plan structure +- Uses scaffolded Socratic techniques +- Tracks topic coverage +- Transitions to quiz when all topics are covered + +### Support Mode (After Quiz Completion) + +After a student passes the quiz, the module remains accessible. The agent: +- Answers follow-up questions about the module's topics +- References the module content as grounding +- Does not re-initiate the lesson plan or re-deliver the quiz + +This design ensures students can revisit material for reinforcement, consistent with spaced retrieval practice principles. + +--- + +## 6. TVET-Specific Context + +The approach is informed by development-sector research on AI in technical and vocational education: + +- **UNESCO-UNEVOC (2021).** "Understanding the Impact of Artificial Intelligence on Skills Development." — AI-powered tutoring can enhance TVET with individualized, on-demand support and real-time feedback. Notes that only 34% of TVET institutions globally have the bandwidth for advanced AI tools, and 78% of teachers lack confidence in using them. + - Report: https://unevoc.unesco.org/pub/understanding_the_impact_of_ai_on_skills_development.pdf + +- **World Bank (2024).** "Building Better Formal TVET Systems: Principles and Practice in Low- and Middle-Income Countries." Joint publication with ILO and UNESCO. — Addresses the need for TVET systems to adapt to technological progress, including AI integration for skills development. + - Report: https://www.worldbank.org/en/topic/skillsdevelopment/publication/better-technical-vocational-education-training-TVET + +- **World Bank (2024).** "AI Revolution in Education: What You Need to Know." Digital Innovations in Education Series (Latin America & Caribbean). — Documents AI-powered tutors as a key innovation for personalized learning in developing country education systems. + - Report: https://documents.worldbank.org/en/publication/documents-reports/documentdetail/099734306182493324 + +- **OECD (2025).** "How Can Innovative Technologies Transform Vocational Education and Training." — Documents how AI and adaptive learning platforms can engage VET learners with personalized support and align training outcomes with labor market needs. + - Report: https://www.oecd.org/en/publications/2025/05/how-can-innovative-technologies-transform-vocational-education-and-training_5b10f8ac.html + +- **UNESCO-UNEVOC (2025).** "European Insights: AI Integration in TVET — Policies, Practices and Pathways for Inclusive Innovation." — Documents AI integration practices across European TVET systems with policy pathways for inclusive innovation. + - Report: https://atlas.unevoc.unesco.org/research-briefs/european-insights-ai-integration-in-tvet-policies-practices-and-pathways-for-inclusive-innovation + +--- + +## Full References + +| # | Authors | Year | Title | Institution | Link | +|---|---------|------|-------|-------------|------| +| 1 | Bastani, H. et al. | 2024 | Generative AI Can Harm Learning | Wharton / UPenn (PNAS) | [Link](https://www.pnas.org/doi/10.1073/pnas.2422633122) | +| 2 | Kestin, G. et al. | 2025 | AI Tutoring Outperforms Active Learning | Harvard (Nature Scientific Reports) | [Link](https://www.nature.com/articles/s41598-025-97652-6) | +| 3 | Blasco, A. & Charisi, V. | 2024 | AI Chatbots in K-12 Education: Socratic vs Non-Socratic | Harvard / EU JRC | [Link](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5040921) | +| 4 | Beale, R. | 2025 | Dialogic Pedagogy for LLMs | University of Birmingham | [Link](https://arxiv.org/html/2506.19484v1) | +| 5 | Burns, M. | 2026 | What the Research Shows About GenAI in Tutoring | Brookings Institution | [Link](https://www.brookings.edu/articles/what-the-research-shows-about-generative-ai-in-tutoring/) | +| 6 | Scarlatos, A. et al. | 2024 | Knowledge Tracing in Tutor-Student Dialogues | UMass / UPenn (LAK '25) | [Link](https://arxiv.org/html/2409.16490v2) | +| 7 | Bloom, B.S. | 1984 | The 2 Sigma Problem | University of Chicago | [Link](https://journals.sagepub.com/doi/10.3102/0013189X013006004) | +| 8 | VanLehn, K. | 2011 | Relative Effectiveness of ITS | Arizona State University | [Link](https://www.tandfonline.com/doi/abs/10.1080/00461520.2011.611369) | +| 9 | Vanacore, K. et al. | 2025 | The Path to Conversational AI Tutors | Cornell / U. Adelaide / Digital Promise | [Link](https://arxiv.org/html/2602.19303v1) | +| 10 | Ruan, S. et al. | 2019 | QuizBot: Dialogue-based Adaptive Learning | Stanford (CHI '19) | [Link](https://dl.acm.org/doi/fullHtml/10.1145/3290605.3300587) | +| 11 | UNESCO-UNEVOC | 2021 | Impact of AI on Skills Development | UNESCO | [Link](https://unevoc.unesco.org/pub/understanding_the_impact_of_ai_on_skills_development.pdf) | +| 12 | World Bank / ILO / UNESCO | 2024 | Building Better Formal TVET Systems | World Bank | [Link](https://www.worldbank.org/en/topic/skillsdevelopment/publication/better-technical-vocational-education-training-TVET) | +| 13 | World Bank | 2024 | AI Revolution in Education | World Bank | [Link](https://documents.worldbank.org/en/publication/documents-reports/documentdetail/099734306182493324) | +| 14 | OECD | 2025 | Innovative Technologies in VET | OECD | [Link](https://www.oecd.org/en/publications/2025/05/how-can-innovative-technologies-transform-vocational-education-and-training_5b10f8ac.html) | +| 15 | UNESCO-UNEVOC | 2025 | AI Integration in TVET: European Insights | UNESCO | [Link](https://atlas.unevoc.unesco.org/research-briefs/european-insights-ai-integration-in-tvet-policies-practices-and-pathways-for-inclusive-innovation) | diff --git a/backend/app/career_readiness/repository.py b/backend/app/career_readiness/repository.py new file mode 100644 index 00000000..02deb504 --- /dev/null +++ b/backend/app/career_readiness/repository.py @@ -0,0 +1,152 @@ +""" +Repository for career readiness conversation data in MongoDB. +""" +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo.errors import DuplicateKeyError + +from app.career_readiness.errors import ConversationAlreadyExistsError +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessMessage, + ConversationMode, +) +from app.server_dependencies.database_collections import Collections + + +class ICareerReadinessConversationRepository(ABC): + """Interface for the career readiness conversation repository.""" + + @abstractmethod + async def create(self, document: CareerReadinessConversationDocument) -> None: + """Insert a new conversation document.""" + raise NotImplementedError() + + @abstractmethod + async def find_by_conversation_id(self, conversation_id: str) -> CareerReadinessConversationDocument | None: + """Find a conversation by its ID.""" + raise NotImplementedError() + + @abstractmethod + async def find_by_user_and_module(self, user_id: str, module_id: str) -> CareerReadinessConversationDocument | None: + """Find a conversation for a specific user and module.""" + raise NotImplementedError() + + @abstractmethod + async def find_all_by_user(self, user_id: str) -> list[CareerReadinessConversationDocument]: + """Find all conversations for a specific user.""" + raise NotImplementedError() + + @abstractmethod + async def append_message(self, conversation_id: str, message: CareerReadinessMessage) -> None: + """Append a message to a conversation and update the updated_at timestamp.""" + raise NotImplementedError() + + @abstractmethod + async def update_covered_topics(self, conversation_id: str, topics: list[str]) -> None: + """Update the list of covered topics for a conversation.""" + raise NotImplementedError() + + @abstractmethod + async def update_quiz_delivered(self, conversation_id: str, delivered: bool) -> None: + """Update whether the quiz has been delivered to the user.""" + raise NotImplementedError() + + @abstractmethod + async def update_quiz_passed(self, conversation_id: str, passed: bool) -> None: + """Update whether the user passed the quiz.""" + raise NotImplementedError() + + @abstractmethod + async def update_conversation_mode(self, conversation_id: str, mode: ConversationMode) -> None: + """Update the conversation mode (INSTRUCTION or SUPPORT).""" + raise NotImplementedError() + + @abstractmethod + async def delete_by_conversation_id(self, conversation_id: str) -> bool: + """Delete a conversation. Returns True if deleted, False if not found.""" + raise NotImplementedError() + + +class CareerReadinessConversationRepository(ICareerReadinessConversationRepository): + """MongoDB implementation of the career readiness conversation repository.""" + + def __init__(self, db: AsyncIOMotorDatabase): + self._collection = db.get_collection(Collections.CAREER_READINESS_CONVERSATIONS) + self._logger = logging.getLogger(CareerReadinessConversationRepository.__name__) + + async def create(self, document: CareerReadinessConversationDocument) -> None: + try: + await self._collection.insert_one(document.model_dump()) + except DuplicateKeyError as e: + raise ConversationAlreadyExistsError(document.module_id, document.user_id) from e + + async def find_by_conversation_id(self, conversation_id: str) -> CareerReadinessConversationDocument | None: + result = await self._collection.find_one( + {"conversation_id": {"$eq": conversation_id}} + ) + if result is None: + return None + return CareerReadinessConversationDocument.from_dict(result) + + async def find_by_user_and_module(self, user_id: str, module_id: str) -> CareerReadinessConversationDocument | None: + result = await self._collection.find_one( + {"user_id": {"$eq": user_id}, "module_id": {"$eq": module_id}} + ) + if result is None: + return None + return CareerReadinessConversationDocument.from_dict(result) + + async def find_all_by_user(self, user_id: str) -> list[CareerReadinessConversationDocument]: + cursor = self._collection.find({"user_id": {"$eq": user_id}}) + results = [] + async for doc in cursor: + results.append(CareerReadinessConversationDocument.from_dict(doc)) + return results + + async def append_message(self, conversation_id: str, message: CareerReadinessMessage) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + { + "$push": {"messages": message.model_dump()}, + "$set": {"updated_at": now}, + }, + ) + + async def update_covered_topics(self, conversation_id: str, topics: list[str]) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"covered_topics": topics, "updated_at": now}}, + ) + + async def update_quiz_delivered(self, conversation_id: str, delivered: bool) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"quiz_delivered": delivered, "updated_at": now}}, + ) + + async def update_quiz_passed(self, conversation_id: str, passed: bool) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"quiz_passed": passed, "updated_at": now}}, + ) + + async def update_conversation_mode(self, conversation_id: str, mode: ConversationMode) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"conversation_mode": mode.value, "updated_at": now}}, + ) + + async def delete_by_conversation_id(self, conversation_id: str) -> bool: + result = await self._collection.delete_one( + {"conversation_id": {"$eq": conversation_id}} + ) + return result.deleted_count > 0 diff --git a/backend/app/career_readiness/routes.py b/backend/app/career_readiness/routes.py new file mode 100644 index 00000000..49540c48 --- /dev/null +++ b/backend/app/career_readiness/routes.py @@ -0,0 +1,293 @@ +""" +This module contains the routes for the career readiness module. +""" +import asyncio +import logging +from http import HTTPStatus +from typing import Annotated, Optional + +from fastapi import Body, FastAPI, APIRouter, Depends, HTTPException, Path +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.career_readiness.errors import ( + CareerReadinessModuleNotFoundError, + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationModuleMismatchError, + ConversationNotFoundError, + ModuleNotUnlockedError, + QuizAlreadyPassedError, + QuizNotAvailableError, +) +from app.career_readiness.module_loader import get_module_registry +from app.career_readiness.repository import CareerReadinessConversationRepository +from app.career_readiness.service import CareerReadinessService, ICareerReadinessService +from app.career_readiness.types import ( + ModuleListResponse, + ModuleDetail, + CareerReadinessConversationResponse, + CareerReadinessConversationInput, + QuizResponse, + QuizSubmissionInput, + QuizSubmissionResponse, +) +from app.constants.errors import HTTPErrorResponse +from app.conversations.constants import MAX_MESSAGE_LENGTH +from app.server_dependencies.db_dependencies import CompassDBProvider +from app.users.auth import Authentication, UserInfo + +logger = logging.getLogger(__name__) + +# Lock to ensure that the singleton instance is thread-safe +_career_readiness_service_lock = asyncio.Lock() +_career_readiness_service_singleton: Optional[ICareerReadinessService] = None + + +async def get_career_readiness_service( + application_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_application_db), +) -> ICareerReadinessService: + """Get or create the career readiness service singleton.""" + global _career_readiness_service_singleton + if _career_readiness_service_singleton is None: + async with _career_readiness_service_lock: + if _career_readiness_service_singleton is None: + _career_readiness_service_singleton = CareerReadinessService( + repository=CareerReadinessConversationRepository(application_db), + module_registry=get_module_registry(), + ) + return _career_readiness_service_singleton + + +def add_career_readiness_routes(app: FastAPI, authentication: Authentication): + """ + Adds all the career readiness routes to the FastAPI app. + + :param app: FastAPI: The FastAPI app to add the routes to. + :param authentication: Authentication Module Dependency: The authentication instance to use for the routes. + """ + + router = APIRouter(prefix="/career-readiness", tags=["career-readiness"]) + + @router.get( + path="/modules", + response_model=ModuleListResponse, + responses={ + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="List all career readiness modules with the current user's progress status.", + ) + async def _list_modules( + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.list_modules(user_info.user_id) + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.get( + path="/modules/{module_id}", + response_model=ModuleDetail, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Get details of a specific career readiness module, including active conversation ID.", + ) + async def _get_module( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.get_module(user_info.user_id, module_id) + except CareerReadinessModuleNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Module not found: {module_id}") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.post( + path="/modules/{module_id}/conversations", + status_code=HTTPStatus.CREATED, + response_model=CareerReadinessConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.CONFLICT: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Start a new conversation for a career readiness module. Returns the introductory message.", + ) + async def _create_conversation( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.create_conversation(user_info.user_id, module_id) + except CareerReadinessModuleNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Module not found: {module_id}") from exc + except ModuleNotUnlockedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) from exc + except ConversationAlreadyExistsError as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, + detail=f"A conversation already exists for module {module_id}") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.post( + path="/modules/{module_id}/conversations/{conversation_id}/messages", + status_code=HTTPStatus.CREATED, + response_model=CareerReadinessConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.REQUEST_ENTITY_TOO_LARGE: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Send a message in an active career readiness conversation and receive the AI response.", + ) + async def _send_message( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + body: CareerReadinessConversationInput, + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + if len(body.user_input) > MAX_MESSAGE_LENGTH: + logger.warning("User input exceeded maximum length of %d characters", MAX_MESSAGE_LENGTH) + raise HTTPException(status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, detail="Too long user input") + + try: + return await service.send_message(user_info.user_id, module_id, conversation_id, body.user_input) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.get( + path="/modules/{module_id}/conversations/{conversation_id}/messages", + response_model=CareerReadinessConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Retrieve the full message history for a career readiness conversation.", + ) + async def _get_conversation_history( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.get_conversation_history(user_info.user_id, module_id, conversation_id) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.delete( + path="/modules/{module_id}/conversations/{conversation_id}", + status_code=HTTPStatus.NO_CONTENT, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Delete a career readiness conversation and reset the module status to NOT_STARTED.", + ) + async def _delete_conversation( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + await service.delete_conversation(user_info.user_id, module_id, conversation_id) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.get( + path="/modules/{module_id}/conversations/{conversation_id}/quiz", + response_model=QuizResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.CONFLICT: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Get quiz questions for the active quiz.", + ) + async def _get_quiz( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.get_quiz(user_info.user_id, module_id, conversation_id) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except (QuizNotAvailableError, QuizAlreadyPassedError) as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="Quiz is not available") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.post( + path="/modules/{module_id}/conversations/{conversation_id}/quiz", + status_code=HTTPStatus.OK, + response_model=QuizSubmissionResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.CONFLICT: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Submit quiz answers for evaluation.", + ) + async def _submit_quiz( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + body: Annotated[QuizSubmissionInput, Body( + openapi_examples={ + "three_questions": { + "summary": "Answers for a 3-question quiz", + "value": {"answers": {"1": "B", "2": "A", "3": "C"}}, + }, + }, + )], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.submit_quiz(user_info.user_id, module_id, conversation_id, body.answers) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except (QuizNotAvailableError, QuizAlreadyPassedError) as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="Quiz is not available") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + app.include_router(router) diff --git a/backend/app/career_readiness/service.py b/backend/app/career_readiness/service.py new file mode 100644 index 00000000..c62c85b0 --- /dev/null +++ b/backend/app/career_readiness/service.py @@ -0,0 +1,573 @@ +""" +Service layer for the career readiness module. + +Orchestrates the module registry, repository, and agent to implement +the career readiness business logic. +""" +import logging +import math +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from typing import Callable + +from bson import ObjectId + +from app.agent.agent_types import AgentInput, AgentOutput +from app.career_readiness.agent import CareerReadinessAgent, CareerReadinessAgentOutput +from app.career_readiness.errors import ( + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationModuleMismatchError, + ConversationNotFoundError, + CareerReadinessModuleNotFoundError, + ModuleNotUnlockedError, + QuizAlreadyPassedError, + QuizNotAvailableError, +) +from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry, QuizConfig +from app.career_readiness.repository import ICareerReadinessConversationRepository +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessConversationResponse, + CareerReadinessMessage, + CareerReadinessMessageSender, + ConversationMode, + ModuleDetail, + ModuleListResponse, + ModuleStatus, + ModuleSummary, + QuizQuestionResponse, + QuizQuestionResult, + QuizResponse, + QuizSubmissionResponse, +) +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) + + +SILENCE_MESSAGE = "(silence)" +"""Synthetic user message stored alongside the agent intro, matching the core Compass pattern.""" + + +def _filter_silence(messages: list[CareerReadinessMessage]) -> list[CareerReadinessMessage]: + """Filter out synthetic silence messages before returning to the frontend.""" + return [m for m in messages if m.message != SILENCE_MESSAGE] + + +class ICareerReadinessService(ABC): + """Interface for the career readiness service.""" + + @abstractmethod + async def list_modules(self, user_id: str) -> ModuleListResponse: + """List all modules with the user's progress status.""" + raise NotImplementedError() + + @abstractmethod + async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: + """Get detailed information about a specific module.""" + raise NotImplementedError() + + @abstractmethod + async def create_conversation(self, user_id: str, module_id: str) -> CareerReadinessConversationResponse: + """Start a new conversation for a module.""" + raise NotImplementedError() + + @abstractmethod + async def send_message(self, user_id: str, module_id: str, + conversation_id: str, user_input: str) -> CareerReadinessConversationResponse: + """Send a user message and get the agent's response.""" + raise NotImplementedError() + + @abstractmethod + async def get_conversation_history(self, user_id: str, module_id: str, + conversation_id: str) -> CareerReadinessConversationResponse: + """Retrieve the full message history for a conversation.""" + raise NotImplementedError() + + @abstractmethod + async def get_quiz(self, user_id: str, module_id: str, + conversation_id: str) -> QuizResponse: + """Get quiz questions for the active quiz.""" + raise NotImplementedError() + + @abstractmethod + async def submit_quiz(self, user_id: str, module_id: str, + conversation_id: str, answers: dict[int, str]) -> QuizSubmissionResponse: + """Submit quiz answers for evaluation.""" + raise NotImplementedError() + + @abstractmethod + async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: + """Delete a conversation.""" + raise NotImplementedError() + + +def _default_agent_factory(module_config: ModuleConfig, mode: ConversationMode) -> CareerReadinessAgent: + """Default factory that creates a real CareerReadinessAgent.""" + return CareerReadinessAgent( + module_title=module_config.title, + module_content=module_config.content, + mode=mode, + topics=module_config.topics, + ) + + +def _derive_module_statuses( + all_modules: list[ModuleConfig], + user_conversations: list[CareerReadinessConversationDocument], +) -> dict[str, ModuleStatus]: + """ + Derive the status of all modules based on sequential unlock logic. + + Rules: + - First module is always UNLOCKED (if no conversation exists) + - Subsequent modules unlock when the previous module is COMPLETED (quiz passed) + - A module with a conversation is IN_PROGRESS (unless quiz passed → COMPLETED) + - Only one module can be UNLOCKED at a time (the next eligible one) + """ + conv_by_module = {c.module_id: c for c in user_conversations} + sorted_modules = sorted(all_modules, key=lambda m: m.sort_order) + + statuses: dict[str, ModuleStatus] = {} + previous_completed = True # First module is always unlocked + + for module in sorted_modules: + conversation = conv_by_module.get(module.id) + + if conversation is not None: + if conversation.quiz_passed: + statuses[module.id] = ModuleStatus.COMPLETED + previous_completed = True + else: + statuses[module.id] = ModuleStatus.IN_PROGRESS + previous_completed = False + elif previous_completed: + statuses[module.id] = ModuleStatus.UNLOCKED + previous_completed = False + else: + statuses[module.id] = ModuleStatus.NOT_STARTED + previous_completed = False + + return statuses + + +def _build_conversation_context(messages: list[CareerReadinessMessage]) -> ConversationContext: + """ + Build a ConversationContext from stored messages. + + Pairs user+agent messages into ConversationTurns for the LLM history. + """ + turns: list[ConversationTurn] = [] + index = 0 + + i = 0 + while i < len(messages): + msg = messages[i] + + # Skip agent messages that are not paired with a user message (e.g. intro message) + if msg.sender == CareerReadinessMessageSender.AGENT and (i + 1 >= len(messages) or messages[i + 1].sender != CareerReadinessMessageSender.USER): + i += 1 + continue + + # Look for user+agent pairs + if msg.sender == CareerReadinessMessageSender.USER and i + 1 < len(messages) and messages[i + 1].sender == CareerReadinessMessageSender.AGENT: + user_msg = msg + agent_msg = messages[i + 1] + turns.append(ConversationTurn( + index=index, + input=AgentInput( + message_id=user_msg.message_id, + message=user_msg.message, + sent_at=user_msg.sent_at, + ), + output=AgentOutput( + message_id=agent_msg.message_id, + message_for_user=agent_msg.message, + finished=False, + agent_response_time_in_sec=0, + llm_stats=[], + sent_at=agent_msg.sent_at, + ), + )) + index += 1 + i += 2 + else: + i += 1 + + history = ConversationHistory(turns=turns) + return ConversationContext(all_history=history, history=history) + + +def _evaluate_quiz(quiz: QuizConfig, answers: dict[int, str]) -> tuple[int, int, list[bool]]: + """ + Evaluate quiz answers deterministically. + Returns (score, total, list_of_correct_booleans). + """ + total = len(quiz.questions) + results = [] + score = 0 + for i, question in enumerate(quiz.questions, 1): + user_answer = answers.get(i, "") + correct = user_answer == question.correct_answer + results.append(correct) + if correct: + score += 1 + return score, total, results + + +class CareerReadinessService(ICareerReadinessService): + """Implementation of the career readiness service.""" + + def __init__( + self, + repository: ICareerReadinessConversationRepository, + module_registry: ModuleRegistry, + agent_factory: Callable[[ModuleConfig, ConversationMode], CareerReadinessAgent] | None = None, + ): + self._repository = repository + self._module_registry = module_registry + self._agent_factory = agent_factory or _default_agent_factory + self._logger = logging.getLogger(CareerReadinessService.__name__) + + def _get_module_or_raise(self, module_id: str) -> ModuleConfig: + """Get a module from the registry or raise CareerReadinessModuleNotFoundError.""" + module = self._module_registry.get_module(module_id) + if module is None: + raise CareerReadinessModuleNotFoundError(module_id) + return module + + async def _get_conversation_or_raise(self, conversation_id: str) -> CareerReadinessConversationDocument: + """Find a conversation or raise ConversationNotFoundError.""" + conversation = await self._repository.find_by_conversation_id(conversation_id) + if conversation is None: + raise ConversationNotFoundError(conversation_id) + return conversation + + def _validate_access(self, conversation: CareerReadinessConversationDocument, + user_id: str, module_id: str) -> None: + """Validate that the user owns the conversation and it belongs to the correct module.""" + if conversation.user_id != user_id: + raise ConversationAccessDeniedError(conversation.conversation_id, user_id) + if conversation.module_id != module_id: + raise ConversationModuleMismatchError(conversation.conversation_id, module_id) + + async def list_modules(self, user_id: str) -> ModuleListResponse: + all_modules = self._module_registry.get_all_modules() + user_conversations = await self._repository.find_all_by_user(user_id) + statuses = _derive_module_statuses(all_modules, user_conversations) + + summaries = [] + for module in all_modules: + summaries.append(ModuleSummary( + id=module.id, + title=module.title, + description=module.description, + icon=module.icon, + status=statuses.get(module.id, ModuleStatus.NOT_STARTED), + sort_order=module.sort_order, + input_placeholder=module.input_placeholder, + )) + + return ModuleListResponse(modules=summaries) + + async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: + module = self._get_module_or_raise(module_id) + all_modules = self._module_registry.get_all_modules() + user_conversations = await self._repository.find_all_by_user(user_id) + statuses = _derive_module_statuses(all_modules, user_conversations) + conversation = next((c for c in user_conversations if c.module_id == module_id), None) + + return ModuleDetail( + id=module.id, + title=module.title, + description=module.description, + icon=module.icon, + status=statuses.get(module.id, ModuleStatus.NOT_STARTED), + sort_order=module.sort_order, + input_placeholder=module.input_placeholder, + scope=module.content, + active_conversation_id=conversation.conversation_id if conversation else None, + ) + + async def create_conversation(self, user_id: str, module_id: str) -> CareerReadinessConversationResponse: + module = self._get_module_or_raise(module_id) + + # Check sequential unlock + all_modules = self._module_registry.get_all_modules() + user_conversations = await self._repository.find_all_by_user(user_id) + statuses = _derive_module_statuses(all_modules, user_conversations) + module_status = statuses.get(module_id, ModuleStatus.NOT_STARTED) + + if module_status == ModuleStatus.NOT_STARTED: + raise ModuleNotUnlockedError(module_id) + + # Check for existing conversation + existing = next((c for c in user_conversations if c.module_id == module_id), None) + if existing is not None: + raise ConversationAlreadyExistsError(module_id, user_id) + + # Create agent in instruction mode and generate intro + agent = self._agent_factory(module, ConversationMode.INSTRUCTION) + empty_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + intro_result = await agent.generate_intro_message(empty_context) + intro_output = intro_result.agent_output + + # Build conversation document with synthetic silence + intro (matches core Compass pattern) + now = datetime.now(timezone.utc) + conversation_id = str(ObjectId()) + silence_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=SILENCE_MESSAGE, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) + intro_message = CareerReadinessMessage( + message_id=intro_output.message_id or str(ObjectId()), + message=intro_output.message_for_user, + sender=CareerReadinessMessageSender.AGENT, + sent_at=intro_output.sent_at, + ) + + document = CareerReadinessConversationDocument( + conversation_id=conversation_id, + module_id=module_id, + user_id=user_id, + messages=[silence_message, intro_message], + conversation_mode=ConversationMode.INSTRUCTION, + created_at=now, + updated_at=now, + ) + await self._repository.create(document) + + return CareerReadinessConversationResponse( + conversation_id=conversation_id, + module_id=module_id, + messages=_filter_silence([intro_message]), + covered_topics=[], + conversation_mode=ConversationMode.INSTRUCTION, + ) + + async def send_message(self, user_id: str, module_id: str, + conversation_id: str, user_input: str) -> CareerReadinessConversationResponse: + module = self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + if conversation.conversation_mode == ConversationMode.SUPPORT: + return await self._handle_support_message(module, conversation, user_input) + return await self._handle_instruction_message(module, conversation, user_input) + + async def _handle_instruction_message( + self, module: ModuleConfig, + conversation: CareerReadinessConversationDocument, + user_input: str, + ) -> CareerReadinessConversationResponse: + """Handle a message in instruction mode with topic tracking and quiz trigger.""" + existing_messages = list(conversation.messages) + + # Build and persist user message + now = datetime.now(timezone.utc) + user_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=user_input, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) + await self._repository.append_message(conversation.conversation_id, user_message) + + # Build context and call agent + all_messages = existing_messages + [user_message] + context = _build_conversation_context(all_messages) + agent = self._agent_factory(module, ConversationMode.INSTRUCTION) + agent_input = AgentInput(message=user_input, sent_at=now) + agent_result: CareerReadinessAgentOutput = await agent.execute(agent_input, context) + + # Accumulate covered topics + new_covered = set(conversation.covered_topics) | set(agent_result.topics_covered) + covered_topics_list = sorted(new_covered) + await self._repository.update_covered_topics(conversation.conversation_id, covered_topics_list) + + # Build and persist agent response + agent_output = agent_result.agent_output + agent_message = CareerReadinessMessage( + message_id=agent_output.message_id or str(ObjectId()), + message=agent_output.message_for_user, + sender=CareerReadinessMessageSender.AGENT, + sent_at=agent_output.sent_at, + ) + await self._repository.append_message(conversation.conversation_id, agent_message) + response_messages = all_messages + [agent_message] + + # Check if all topics are covered AND agent says finished → deliver quiz + quiz_available = conversation.quiz_delivered and not conversation.quiz_passed + all_topics_covered = set(module.topics).issubset(new_covered) if module.topics else True + if (all_topics_covered and agent_output.finished + and module.quiz is not None and not conversation.quiz_delivered): + marker_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message="Great work! You've covered the key topics. " + "It's time for a short quiz to check your understanding.", + sender=CareerReadinessMessageSender.AGENT, + sent_at=datetime.now(timezone.utc), + ) + await self._repository.append_message(conversation.conversation_id, marker_message) + await self._repository.update_quiz_delivered(conversation.conversation_id, True) + response_messages.append(marker_message) + quiz_available = True + + return CareerReadinessConversationResponse( + conversation_id=conversation.conversation_id, + module_id=conversation.module_id, + messages=_filter_silence(response_messages), + covered_topics=covered_topics_list, + conversation_mode=ConversationMode.INSTRUCTION, + quiz_available=quiz_available, + ) + + async def _handle_support_message( + self, module: ModuleConfig, + conversation: CareerReadinessConversationDocument, + user_input: str, + ) -> CareerReadinessConversationResponse: + """Handle a message in support mode (post-quiz follow-up Q&A).""" + existing_messages = list(conversation.messages) + + now = datetime.now(timezone.utc) + user_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=user_input, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) + await self._repository.append_message(conversation.conversation_id, user_message) + + all_messages = existing_messages + [user_message] + context = _build_conversation_context(all_messages) + agent = self._agent_factory(module, ConversationMode.SUPPORT) + agent_input = AgentInput(message=user_input, sent_at=now) + agent_result: CareerReadinessAgentOutput = await agent.execute(agent_input, context) + agent_output = agent_result.agent_output + + agent_message = CareerReadinessMessage( + message_id=agent_output.message_id or str(ObjectId()), + message=agent_output.message_for_user, + sender=CareerReadinessMessageSender.AGENT, + sent_at=agent_output.sent_at, + ) + await self._repository.append_message(conversation.conversation_id, agent_message) + + return CareerReadinessConversationResponse( + conversation_id=conversation.conversation_id, + module_id=conversation.module_id, + messages=_filter_silence(all_messages + [agent_message]), + covered_topics=conversation.covered_topics, + quiz_passed=True, + conversation_mode=ConversationMode.SUPPORT, + ) + + async def get_conversation_history(self, user_id: str, module_id: str, + conversation_id: str) -> CareerReadinessConversationResponse: + self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + return CareerReadinessConversationResponse( + conversation_id=conversation.conversation_id, + module_id=conversation.module_id, + messages=_filter_silence(list(conversation.messages)), + covered_topics=conversation.covered_topics, + conversation_mode=conversation.conversation_mode, + quiz_passed=conversation.quiz_passed if conversation.quiz_delivered else None, + quiz_available=conversation.quiz_delivered and not conversation.quiz_passed, + ) + + async def get_quiz(self, user_id: str, module_id: str, + conversation_id: str) -> QuizResponse: + module = self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + if conversation.quiz_passed: + raise QuizAlreadyPassedError(conversation_id) + if not conversation.quiz_delivered or module.quiz is None: + raise QuizNotAvailableError(conversation_id) + + return QuizResponse(questions=[ + QuizQuestionResponse(question=q.question, options=q.options) + for q in module.quiz.questions + ]) + + async def submit_quiz(self, user_id: str, module_id: str, + conversation_id: str, answers: dict[int, str]) -> QuizSubmissionResponse: + module = self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + if not conversation.quiz_delivered or module.quiz is None: + raise QuizNotAvailableError(conversation_id) + if conversation.quiz_passed: + raise QuizAlreadyPassedError(conversation_id) + + # Persist user answer message for audit trail + now = datetime.now(timezone.utc) + answer_text = "Quiz answers: " + ", ".join(f"{k}.{v}" for k, v in sorted(answers.items())) + user_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=answer_text, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) + await self._repository.append_message(conversation.conversation_id, user_message) + + score, total, results = _evaluate_quiz(module.quiz, answers) + passed = (score / total) >= module.quiz.pass_threshold if total > 0 else False + + question_results = [ + QuizQuestionResult(question_index=i + 1, is_correct=correct) + for i, correct in enumerate(results) + ] + + if passed: + feedback = (f"You scored {score}/{total}. Congratulations, you passed! " + "You can now continue to ask me any follow-up questions about this topic.") + await self._repository.update_quiz_passed(conversation.conversation_id, True) + await self._repository.update_conversation_mode( + conversation.conversation_id, ConversationMode.SUPPORT) + result_mode = ConversationMode.SUPPORT + else: + threshold_count = math.ceil(module.quiz.pass_threshold * total) + feedback = (f"You scored {score}/{total}. You need at least {threshold_count} " + "correct answers to pass. Let's review the topics and try again.") + result_mode = ConversationMode.INSTRUCTION + + feedback_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=feedback, + sender=CareerReadinessMessageSender.AGENT, + sent_at=datetime.now(timezone.utc), + ) + await self._repository.append_message(conversation.conversation_id, feedback_message) + + return QuizSubmissionResponse( + score=score, + total=total, + passed=passed, + question_results=question_results, + module_completed=passed, + conversation_mode=result_mode, + ) + + async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: + self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + deleted = await self._repository.delete_by_conversation_id(conversation_id) + if not deleted: + raise ConversationNotFoundError(conversation_id) diff --git a/backend/app/career_readiness/test_agent.py b/backend/app/career_readiness/test_agent.py new file mode 100644 index 00000000..36c44df0 --- /dev/null +++ b/backend/app/career_readiness/test_agent.py @@ -0,0 +1,232 @@ +""" +Tests for the career readiness agent. +""" +from unittest.mock import AsyncMock, patch + +import pytest + +from app.agent.agent_types import AgentInput, AgentType, LLMStats +from app.career_readiness.agent import ( + CareerReadinessAgent, + CareerReadinessAgentOutput, + CareerReadinessModelResponse, + _build_instruction_mode_instructions, + _build_support_mode_instructions, +) +from app.career_readiness.types import ConversationMode +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, +) + + +class TestBuildInstructionModeInstructions: + """Tests for the instruction mode system instructions builder.""" + + def test_includes_all_required_elements(self): + # GIVEN a module title, content, and topics + given_title = "CV Development" + given_content = "How to write a great CV." + given_topics = ["CV Structure", "Writing Tips"] + + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions(given_title, given_content, given_topics) + + # THEN the module title and content are included + assert given_title in actual_instructions + assert given_content in actual_instructions + # AND each topic appears in the instructions + for topic in given_topics: + assert topic in actual_instructions + # AND the scaffolded Socratic tutoring keywords are present + assert "ASSESS" in actual_instructions + assert "GUIDE" in actual_instructions + assert "HINT" in actual_instructions + assert "EXPLAIN" in actual_instructions + assert "FADE" in actual_instructions + # AND topics_covered is referenced in the response schema + assert "topics_covered" in actual_instructions + # AND quiz concealment instructions are present + assert "quiz" in actual_instructions.lower() + assert "do not" in actual_instructions.lower() + # AND minimal-response handling instructions are present + assert "minimal" in actual_instructions.lower() + assert "demonstrate" in actual_instructions.lower() + # AND comprehension check techniques are mentioned + assert "explain" in actual_instructions.lower() + assert "application" in actual_instructions.lower() + + +class TestBuildSupportModeInstructions: + """Tests for the support mode system instructions builder.""" + + def test_includes_all_required_elements(self): + # GIVEN a module title and content + given_title = "CV Development" + given_content = "How to write a great CV." + + # WHEN the support mode instructions are built + actual_instructions = _build_support_mode_instructions(given_title, given_content) + + # THEN the module title and content are included + assert given_title in actual_instructions + assert given_content in actual_instructions + # AND it instructs finished to always be false + assert "false" in actual_instructions.lower() + assert "finished" in actual_instructions.lower() + # AND there is an instruction not to re-initiate the lesson plan + assert "lesson plan" in actual_instructions.lower() + + +@patch("app.career_readiness.agent.GeminiGenerativeLLM") +class TestCareerReadinessAgent: + """Tests for the CareerReadinessAgent class.""" + + def test_initializes_in_instruction_mode_by_default(self, mock_llm_cls): + # GIVEN a module title, content, and topics + given_title = "CV Development" + given_content = "Write your CV with clear structure." + given_topics = ["CV Structure", "Writing Tips"] + + # WHEN the agent is created without specifying mode + actual_agent = CareerReadinessAgent( + module_title=given_title, module_content=given_content, topics=given_topics) + + # THEN the system instructions contain scaffolded Socratic content + assert "ASSESS" in actual_agent.system_instructions + assert given_title in actual_agent.system_instructions + assert given_content in actual_agent.system_instructions + + def test_initializes_in_support_mode(self, mock_llm_cls): + # GIVEN a module title and content + given_title = "CV Development" + given_content = "Write your CV with clear structure." + + # WHEN the agent is created in support mode + actual_agent = CareerReadinessAgent( + module_title=given_title, module_content=given_content, + mode=ConversationMode.SUPPORT) + + # THEN the system instructions contain support mode content + assert "follow-up" in actual_agent.system_instructions.lower() + # AND do NOT contain instruction mode content + assert "ASSESS" not in actual_agent.system_instructions + + @pytest.mark.asyncio + async def test_execute_returns_career_readiness_agent_output(self, mock_llm_cls): + # GIVEN an agent with a mocked LLM caller + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", + topics=["Topic A", "Topic B"]) + given_model_response = CareerReadinessModelResponse( + reasoning="The user asked about Topic A.", + finished=False, + message="Let's start with Topic A. What do you already know?", + topics_covered=["Topic A"], + ) + given_llm_stats = [LLMStats( + prompt_token_count=100, + response_token_count=50, + response_time_in_sec=1.0, + )] + given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, given_llm_stats)) + + # AND a user input and empty context + given_input = AgentInput(message="Hi, I'd like to learn about Topic A") + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN execute is called + actual_output = await given_agent.execute(given_input, given_context) + + # THEN the output is a CareerReadinessAgentOutput + assert isinstance(actual_output, CareerReadinessAgentOutput) + # AND the agent output contains the expected message + assert actual_output.agent_output.message_for_user == "Let's start with Topic A. What do you already know?" + # AND the finished flag matches the model response + assert actual_output.agent_output.finished is False + # AND the topics_covered are propagated + assert actual_output.topics_covered == ["Topic A"] + # AND the agent type is correct + assert actual_output.agent_output.agent_type == AgentType.CAREER_READINESS_AGENT + + @pytest.mark.asyncio + async def test_execute_handles_empty_input(self, mock_llm_cls): + # GIVEN an agent with a mocked LLM caller + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", topics=["Topic A"]) + given_model_response = CareerReadinessModelResponse( + reasoning="The user sent empty input, I will greet them.", + finished=False, + message="Hello! How can I help you today?", + topics_covered=[], + ) + given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) + + # AND an empty user input + given_input = AgentInput(message=" ") + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN execute is called + actual_output = await given_agent.execute(given_input, given_context) + + # THEN the LLM is called with "(silence)" as the user message + call_args = given_agent._llm_caller.call_llm.call_args + llm_input = call_args.kwargs["llm_input"] + assert any("(silence)" in turn.content for turn in llm_input.turns) + + @pytest.mark.asyncio + async def test_execute_handles_llm_error(self, mock_llm_cls): + # GIVEN an agent whose LLM caller raises an exception + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", topics=["Topic A"]) + given_agent._llm_caller.call_llm = AsyncMock(side_effect=Exception("LLM service unavailable")) + + given_input = AgentInput(message="Tell me about CVs") + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN execute is called + actual_output = await given_agent.execute(given_input, given_context) + + # THEN a fallback error message is returned + assert "difficulties" in actual_output.agent_output.message_for_user + # AND the agent does not claim to be finished + assert actual_output.agent_output.finished is False + # AND topics_covered is empty + assert actual_output.topics_covered == [] + + @pytest.mark.asyncio + async def test_generate_intro_message_sends_artificial_input(self, mock_llm_cls): + # GIVEN an agent with a mocked LLM caller + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", topics=["Topic A"]) + given_model_response = CareerReadinessModelResponse( + reasoning="Starting a new conversation, introducing the module.", + finished=False, + message="Welcome to the CV Development module!", + topics_covered=[], + ) + given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) + + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN generate_intro_message is called + actual_output = await given_agent.generate_intro_message(given_context) + + # THEN the output contains the introductory message + assert actual_output.agent_output.message_for_user == "Welcome to the CV Development module!" + # AND the LLM is called with "(silence)" as the user message + call_args = given_agent._llm_caller.call_llm.call_args + llm_input = call_args.kwargs["llm_input"] + assert any("(silence)" in turn.content for turn in llm_input.turns) diff --git a/backend/app/career_readiness/test_module_loader.py b/backend/app/career_readiness/test_module_loader.py new file mode 100644 index 00000000..056251ca --- /dev/null +++ b/backend/app/career_readiness/test_module_loader.py @@ -0,0 +1,365 @@ +""" +Tests for the career readiness module loader. +""" +import pytest + +from app.career_readiness.module_loader import ( + ModuleRegistry, + _parse_frontmatter, + _load_module_from_file, + _split_quiz_section, + _parse_quiz_section, +) + + +_VALID_FRONTMATTER = ( + "---\n" + "id: test-module\n" + "title: Test Module\n" + "description: A test module.\n" + "icon: test\n" + "sort_order: 1\n" + "input_placeholder: Ask something...\n" + "topics: Topic A, Topic B, Topic C\n" + "---\n\n" +) + +_VALID_QUIZ_SECTION = ( + "pass_threshold: 0.8\n\n" + "1. What is the answer?\n" + "A. Wrong\n" + "B. Correct\n" + "C. Wrong again\n" + "D. Still wrong\n" + "Answer: B\n\n" + "2. Another question?\n" + "A. No\n" + "B. Yes\n" + "C. Maybe\n" + "D. Perhaps\n" + "Answer: B\n" +) + + +class TestParseFrontmatter: + """Tests for the frontmatter parser.""" + + def test_parses_valid_frontmatter_and_body(self): + # GIVEN a markdown string with valid frontmatter + given_text = "---\nid: test-module\ntitle: Test Module\n---\n\n# Body Content\n\nSome text." + + # WHEN the frontmatter is parsed + actual_metadata, actual_body = _parse_frontmatter(given_text) + + # THEN the metadata contains the parsed key-value pairs + assert actual_metadata["id"] == "test-module" + assert actual_metadata["title"] == "Test Module" + # AND the body contains the markdown content + assert "# Body Content" in actual_body + assert "Some text." in actual_body + + def test_raises_when_missing_opening_delimiter(self): + # GIVEN a markdown string without the opening --- delimiter + given_text = "id: test-module\n---\n\nBody." + + # WHEN the frontmatter is parsed + # THEN a ValueError is raised + with pytest.raises(ValueError, match="must start with ---"): + _parse_frontmatter(given_text) + + def test_raises_when_line_has_no_colon(self): + # GIVEN a markdown string with an invalid frontmatter line (no colon) + given_text = "---\nid: test-module\ninvalid line\n---\n\nBody." + + # WHEN the frontmatter is parsed + # THEN a ValueError is raised + with pytest.raises(ValueError, match="missing colon"): + _parse_frontmatter(given_text) + + def test_handles_colons_in_values(self): + # GIVEN a frontmatter value that contains a colon + given_text = "---\ndescription: Learn to write: a guide\n---\n\nBody." + + # WHEN the frontmatter is parsed + actual_metadata, _ = _parse_frontmatter(given_text) + + # THEN the value includes everything after the first colon + assert actual_metadata["description"] == "Learn to write: a guide" + + +class TestSplitQuizSection: + """Tests for splitting the quiz section from module content.""" + + def test_splits_body_with_quiz_section(self): + # GIVEN a markdown body with a ## Quiz section + given_body = "# Module Content\n\nSome text.\n\n## Quiz\n\n1. A question?\nA. Yes\nAnswer: A" + + # WHEN the body is split + actual_content, actual_quiz_text = _split_quiz_section(given_body) + + # THEN the content contains everything before ## Quiz + assert "# Module Content" in actual_content + assert "Some text." in actual_content + # AND the quiz text contains the quiz section + assert "1. A question?" in actual_quiz_text + # AND the content does NOT contain quiz content + assert "## Quiz" not in actual_content + assert "A question?" not in actual_content + + def test_returns_none_when_no_quiz_section(self): + # GIVEN a markdown body without a ## Quiz section + given_body = "# Module Content\n\nSome text." + + # WHEN the body is split + actual_content, actual_quiz_text = _split_quiz_section(given_body) + + # THEN the content is the full body + assert actual_content == given_body + # AND the quiz text is None + assert actual_quiz_text is None + + def test_does_not_split_on_quiz_in_different_heading_level(self): + # GIVEN a body with ### Quiz (not ## Quiz) + given_body = "# Content\n\n### Quiz\n\nNot a real quiz." + + # WHEN the body is split + actual_content, actual_quiz_text = _split_quiz_section(given_body) + + # THEN the quiz text is None (### Quiz is not a split point) + assert actual_quiz_text is None + # AND the content contains the full body + assert "### Quiz" in actual_content + + +class TestParseQuizSection: + """Tests for parsing the quiz section text into QuizConfig.""" + + def test_parses_valid_quiz_section(self): + # GIVEN a valid quiz section text + given_text = _VALID_QUIZ_SECTION + + # WHEN the quiz section is parsed + actual_quiz = _parse_quiz_section(given_text) + + # THEN the pass threshold is parsed correctly + assert actual_quiz.pass_threshold == 0.8 + # AND there are 2 questions + assert len(actual_quiz.questions) == 2 + # AND the first question is parsed correctly + assert actual_quiz.questions[0].question == "What is the answer?" + assert len(actual_quiz.questions[0].options) == 4 + assert actual_quiz.questions[0].correct_answer == "B" + + def test_raises_when_no_questions_found(self): + # GIVEN a quiz section with no valid questions + given_text = "pass_threshold: 0.8\n\nSome random text." + + # WHEN the quiz section is parsed + # THEN a ValueError is raised + with pytest.raises(ValueError, match="no questions"): + _parse_quiz_section(given_text) + + def test_raises_when_answer_missing(self): + # GIVEN a quiz question without an Answer line + given_text = ( + "1. A question?\n" + "A. Option A\n" + "B. Option B\n" + "C. Option C\n" + "D. Option D\n" + ) + + # WHEN the quiz section is parsed + # THEN a ValueError is raised for missing answer + with pytest.raises(ValueError, match="missing Answer"): + _parse_quiz_section(given_text) + + +class TestLoadModuleFromFile: + """Tests for loading a single module from a file.""" + + def test_loads_valid_module_file_with_topics_and_quiz(self, tmp_path): + # GIVEN a valid module markdown file with topics and a quiz section + given_file = tmp_path / "test.md" + given_file.write_text( + _VALID_FRONTMATTER + + "# Test Content\n\nBody text.\n\n" + + "## Quiz\n\n" + + _VALID_QUIZ_SECTION, + encoding="utf-8", + ) + + # WHEN the module is loaded + actual_module = _load_module_from_file(given_file) + + # THEN the module has the correct metadata + assert actual_module.id == "test-module" + assert actual_module.title == "Test Module" + assert actual_module.sort_order == 1 + # AND the topics are parsed from comma-separated frontmatter + assert actual_module.topics == ["Topic A", "Topic B", "Topic C"] + # AND the content contains the markdown body but NOT the quiz + assert "# Test Content" in actual_module.content + assert "Body text." in actual_module.content + assert "## Quiz" not in actual_module.content + assert "What is the answer?" not in actual_module.content + # AND the quiz is parsed correctly + assert actual_module.quiz is not None + assert len(actual_module.quiz.questions) == 2 + assert actual_module.quiz.pass_threshold == 0.8 + + def test_loads_module_without_quiz(self, tmp_path): + # GIVEN a module file without a quiz section + given_file = tmp_path / "no_quiz.md" + given_file.write_text( + _VALID_FRONTMATTER + "# Content\n\nNo quiz here.", + encoding="utf-8", + ) + + # WHEN the module is loaded + actual_module = _load_module_from_file(given_file) + + # THEN the quiz is None + assert actual_module.quiz is None + # AND the content includes the full body + assert "No quiz here." in actual_module.content + + def test_raises_when_required_field_missing(self, tmp_path): + # GIVEN a module file missing the 'title' field + given_file = tmp_path / "bad.md" + given_file.write_text( + "---\n" + "id: bad-module\n" + "description: Missing title.\n" + "icon: test\n" + "sort_order: 1\n" + "input_placeholder: Ask...\n" + "---\n\nBody.", + encoding="utf-8", + ) + + # WHEN the module is loaded + # THEN a KeyError is raised for the missing field + with pytest.raises(KeyError): + _load_module_from_file(given_file) + + +class TestModuleRegistry: + """Tests for the module registry.""" + + def test_loads_all_real_modules(self): + # GIVEN the real modules directory + # WHEN a registry is created with the default path + actual_registry = ModuleRegistry() + + # THEN all 6 modules are loaded + actual_modules = actual_registry.get_all_modules() + assert len(actual_modules) == 6 + # AND they are sorted by sort_order + actual_orders = [m.sort_order for m in actual_modules] + assert actual_orders == sorted(actual_orders) + + def test_get_module_returns_correct_module(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN a specific module is requested + actual_module = actual_registry.get_module("cv-development") + + # THEN the correct module is returned + assert actual_module is not None + assert actual_module.id == "cv-development" + assert actual_module.title == "CV Development" + + def test_get_module_returns_none_for_nonexistent(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN a nonexistent module is requested + actual_module = actual_registry.get_module("nonexistent-module") + + # THEN None is returned + assert actual_module is None + + def test_loads_from_custom_directory(self, tmp_path): + # GIVEN a custom directory with two module files + given_module_dir = tmp_path / "modules" + given_module_dir.mkdir() + + for i, name in enumerate(["alpha", "beta"], start=1): + (given_module_dir / f"{name}.md").write_text( + f"---\n" + f"id: {name}\n" + f"title: Module {name.title()}\n" + f"description: Description for {name}.\n" + f"icon: {name}\n" + f"sort_order: {i}\n" + f"input_placeholder: Ask about {name}...\n" + f"topics: Topic 1, Topic 2\n" + f"---\n\n" + f"# {name.title()} Content", + encoding="utf-8", + ) + + # WHEN a registry is created with the custom directory + actual_registry = ModuleRegistry(modules_dir=given_module_dir) + + # THEN both modules are loaded + assert len(actual_registry.get_all_modules()) == 2 + assert actual_registry.get_module("alpha") is not None + assert actual_registry.get_module("beta") is not None + + def test_skips_files_starting_with_underscore(self, tmp_path): + # GIVEN a directory with a normal module and an underscore-prefixed file + given_module_dir = tmp_path / "modules" + given_module_dir.mkdir() + + (given_module_dir / "real.md").write_text( + "---\n" + "id: real\n" + "title: Real Module\n" + "description: A real module.\n" + "icon: real\n" + "sort_order: 1\n" + "input_placeholder: Ask...\n" + "topics: Topic 1\n" + "---\n\n# Content", + encoding="utf-8", + ) + (given_module_dir / "_example.md").write_text( + "---\nid: example\ntitle: Example\n---\n\nThis should be skipped.", + encoding="utf-8", + ) + + # WHEN a registry is created + actual_registry = ModuleRegistry(modules_dir=given_module_dir) + + # THEN only the real module is loaded + assert len(actual_registry.get_all_modules()) == 1 + assert actual_registry.get_module("real") is not None + assert actual_registry.get_module("example") is None + + def test_all_real_modules_have_topics(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN each module has at least one topic + for module in actual_modules: + assert len(module.topics) > 0, f"Module {module.id} has no topics" + + def test_all_real_modules_have_quiz(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN each module has a quiz with 10 questions + for module in actual_modules: + assert module.quiz is not None, f"Module {module.id} has no quiz" + assert len(module.quiz.questions) == 10, ( + f"Module {module.id} has {len(module.quiz.questions)} questions, expected 10" + ) diff --git a/backend/app/career_readiness/test_repository.py b/backend/app/career_readiness/test_repository.py new file mode 100644 index 00000000..adbb90ea --- /dev/null +++ b/backend/app/career_readiness/test_repository.py @@ -0,0 +1,302 @@ +""" +Tests for the career readiness conversation repository. +""" +from datetime import datetime, timezone +from typing import Awaitable + +import pytest +from bson import ObjectId + +from app.career_readiness.errors import ConversationAlreadyExistsError +from app.career_readiness.repository import CareerReadinessConversationRepository +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessMessage, + CareerReadinessMessageSender, + ConversationMode, +) + + +def _make_message(sender: CareerReadinessMessageSender = CareerReadinessMessageSender.AGENT, + message: str = "Hello") -> CareerReadinessMessage: + """Helper to create a test message.""" + return CareerReadinessMessage( + message_id=str(ObjectId()), + message=message, + sender=sender, + sent_at=datetime.now(timezone.utc), + ) + + +def _make_conversation( + user_id: str = "test_user", + module_id: str = "cv-development", + messages: list[CareerReadinessMessage] | None = None, +) -> CareerReadinessConversationDocument: + """Helper to create a test conversation document.""" + now = datetime.now(timezone.utc) + return CareerReadinessConversationDocument( + conversation_id=str(ObjectId()), + module_id=module_id, + user_id=user_id, + messages=messages or [_make_message()], + created_at=now, + updated_at=now, + ) + + +@pytest.fixture(scope="function") +async def get_repository(in_memory_application_database) -> CareerReadinessConversationRepository: + application_db = await in_memory_application_database + return CareerReadinessConversationRepository(application_db) + + +class TestCreate: + """Tests for creating conversation documents.""" + + @pytest.mark.asyncio + async def test_creates_and_finds_document(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository and a conversation document + repo = await get_repository + given_conversation = _make_conversation() + + # WHEN the document is created + await repo.create(given_conversation) + + # THEN the document can be found by conversation_id + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.conversation_id == given_conversation.conversation_id + assert actual_result.module_id == given_conversation.module_id + assert actual_result.user_id == given_conversation.user_id + assert len(actual_result.messages) == 1 + + + @pytest.mark.asyncio + async def test_raises_already_exists_on_duplicate_user_module(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with an existing conversation for a user and module + repo = await get_repository + given_user_id = "user_abc" + given_module_id = "cv-development" + given_first = _make_conversation(user_id=given_user_id, module_id=given_module_id) + await repo.create(given_first) + + # WHEN a second conversation is created for the same user and module + given_duplicate = _make_conversation(user_id=given_user_id, module_id=given_module_id) + + # THEN ConversationAlreadyExistsError is raised + with pytest.raises(ConversationAlreadyExistsError): + await repo.create(given_duplicate) + + +class TestFindByConversationId: + """Tests for finding a conversation by its ID.""" + + @pytest.mark.asyncio + async def test_returns_document_when_found(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN the conversation is requested by ID + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + + # THEN the correct document is returned + assert actual_result is not None + assert actual_result.conversation_id == given_conversation.conversation_id + + +class TestFindByUserAndModule: + """Tests for finding a conversation by user and module.""" + + @pytest.mark.asyncio + async def test_returns_correct_document(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation for a specific user and module + repo = await get_repository + given_user_id = "user_abc" + given_module_id = "cv-development" + given_conversation = _make_conversation(user_id=given_user_id, module_id=given_module_id) + await repo.create(given_conversation) + + # WHEN the conversation is requested by user and module + actual_result = await repo.find_by_user_and_module(given_user_id, given_module_id) + + # THEN the correct document is returned + assert actual_result is not None + assert actual_result.user_id == given_user_id + assert actual_result.module_id == given_module_id + + +class TestFindAllByUser: + """Tests for finding all conversations for a user.""" + + @pytest.mark.asyncio + async def test_returns_empty_list_when_no_conversations(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with no documents + repo = await get_repository + + # WHEN all conversations for a user are requested + actual_result = await repo.find_all_by_user("some_user") + + # THEN an empty list is returned + assert actual_result == [] + + @pytest.mark.asyncio + async def test_returns_all_conversations_for_user(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with multiple conversations for the same user + repo = await get_repository + given_user_id = "user_abc" + given_conv_1 = _make_conversation(user_id=given_user_id, module_id="cv-development") + given_conv_2 = _make_conversation(user_id=given_user_id, module_id="interview-preparation") + # AND a conversation for a different user + given_other_conv = _make_conversation(user_id="other_user", module_id="cv-development") + await repo.create(given_conv_1) + await repo.create(given_conv_2) + await repo.create(given_other_conv) + + # WHEN all conversations for the user are requested + actual_result = await repo.find_all_by_user(given_user_id) + + # THEN only the user's conversations are returned + assert len(actual_result) == 2 + actual_ids = {r.conversation_id for r in actual_result} + assert given_conv_1.conversation_id in actual_ids + assert given_conv_2.conversation_id in actual_ids + + +class TestAppendMessage: + """Tests for appending a message to a conversation.""" + + @pytest.mark.asyncio + async def test_appends_message_to_conversation(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation containing one message + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN a new message is appended + given_new_message = _make_message( + sender=CareerReadinessMessageSender.USER, + message="How do I write a CV?", + ) + await repo.append_message(given_conversation.conversation_id, given_new_message) + + # THEN the conversation now has two messages + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert len(actual_result.messages) == 2 + assert actual_result.messages[1].message == "How do I write a CV?" + + +class TestDeleteByConversationId: + """Tests for deleting a conversation.""" + + @pytest.mark.asyncio + async def test_returns_false_when_not_found(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with no documents + repo = await get_repository + + # WHEN a nonexistent conversation is deleted + actual_result = await repo.delete_by_conversation_id("nonexistent") + + # THEN False is returned + assert actual_result is False + + @pytest.mark.asyncio + async def test_deletes_and_returns_true(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN the conversation is deleted + actual_result = await repo.delete_by_conversation_id(given_conversation.conversation_id) + + # THEN True is returned + assert actual_result is True + # AND the conversation can no longer be found + assert await repo.find_by_conversation_id(given_conversation.conversation_id) is None + + +class TestUpdateCoveredTopics: + """Tests for updating covered topics on a conversation.""" + + @pytest.mark.asyncio + async def test_updates_covered_topics(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN covered topics are updated + given_topics = ["Topic A", "Topic B"] + await repo.update_covered_topics(given_conversation.conversation_id, given_topics) + + # THEN the conversation has the updated topics + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.covered_topics == given_topics + + +class TestUpdateQuizDelivered: + """Tests for updating the quiz_delivered flag.""" + + @pytest.mark.asyncio + async def test_sets_quiz_delivered_to_true(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation where quiz is not yet delivered + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN quiz_delivered is set to True + await repo.update_quiz_delivered(given_conversation.conversation_id, True) + + # THEN the conversation has quiz_delivered = True + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.quiz_delivered is True + + +class TestUpdateQuizPassed: + """Tests for updating the quiz_passed flag.""" + + @pytest.mark.asyncio + async def test_sets_quiz_passed_to_true(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN quiz_passed is set to True + await repo.update_quiz_passed(given_conversation.conversation_id, True) + + # THEN the conversation has quiz_passed = True + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.quiz_passed is True + + +class TestUpdateConversationMode: + """Tests for updating the conversation mode.""" + + @pytest.mark.asyncio + async def test_updates_to_support_mode(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation in INSTRUCTION mode + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN the conversation mode is updated to SUPPORT + await repo.update_conversation_mode(given_conversation.conversation_id, ConversationMode.SUPPORT) + + # THEN the conversation has mode = SUPPORT + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.conversation_mode == ConversationMode.SUPPORT diff --git a/backend/app/career_readiness/test_routes.py b/backend/app/career_readiness/test_routes.py new file mode 100644 index 00000000..959af3c6 --- /dev/null +++ b/backend/app/career_readiness/test_routes.py @@ -0,0 +1,472 @@ +""" +Tests for the career readiness routes. +""" +from datetime import datetime, timezone +from http import HTTPStatus +from typing import Optional + +import pytest +import pytest_mock +from bson import ObjectId +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.career_readiness.errors import ( + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationNotFoundError, + CareerReadinessModuleNotFoundError, + QuizAlreadyPassedError, + QuizNotAvailableError, +) +from app.career_readiness.routes import add_career_readiness_routes, get_career_readiness_service +from app.career_readiness.service import ICareerReadinessService +from app.career_readiness.types import ( + CareerReadinessConversationResponse, + CareerReadinessMessage, + CareerReadinessMessageSender, + ConversationMode, + ModuleDetail, + ModuleListResponse, + ModuleStatus, + ModuleSummary, + QuizQuestionResponse, + QuizQuestionResult, + QuizResponse, + QuizSubmissionResponse, +) +from app.conversations.constants import MAX_MESSAGE_LENGTH +from app.users.auth import UserInfo +from common_libs.test_utilities.mock_auth import MockAuth + +TestClientWithMocks = tuple[TestClient, ICareerReadinessService, UserInfo] + + +def _make_message( + sender: CareerReadinessMessageSender = CareerReadinessMessageSender.AGENT, + message: str = "Hello", +) -> CareerReadinessMessage: + return CareerReadinessMessage( + message_id=str(ObjectId()), + message=message, + sender=sender, + sent_at=datetime.now(timezone.utc), + ) + + +def _make_module_summary(module_id: str = "cv-development") -> ModuleSummary: + return ModuleSummary( + id=module_id, + title="CV Development", + description="Learn to write a CV.", + icon="cv", + status=ModuleStatus.NOT_STARTED, + sort_order=1, + input_placeholder="Ask about CVs...", + ) + + +def _make_conversation_response( + conversation_id: str | None = None, + module_id: str = "cv-development", +) -> CareerReadinessConversationResponse: + return CareerReadinessConversationResponse( + conversation_id=conversation_id or str(ObjectId()), + module_id=module_id, + messages=[_make_message()], + ) + + +@pytest.fixture(scope="function") +def client_with_mocks() -> TestClientWithMocks: + """Create a FastAPI test client with mocked service and auth.""" + + class MockService(ICareerReadinessService): + async def list_modules(self, user_id: str) -> ModuleListResponse: + return ModuleListResponse(modules=[_make_module_summary()]) + + async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: + return ModuleDetail( + id=module_id, + title="CV Development", + description="Learn to write a CV.", + icon="cv", + status=ModuleStatus.NOT_STARTED, + sort_order=1, + input_placeholder="Ask about CVs...", + scope="# CV Content", + ) + + async def create_conversation(self, user_id: str, module_id: str) -> CareerReadinessConversationResponse: + return _make_conversation_response(module_id=module_id) + + async def send_message(self, user_id: str, module_id: str, + conversation_id: str, user_input: str) -> CareerReadinessConversationResponse: + return _make_conversation_response(conversation_id=conversation_id, module_id=module_id) + + async def get_conversation_history(self, user_id: str, module_id: str, + conversation_id: str) -> CareerReadinessConversationResponse: + return _make_conversation_response(conversation_id=conversation_id, module_id=module_id) + + async def get_quiz(self, user_id: str, module_id: str, + conversation_id: str) -> QuizResponse: + return QuizResponse(questions=[ + QuizQuestionResponse(question="Q1?", options=["A. Opt1", "B. Opt2"]), + ]) + + async def submit_quiz(self, user_id: str, module_id: str, + conversation_id: str, answers: dict[int, str]) -> QuizSubmissionResponse: + return QuizSubmissionResponse( + score=1, total=1, passed=True, + question_results=[QuizQuestionResult(question_index=1, is_correct=True)], + module_completed=True, + conversation_mode=ConversationMode.SUPPORT, + ) + + async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: + return None + + mock_service = MockService() + mock_auth = MockAuth() + + app = FastAPI() + app.dependency_overrides[get_career_readiness_service] = lambda: mock_service + + add_career_readiness_routes(app, authentication=mock_auth) + + yield TestClient(app), mock_service, mock_auth.mocked_user + app.dependency_overrides = {} + + +class TestListModules: + """Tests for GET /career-readiness/modules.""" + + def test_returns_200_with_modules(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN modules are listed + response = client.get("/career-readiness/modules") + + # THEN 200 OK is returned with modules + assert response.status_code == HTTPStatus.OK + body = response.json() + assert len(body["modules"]) == 1 + assert body["modules"][0]["id"] == "cv-development" + + +class TestGetModule: + """Tests for GET /career-readiness/modules/{module_id}.""" + + def test_returns_200_with_module_detail(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a module is requested + response = client.get("/career-readiness/modules/cv-development") + + # THEN 200 OK is returned + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["id"] == "cv-development" + + def test_returns_404_when_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises CareerReadinessModuleNotFoundError + mocker.patch.object(mock_service, "get_module", side_effect=CareerReadinessModuleNotFoundError("nonexistent")) + + # WHEN the module is requested + response = client.get("/career-readiness/modules/nonexistent") + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestCreateConversation: + """Tests for POST /career-readiness/modules/{module_id}/conversations.""" + + def test_returns_201_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a conversation is created + response = client.post("/career-readiness/modules/cv-development/conversations") + + # THEN 201 CREATED is returned + assert response.status_code == HTTPStatus.CREATED + body = response.json() + assert body["module_id"] == "cv-development" + assert len(body["messages"]) == 1 + + def test_returns_404_when_module_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises CareerReadinessModuleNotFoundError + mocker.patch.object(mock_service, "create_conversation", side_effect=CareerReadinessModuleNotFoundError("nonexistent")) + + # WHEN a conversation is created + response = client.post("/career-readiness/modules/nonexistent/conversations") + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_returns_409_when_already_exists(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, mocked_user = client_with_mocks + + # GIVEN the service raises ConversationAlreadyExistsError + mocker.patch.object(mock_service, "create_conversation", + side_effect=ConversationAlreadyExistsError("cv-development", mocked_user.user_id)) + + # WHEN a conversation is created + response = client.post("/career-readiness/modules/cv-development/conversations") + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT + + +class TestSendMessage: + """Tests for POST /career-readiness/modules/{module_id}/conversations/{conversation_id}/messages.""" + + def test_returns_201_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a message is sent + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + json={"user_input": "How do I write a CV?"}, + ) + + # THEN 201 CREATED is returned + assert response.status_code == HTTPStatus.CREATED + + def test_returns_404_when_conversation_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationNotFoundError + mocker.patch.object(mock_service, "send_message", side_effect=ConversationNotFoundError("conv123")) + + # WHEN a message is sent + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + json={"user_input": "Hello"}, + ) + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_returns_413_when_message_too_long(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # GIVEN a message exceeding the maximum length + given_long_message = "a" * (MAX_MESSAGE_LENGTH + 1) + + # WHEN the long message is sent + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + json={"user_input": given_long_message}, + ) + + # THEN 413 REQUEST_ENTITY_TOO_LARGE is returned + assert response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + + +class TestGetConversationHistory: + """Tests for GET /career-readiness/modules/{module_id}/conversations/{conversation_id}/messages.""" + + def test_returns_200_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN the conversation history is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + ) + + # THEN 200 OK is returned + assert response.status_code == HTTPStatus.OK + + def test_returns_404_when_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationNotFoundError + mocker.patch.object(mock_service, "get_conversation_history", + side_effect=ConversationNotFoundError("conv123")) + + # WHEN the conversation history is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + ) + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestDeleteConversation: + """Tests for DELETE /career-readiness/modules/{module_id}/conversations/{conversation_id}.""" + + def test_returns_204_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a conversation is deleted + response = client.delete( + "/career-readiness/modules/cv-development/conversations/conv123", + ) + + # THEN 204 NO_CONTENT is returned + assert response.status_code == HTTPStatus.NO_CONTENT + + def test_returns_404_when_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationNotFoundError + mocker.patch.object(mock_service, "delete_conversation", + side_effect=ConversationNotFoundError("conv123")) + + # WHEN the conversation is deleted + response = client.delete( + "/career-readiness/modules/cv-development/conversations/conv123", + ) + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "delete_conversation", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN the conversation is deleted + response = client.delete( + "/career-readiness/modules/cv-development/conversations/conv123", + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + + +class TestGetQuiz: + """Tests for GET /career-readiness/modules/{module_id}/conversations/{conversation_id}/quiz.""" + + def test_returns_200_with_questions_and_no_correct_answer(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN the quiz is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + ) + + # THEN 200 OK is returned with quiz questions + assert response.status_code == HTTPStatus.OK + body = response.json() + assert len(body["questions"]) == 1 + assert body["questions"][0]["question"] == "Q1?" + # AND correct_answer is not present in the response + assert "correct_answer" not in body["questions"][0] + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "get_quiz", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN the quiz is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + + def test_returns_409_when_not_available(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises QuizNotAvailableError + mocker.patch.object(mock_service, "get_quiz", + side_effect=QuizNotAvailableError("conv123")) + + # WHEN the quiz is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + ) + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT + + +class TestSubmitQuiz: + """Tests for POST /career-readiness/modules/{module_id}/conversations/{conversation_id}/quiz.""" + + def test_returns_200_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 200 OK is returned with results + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["passed"] is True + assert body["score"] == 1 + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "submit_quiz", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + + def test_returns_409_when_not_available(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises QuizNotAvailableError + mocker.patch.object(mock_service, "submit_quiz", + side_effect=QuizNotAvailableError("conv123")) + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT + + def test_returns_409_when_already_passed(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises QuizAlreadyPassedError + mocker.patch.object(mock_service, "submit_quiz", + side_effect=QuizAlreadyPassedError("conv123")) + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT diff --git a/backend/app/career_readiness/test_service.py b/backend/app/career_readiness/test_service.py new file mode 100644 index 00000000..e2cc546f --- /dev/null +++ b/backend/app/career_readiness/test_service.py @@ -0,0 +1,969 @@ +""" +Tests for the career readiness service layer. +""" +from datetime import datetime, timezone + +import pytest +from bson import ObjectId + +from app.agent.agent_types import AgentOutput, AgentType +from app.career_readiness.agent import CareerReadinessAgentOutput +from app.career_readiness.errors import ( + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationNotFoundError, + CareerReadinessModuleNotFoundError, + ModuleNotUnlockedError, + QuizAlreadyPassedError, + QuizNotAvailableError, +) +from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry, QuizConfig, QuizQuestion +from app.career_readiness.repository import ICareerReadinessConversationRepository +from app.career_readiness.service import ( + CareerReadinessService, + _build_conversation_context, + _derive_module_statuses, + _evaluate_quiz, +) +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessMessage, + CareerReadinessMessageSender, + ConversationMode, + ModuleStatus, +) + + +# --------------------------------------------------------------------------- +# Quiz helpers +# --------------------------------------------------------------------------- + +def _make_quiz_questions() -> list[QuizQuestion]: + return [ + QuizQuestion(question="Q1?", options=["A. Opt1", "B. Opt2", "C. Opt3", "D. Opt4"], correct_answer="A"), + QuizQuestion(question="Q2?", options=["A. Opt1", "B. Opt2", "C. Opt3", "D. Opt4"], correct_answer="B"), + ] + + +def _make_quiz_config() -> QuizConfig: + return QuizConfig(pass_threshold=0.5, questions=_make_quiz_questions()) + + +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + +def _make_module_config( + module_id: str = "cv-development", + title: str = "CV Development", + sort_order: int = 1, + topics: list[str] | None = None, + quiz: QuizConfig | None = None, +) -> ModuleConfig: + return ModuleConfig( + id=module_id, + title=title, + description="A test module.", + icon="cv", + sort_order=sort_order, + input_placeholder="Ask about CVs...", + content="# CV Content\nWrite your CV.", + topics=topics or ["Topic A", "Topic B"], + quiz=quiz or _make_quiz_config(), + ) + + +def _make_message( + sender: CareerReadinessMessageSender = CareerReadinessMessageSender.AGENT, + message: str = "Hello", +) -> CareerReadinessMessage: + return CareerReadinessMessage( + message_id=str(ObjectId()), + message=message, + sender=sender, + sent_at=datetime.now(timezone.utc), + ) + + +def _make_conversation( + user_id: str = "test_user", + module_id: str = "cv-development", + messages: list[CareerReadinessMessage] | None = None, + conversation_mode: ConversationMode = ConversationMode.INSTRUCTION, + covered_topics: list[str] | None = None, + quiz_delivered: bool = False, + quiz_passed: bool = False, +) -> CareerReadinessConversationDocument: + now = datetime.now(timezone.utc) + return CareerReadinessConversationDocument( + conversation_id=str(ObjectId()), + module_id=module_id, + user_id=user_id, + messages=messages or [_make_message()], + conversation_mode=conversation_mode, + covered_topics=covered_topics or [], + quiz_delivered=quiz_delivered, + quiz_passed=quiz_passed, + created_at=now, + updated_at=now, + ) + + +class MockRepository(ICareerReadinessConversationRepository): + """In-memory mock repository for testing.""" + + def __init__(self): + self._conversations: dict[str, CareerReadinessConversationDocument] = {} + + async def create(self, document: CareerReadinessConversationDocument) -> None: + self._conversations[document.conversation_id] = document + + async def find_by_conversation_id(self, conversation_id: str) -> CareerReadinessConversationDocument | None: + return self._conversations.get(conversation_id) + + async def find_by_user_and_module(self, user_id: str, module_id: str) -> CareerReadinessConversationDocument | None: + for conv in self._conversations.values(): + if conv.user_id == user_id and conv.module_id == module_id: + return conv + return None + + async def find_all_by_user(self, user_id: str) -> list[CareerReadinessConversationDocument]: + return [conv for conv in self._conversations.values() if conv.user_id == user_id] + + async def append_message(self, conversation_id: str, message: CareerReadinessMessage) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.messages.append(message) + conv.updated_at = datetime.now(timezone.utc) + + async def update_covered_topics(self, conversation_id: str, topics: list[str]) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.covered_topics = topics + conv.updated_at = datetime.now(timezone.utc) + + async def update_quiz_delivered(self, conversation_id: str, delivered: bool) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.quiz_delivered = delivered + conv.updated_at = datetime.now(timezone.utc) + + async def update_quiz_passed(self, conversation_id: str, passed: bool) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.quiz_passed = passed + conv.updated_at = datetime.now(timezone.utc) + + async def update_conversation_mode(self, conversation_id: str, mode: ConversationMode) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.conversation_mode = mode + conv.updated_at = datetime.now(timezone.utc) + + async def delete_by_conversation_id(self, conversation_id: str) -> bool: + if conversation_id in self._conversations: + del self._conversations[conversation_id] + return True + return False + + +class MockModuleRegistry(ModuleRegistry): + """Module registry that uses in-memory modules instead of loading from disk.""" + + def __init__(self, modules: list[ModuleConfig] | None = None): + # Skip parent __init__ to avoid loading from disk + self._modules: dict[str, ModuleConfig] = {} + for m in (modules or []): + self._modules[m.id] = m + + +def _make_mock_agent_output( + message: str = "Agent response", + finished: bool = False, + topics_covered: list[str] | None = None, +) -> CareerReadinessAgentOutput: + agent_output = AgentOutput( + message_id=str(ObjectId()), + message_for_user=message, + finished=finished, + agent_type=AgentType.CAREER_READINESS_AGENT, + agent_response_time_in_sec=0.5, + llm_stats=[], + sent_at=datetime.now(timezone.utc), + ) + return CareerReadinessAgentOutput( + agent_output=agent_output, + topics_covered=topics_covered or [], + ) + + +class MockCareerReadinessAgent: + """Mock agent that returns canned responses without calling an LLM.""" + + def __init__(self, intro_message: str = "Welcome!", response_message: str = "Agent response", + topics_covered: list[str] | None = None, finished: bool = False): + self._intro_message = intro_message + self._response_message = response_message + self._topics_covered = topics_covered or [] + self._finished = finished + + async def generate_intro_message(self, context): + return _make_mock_agent_output(message=self._intro_message) + + async def execute(self, user_input, context): + return _make_mock_agent_output( + message=self._response_message, + finished=self._finished, + topics_covered=self._topics_covered, + ) + + +def _make_mock_agent_factory(agent: MockCareerReadinessAgent | None = None): + """Factory that returns a mock agent regardless of mode.""" + mock_agent = agent or MockCareerReadinessAgent() + + def factory(module_config, mode): + return mock_agent + + return factory + + +def _make_service( + modules: list[ModuleConfig] | None = None, + repository: MockRepository | None = None, + agent: MockCareerReadinessAgent | None = None, +) -> tuple[CareerReadinessService, MockRepository]: + """Helper to create a service with mocked dependencies.""" + repo = repository or MockRepository() + registry = MockModuleRegistry(modules or [_make_module_config()]) + factory = _make_mock_agent_factory(agent) + service = CareerReadinessService(repository=repo, module_registry=registry, agent_factory=factory) + return service, repo + + +# --------------------------------------------------------------------------- +# Tests — Pure functions +# --------------------------------------------------------------------------- + +class TestDeriveModuleStatuses: + """Tests for the _derive_module_statuses function.""" + + def test_first_module_unlocked_when_no_conversations(self): + # GIVEN three modules and no conversations + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + _make_module_config(module_id="m3", sort_order=3), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, []) + + # THEN the first module is UNLOCKED and the rest are NOT_STARTED + assert actual_statuses["m1"] == ModuleStatus.UNLOCKED + assert actual_statuses["m2"] == ModuleStatus.NOT_STARTED + assert actual_statuses["m3"] == ModuleStatus.NOT_STARTED + + def test_second_module_unlocked_when_first_completed(self): + # GIVEN three modules and the first one completed + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + _make_module_config(module_id="m3", sort_order=3), + ] + given_conversations = [ + _make_conversation(module_id="m1", quiz_passed=True), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, given_conversations) + + # THEN the first is COMPLETED, second is UNLOCKED, third is NOT_STARTED + assert actual_statuses["m1"] == ModuleStatus.COMPLETED + assert actual_statuses["m2"] == ModuleStatus.UNLOCKED + assert actual_statuses["m3"] == ModuleStatus.NOT_STARTED + + def test_chain_completion_unlocks_next(self): + # GIVEN three modules with first two completed + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + _make_module_config(module_id="m3", sort_order=3), + ] + given_conversations = [ + _make_conversation(module_id="m1", quiz_passed=True), + _make_conversation(module_id="m2", quiz_passed=True), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, given_conversations) + + # THEN third module is UNLOCKED + assert actual_statuses["m1"] == ModuleStatus.COMPLETED + assert actual_statuses["m2"] == ModuleStatus.COMPLETED + assert actual_statuses["m3"] == ModuleStatus.UNLOCKED + + def test_in_progress_module_blocks_next(self): + # GIVEN first module in progress (has conversation but quiz not passed) + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + ] + given_conversations = [ + _make_conversation(module_id="m1", quiz_passed=False), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, given_conversations) + + # THEN first is IN_PROGRESS, second is NOT_STARTED + assert actual_statuses["m1"] == ModuleStatus.IN_PROGRESS + assert actual_statuses["m2"] == ModuleStatus.NOT_STARTED + + +class TestEvaluateQuiz: + """Tests for the quiz evaluator.""" + + def test_all_correct(self): + # GIVEN a quiz and all correct answers + given_quiz = _make_quiz_config() + given_answers = {1: "A", 2: "B"} + + # WHEN evaluated + actual_score, actual_total, actual_results = _evaluate_quiz(given_quiz, given_answers) + + # THEN all are correct + assert actual_score == 2 + assert actual_total == 2 + assert actual_results == [True, True] + + def test_partial_correct(self): + # GIVEN a quiz with one wrong answer + given_quiz = _make_quiz_config() + given_answers = {1: "A", 2: "C"} + + # WHEN evaluated + actual_score, actual_total, actual_results = _evaluate_quiz(given_quiz, given_answers) + + # THEN score is 1 out of 2 + assert actual_score == 1 + assert actual_total == 2 + assert actual_results == [True, False] + + def test_missing_answers_counted_as_wrong(self): + # GIVEN a quiz with only one answer provided (question 2 missing) + given_quiz = _make_quiz_config() + given_answers = {1: "A"} + + # WHEN evaluated + actual_score, actual_total, actual_results = _evaluate_quiz(given_quiz, given_answers) + + # THEN the missing answer is counted as wrong + assert actual_score == 1 + assert actual_total == 2 + assert actual_results == [True, False] + + +# --------------------------------------------------------------------------- +# Tests — Service methods +# --------------------------------------------------------------------------- + +class TestBuildConversationContext: + """Tests for the _build_conversation_context helper.""" + + def test_builds_context_from_silence_intro_and_user_agent_pair(self): + # GIVEN messages with silence+intro pair followed by user+agent pair + given_messages = [ + _make_message(sender=CareerReadinessMessageSender.USER, message="(silence)"), + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), + _make_message(sender=CareerReadinessMessageSender.USER, message="Help me"), + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Sure!"), + ] + + # WHEN the context is built + actual_context = _build_conversation_context(given_messages) + + # THEN there are two conversation turns (silence+intro and user+agent) + assert len(actual_context.history.turns) == 2 + assert actual_context.history.turns[0].input.message == "(silence)" + assert actual_context.history.turns[0].output.message_for_user == "Welcome!" + assert actual_context.history.turns[1].input.message == "Help me" + assert actual_context.history.turns[1].output.message_for_user == "Sure!" + + def test_builds_context_from_silence_intro_only(self): + # GIVEN only a silence+intro pair + given_messages = [ + _make_message(sender=CareerReadinessMessageSender.USER, message="(silence)"), + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), + ] + + # WHEN the context is built + actual_context = _build_conversation_context(given_messages) + + # THEN there is one turn (the intro) + assert len(actual_context.history.turns) == 1 + assert actual_context.history.turns[0].input.message == "(silence)" + assert actual_context.history.turns[0].output.message_for_user == "Welcome!" + + +class TestListModules: + """Tests for listing modules with sequential unlock status.""" + + @pytest.mark.asyncio + async def test_first_module_unlocked_rest_not_started(self): + # GIVEN a service with two modules and no conversations + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", title="Module 2", sort_order=2), + ] + service, _ = _make_service(modules=given_modules) + + # WHEN modules are listed + actual_result = await service.list_modules("user_abc") + + # THEN first is UNLOCKED, second is NOT_STARTED + assert actual_result.modules[0].status == ModuleStatus.UNLOCKED + assert actual_result.modules[1].status == ModuleStatus.NOT_STARTED + + @pytest.mark.asyncio + async def test_returns_in_progress_when_conversation_exists(self): + # GIVEN a service with a module and an existing conversation + given_module = _make_module_config(module_id="cv-development") + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN modules are listed for the user + actual_result = await service.list_modules("user_abc") + + # THEN the module with a conversation has IN_PROGRESS status + assert actual_result.modules[0].status == ModuleStatus.IN_PROGRESS + + + +class TestGetModule: + """Tests for getting module details.""" + + @pytest.mark.asyncio + async def test_returns_module_detail(self): + # GIVEN a service with a module + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN the module is retrieved + actual_result = await service.get_module("user_abc", "cv-development") + + # THEN the module detail is returned with UNLOCKED status + assert actual_result.id == "cv-development" + assert actual_result.status == ModuleStatus.UNLOCKED + + @pytest.mark.asyncio + async def test_returns_active_conversation_id(self): + # GIVEN a service with an existing conversation + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN the module is retrieved + actual_result = await service.get_module("user_abc", "cv-development") + + # THEN the active conversation ID is included + assert actual_result.active_conversation_id == given_conversation.conversation_id + assert actual_result.status == ModuleStatus.IN_PROGRESS + + @pytest.mark.asyncio + async def test_raises_module_not_found(self): + # GIVEN a service with no modules + service, _ = _make_service(modules=[]) + + # WHEN a non-existent module is requested + # THEN CareerReadinessModuleNotFoundError is raised + with pytest.raises(CareerReadinessModuleNotFoundError): + await service.get_module("user_abc", "nonexistent") + + +class TestCreateConversation: + """Tests for creating a new conversation.""" + + @pytest.mark.asyncio + async def test_creates_conversation_with_intro_message(self): + # GIVEN a service with a module and a mock agent + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent(intro_message="Welcome to CV Development!") + service, repo = _make_service(modules=[given_module], agent=given_agent) + + # WHEN a conversation is created + actual_result = await service.create_conversation("user_abc", "cv-development") + + # THEN a conversation response is returned with the intro message (silence filtered out) + assert actual_result.module_id == "cv-development" + assert len(actual_result.messages) == 1 + assert actual_result.messages[0].message == "Welcome to CV Development!" + assert actual_result.conversation_mode == ConversationMode.INSTRUCTION + # AND the DB stores both the silence and intro messages + actual_doc = await repo.find_by_conversation_id(actual_result.conversation_id) + assert actual_doc is not None + assert len(actual_doc.messages) == 2 + assert actual_doc.messages[0].message == "(silence)" + assert actual_doc.messages[1].message == "Welcome to CV Development!" + + @pytest.mark.asyncio + async def test_raises_already_exists_when_duplicate(self): + # GIVEN a service with an existing conversation + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a duplicate conversation is attempted + # THEN ConversationAlreadyExistsError is raised + with pytest.raises(ConversationAlreadyExistsError): + await service.create_conversation("user_abc", "cv-development") + + @pytest.mark.asyncio + async def test_raises_module_not_found(self): + # GIVEN a service with no modules + service, _ = _make_service(modules=[]) + + # WHEN a conversation is created for a non-existent module + # THEN CareerReadinessModuleNotFoundError is raised + with pytest.raises(CareerReadinessModuleNotFoundError): + await service.create_conversation("user_abc", "nonexistent") + + @pytest.mark.asyncio + async def test_raises_not_unlocked_for_locked_module(self): + # GIVEN two modules where the second is locked (first not completed) + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", title="Module 2", sort_order=2), + ] + service, _ = _make_service(modules=given_modules) + + # WHEN a conversation is created for the locked second module + # THEN ModuleNotUnlockedError is raised + with pytest.raises(ModuleNotUnlockedError): + await service.create_conversation("user_abc", "m2") + + +class TestSendMessage: + """Tests for sending messages with mode dispatch.""" + + @pytest.mark.asyncio + async def test_instruction_mode_normal_response(self): + # GIVEN a service with a conversation in instruction mode + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent( + response_message="Let's discuss Topic A.", + topics_covered=["Topic A"], + ) + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a message is sent + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "Tell me about Topic A", + ) + + # THEN the response includes the user message and agent response + assert actual_result.messages[-1].message == "Let's discuss Topic A." + # AND topics are accumulated in the conversation + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert "Topic A" in actual_conv.covered_topics + + @pytest.mark.asyncio + async def test_instruction_mode_triggers_quiz_when_all_topics_covered_and_finished(self): + # GIVEN a conversation where all topics are already covered except the agent says finished this turn + given_module = _make_module_config(topics=["Topic A", "Topic B"]) + given_agent = MockCareerReadinessAgent( + response_message="Great, we've covered everything!", + topics_covered=["Topic B"], + finished=True, + ) + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + covered_topics=["Topic A"], + ) + await repo.create(given_conversation) + + # WHEN a message is sent + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "I understand now", + ) + + # THEN quiz_available is True in the response + assert actual_result.quiz_available is True + # AND the last message is a marker indicating quiz is ready + assert "quiz" in actual_result.messages[-1].message.lower() + # AND quiz_delivered is set on the conversation + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_delivered is True + + @pytest.mark.asyncio + async def test_chat_during_active_quiz_sets_quiz_available(self): + # GIVEN a conversation with quiz delivered but not passed + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent(response_message="Let me help you with that.") + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN the user sends a chat message while quiz is active + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "Can you explain more?", + ) + + # THEN the response includes quiz_available=True + assert actual_result.quiz_available is True + # AND the agent still responds normally + assert actual_result.messages[-1].message == "Let me help you with that." + + @pytest.mark.asyncio + async def test_support_mode_message(self): + # GIVEN a completed conversation in support mode + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent(response_message="Here's more info on that topic.") + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + conversation_mode=ConversationMode.SUPPORT, + quiz_passed=True, + ) + await repo.create(given_conversation) + + # WHEN a message is sent in support mode + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "Can you explain more?", + ) + + # THEN the agent responds normally + assert actual_result.messages[-1].message == "Here's more info on that topic." + assert actual_result.conversation_mode == ConversationMode.SUPPORT + assert actual_result.quiz_passed is True + + @pytest.mark.asyncio + async def test_raises_conversation_not_found(self): + # GIVEN a service with a module but no conversation + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN a message is sent to a non-existent conversation + # THEN ConversationNotFoundError is raised + with pytest.raises(ConversationNotFoundError): + await service.send_message("user_abc", "cv-development", "nonexistent", "Hello") + + @pytest.mark.asyncio + async def test_raises_access_denied_for_wrong_user(self): + # GIVEN a conversation owned by user_abc + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a different user tries to send a message + # THEN ConversationAccessDeniedError is raised + with pytest.raises(ConversationAccessDeniedError): + await service.send_message( + "other_user", "cv-development", given_conversation.conversation_id, "Hello", + ) + + +class TestGetConversationHistory: + """Tests for retrieving conversation history.""" + + @pytest.mark.asyncio + async def test_returns_conversation_history(self): + # GIVEN a conversation with messages + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_messages = [ + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), + _make_message(sender=CareerReadinessMessageSender.USER, message="Hello!"), + ] + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development", messages=given_messages) + await repo.create(given_conversation) + + # WHEN the history is requested + actual_result = await service.get_conversation_history( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN all messages are returned + assert len(actual_result.messages) == 2 + assert actual_result.conversation_id == given_conversation.conversation_id + + @pytest.mark.asyncio + async def test_raises_conversation_not_found(self): + # GIVEN a service with no conversations + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN a non-existent conversation is requested + # THEN ConversationNotFoundError is raised + with pytest.raises(ConversationNotFoundError): + await service.get_conversation_history("user_abc", "cv-development", "nonexistent") + + @pytest.mark.asyncio + async def test_returns_quiz_passed_false_when_quiz_delivered_but_not_passed(self): + # GIVEN a conversation where the quiz was delivered but not yet passed + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, quiz_passed=False, + ) + await repo.create(given_conversation) + + # WHEN the history is requested + actual_result = await service.get_conversation_history( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN quiz_passed is False (not None) + assert actual_result.quiz_passed is False + # AND quiz_available is True + assert actual_result.quiz_available is True + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "quiz_delivered, quiz_passed, expected_quiz_available", + [ + (True, False, True), + (False, False, False), + (True, True, False), + ], + ids=["delivered-not-passed", "not-delivered", "already-passed"], + ) + async def test_quiz_available_reflects_quiz_state(self, quiz_delivered, quiz_passed, expected_quiz_available): + # GIVEN a conversation with given quiz state + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=quiz_delivered, quiz_passed=quiz_passed, + ) + await repo.create(given_conversation) + + # WHEN the history is requested + actual_result = await service.get_conversation_history( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN quiz_available matches the expected value + assert actual_result.quiz_available is expected_quiz_available + + @pytest.mark.asyncio + async def test_raises_access_denied_for_wrong_user(self): + # GIVEN a conversation owned by user_abc + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a different user requests the history + # THEN ConversationAccessDeniedError is raised + with pytest.raises(ConversationAccessDeniedError): + await service.get_conversation_history( + "other_user", "cv-development", given_conversation.conversation_id, + ) + + +class TestDeleteConversation: + """Tests for deleting a conversation.""" + + @pytest.mark.asyncio + async def test_deletes_conversation(self): + # GIVEN a conversation + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN the conversation is deleted + await service.delete_conversation("user_abc", "cv-development", given_conversation.conversation_id) + + # THEN the conversation no longer exists + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is None + + @pytest.mark.asyncio + async def test_raises_conversation_not_found(self): + # GIVEN a service with no conversations + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN a non-existent conversation is deleted + # THEN ConversationNotFoundError is raised + with pytest.raises(ConversationNotFoundError): + await service.delete_conversation("user_abc", "cv-development", "nonexistent") + + @pytest.mark.asyncio + async def test_raises_access_denied_for_wrong_user(self): + # GIVEN a conversation owned by user_abc + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a different user tries to delete + # THEN ConversationAccessDeniedError is raised + with pytest.raises(ConversationAccessDeniedError): + await service.delete_conversation( + "other_user", "cv-development", given_conversation.conversation_id, + ) + + +class TestGetQuiz: + """Tests for retrieving quiz questions.""" + + @pytest.mark.asyncio + async def test_returns_questions_without_correct_answers(self): + # GIVEN a conversation with quiz delivered + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN the quiz is requested + actual_result = await service.get_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN questions are returned with options + assert len(actual_result.questions) == 2 + assert actual_result.questions[0].question == "Q1?" + assert actual_result.questions[0].options == ["A. Opt1", "B. Opt2", "C. Opt3", "D. Opt4"] + # AND no correct_answer field is exposed + assert not hasattr(actual_result.questions[0], "correct_answer") + + @pytest.mark.asyncio + async def test_raises_not_available_when_quiz_not_delivered(self): + # GIVEN a conversation where the quiz has not been delivered + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=False, quiz_passed=False, + ) + await repo.create(given_conversation) + + # WHEN the quiz is requested + # THEN QuizNotAvailableError is raised + with pytest.raises(QuizNotAvailableError): + await service.get_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + @pytest.mark.asyncio + async def test_raises_already_passed_when_quiz_completed(self): + # GIVEN a conversation where the quiz was already passed + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, quiz_passed=True, + ) + await repo.create(given_conversation) + + # WHEN the quiz is requested + # THEN QuizAlreadyPassedError is raised + with pytest.raises(QuizAlreadyPassedError): + await service.get_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + +class TestSubmitQuiz: + """Tests for submitting quiz answers.""" + + @pytest.mark.asyncio + async def test_pass_transitions_to_support(self): + # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN correct answers are submitted + actual_result = await service.submit_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, {1: "A", 2: "B"}, + ) + + # THEN the quiz is passed + assert actual_result.passed is True + assert actual_result.score == 2 + assert actual_result.total == 2 + assert actual_result.module_completed is True + assert actual_result.conversation_mode == ConversationMode.SUPPORT + # AND per-question results are correct + assert all(r.is_correct for r in actual_result.question_results) + # AND the conversation state is updated + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_passed is True + assert actual_conv.conversation_mode == ConversationMode.SUPPORT + + @pytest.mark.asyncio + async def test_fail_keeps_quiz_available_for_retry(self): + # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN all wrong answers are submitted + actual_result = await service.submit_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, {1: "C", 2: "C"}, + ) + + # THEN the quiz is not passed + assert actual_result.passed is False + assert actual_result.score == 0 + assert actual_result.module_completed is False + assert actual_result.conversation_mode == ConversationMode.INSTRUCTION + # AND quiz_delivered stays active for retry + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_delivered is True + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "quiz_delivered, quiz_passed, expected_error", + [ + (False, False, QuizNotAvailableError), + (True, True, QuizAlreadyPassedError), + ], + ids=["not-delivered", "already-passed"], + ) + async def test_raises_not_available_when_quiz_not_active(self, quiz_delivered, quiz_passed, expected_error): + # GIVEN a conversation where the quiz is not active + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=quiz_delivered, quiz_passed=quiz_passed, + ) + await repo.create(given_conversation) + + # WHEN answers are submitted + # THEN the expected error is raised + with pytest.raises(expected_error): + await service.submit_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, {1: "A", 2: "B"}, + ) diff --git a/backend/app/career_readiness/types.py b/backend/app/career_readiness/types.py new file mode 100644 index 00000000..8a75faeb --- /dev/null +++ b/backend/app/career_readiness/types.py @@ -0,0 +1,324 @@ +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator + + +class ModuleStatus(str, Enum): + NOT_STARTED = "NOT_STARTED" + UNLOCKED = "UNLOCKED" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + + +class ConversationMode(str, Enum): + INSTRUCTION = "INSTRUCTION" + SUPPORT = "SUPPORT" + + +class CareerReadinessMessageSender(str, Enum): + USER = "USER" + AGENT = "AGENT" + + +class ModuleSummary(BaseModel): + """ + Summary of a career readiness module, used in listing endpoints. + """ + + id: str + """The unique identifier (slug) of the module, e.g. 'cv-resume-creation'""" + + title: str + """The display title of the module""" + + description: str + """A short description of what the module covers""" + + icon: str + """Icon identifier for the module""" + + status: ModuleStatus + """The user's current progress status for this module""" + + sort_order: int + """Display order of the module in the list""" + + input_placeholder: str + """Placeholder text shown in the chat input for this module""" + + class Config: + extra = "forbid" + + +class ModuleDetail(BaseModel): + """ + Detailed view of a career readiness module, including active conversation info. + """ + + id: str + """The unique identifier (slug) of the module""" + + title: str + """The display title of the module""" + + description: str + """A short description of what the module covers""" + + icon: str + """Icon identifier for the module""" + + status: ModuleStatus + """The user's current progress status for this module""" + + sort_order: int + """Display order of the module in the list""" + + input_placeholder: str + """Placeholder text shown in the chat input for this module""" + + scope: str + """The full scope/content description of the module""" + + active_conversation_id: str | None = None + """The ID of the active conversation for this module, if one exists""" + + class Config: + extra = "forbid" + + +class ModuleListResponse(BaseModel): + """ + Response containing the list of all career readiness modules. + """ + + modules: list[ModuleSummary] + """The list of available modules with user progress""" + + class Config: + extra = "forbid" + + +class CareerReadinessMessage(BaseModel): + """ + Represents a single message in a career readiness conversation. + """ + + message_id: str + """The unique id of the message""" + + message: str + """The message content""" + + sent_at: datetime + """The time the message was sent, in ISO format, in UTC""" + + sender: CareerReadinessMessageSender + """The sender of the message, either USER or AGENT""" + + @field_serializer('sent_at') + def _serialize_sent_at(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @field_serializer("sender") + def _serialize_sender(self, sender: CareerReadinessMessageSender, _info) -> str: + return sender.name + + @classmethod + @field_validator("sender", mode='before') + def _deserialize_sender(cls, value: str | CareerReadinessMessageSender) -> CareerReadinessMessageSender: + if isinstance(value, str): + return CareerReadinessMessageSender[value] + elif isinstance(value, CareerReadinessMessageSender): + return value + else: + raise ValueError(f"Invalid message sender: {value}") + + class Config: + extra = "forbid" + + +class CareerReadinessConversationResponse(BaseModel): + """ + Response for a career readiness conversation, including messages and completion status. + """ + + conversation_id: str + """The unique id of the conversation""" + + module_id: str + """The module this conversation belongs to""" + + messages: list[CareerReadinessMessage] + """The messages in the conversation""" + + module_completed: bool = False + """Whether the module has been completed through this conversation""" + + quiz_passed: bool | None = None + """Whether the user passed the module quiz. None = not attempted.""" + + covered_topics: list[str] = [] + """Topics that have been covered so far in this conversation.""" + + conversation_mode: ConversationMode | None = None + """The current conversation mode (INSTRUCTION or SUPPORT).""" + + quiz_available: bool = False + """Whether the quiz is available for this conversation (quiz delivered but not yet passed).""" + + class Config: + extra = "forbid" + + +class QuizQuestionResponse(BaseModel): + """A quiz question for the frontend (excludes correct_answer).""" + + question: str + options: list[str] + + class Config: + extra = "forbid" + + +class QuizResponse(BaseModel): + """Response for GET .../quiz — the quiz questions.""" + + questions: list[QuizQuestionResponse] + + class Config: + extra = "forbid" + + +class QuizSubmissionInput(BaseModel): + """Input for POST .../quiz — structured quiz answers.""" + + answers: dict[int, str] = Field( + json_schema_extra={ + "example": {"1": "B", "2": "A", "3": "C"}, + }, + ) + """question_number (1-indexed) → answer letter (A-D)""" + + class Config: + extra = "forbid" + + @model_validator(mode="after") + def _validate_answers(self) -> "QuizSubmissionInput": + valid_letters = {"A", "B", "C", "D"} + normalized: dict[int, str] = {} + for key, value in self.answers.items(): + upper = value.upper() + if upper not in valid_letters: + raise ValueError(f"Invalid answer '{value}' for question {key}. Must be A-D.") + normalized[key] = upper + self.answers = normalized + return self + + +class QuizQuestionResult(BaseModel): + """Per-question result (no correct answer exposed).""" + + question_index: int + """1-indexed question number""" + + is_correct: bool + + class Config: + extra = "forbid" + + +class QuizSubmissionResponse(BaseModel): + """Response for POST .../quiz — evaluation results.""" + + score: int + total: int + passed: bool + question_results: list[QuizQuestionResult] + module_completed: bool + conversation_mode: ConversationMode + + class Config: + extra = "forbid" + + +class CareerReadinessConversationInput(BaseModel): + """ + Input for sending a message in a career readiness conversation. + """ + + user_input: str + """The user input""" + + class Config: + extra = "forbid" + + +class CareerReadinessConversationDocument(BaseModel): + """ + Represents a career readiness conversation document in MongoDB. + """ + + conversation_id: str + """The unique identifier for the conversation""" + + module_id: str + """The module this conversation belongs to""" + + user_id: str + """The user who owns this conversation""" + + messages: list[CareerReadinessMessage] = Field(default_factory=list) + """The messages in the conversation""" + + conversation_mode: ConversationMode = ConversationMode.INSTRUCTION + """The current conversation mode (INSTRUCTION during teaching, SUPPORT after quiz pass)""" + + covered_topics: list[str] = Field(default_factory=list) + """Topics the agent has covered so far in the conversation""" + + quiz_delivered: bool = False + """Whether the quiz has been presented to the user""" + + quiz_passed: bool = False + """Whether the user passed the module quiz""" + + created_at: datetime + """When the conversation was created""" + + updated_at: datetime + """When the conversation was last updated""" + + @field_serializer("created_at", "updated_at") + def _serialize_datetime(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @classmethod + @field_validator("created_at", "updated_at", mode="before") + def _deserialize_datetime(cls, value: str | datetime) -> datetime: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + elif isinstance(value, datetime): + dt = value + else: + raise ValueError(f"Invalid datetime value: {value}") + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + @staticmethod + def from_dict(_dict: dict) -> "CareerReadinessConversationDocument": + """Convert a MongoDB document dictionary to a typed object.""" + return CareerReadinessConversationDocument( + conversation_id=str(_dict["conversation_id"]), + module_id=str(_dict["module_id"]), + user_id=str(_dict["user_id"]), + messages=[CareerReadinessMessage(**msg) for msg in _dict.get("messages", [])], + conversation_mode=ConversationMode(_dict.get("conversation_mode", ConversationMode.INSTRUCTION)), + covered_topics=_dict.get("covered_topics", []), + quiz_delivered=_dict.get("quiz_delivered", False), + quiz_passed=_dict.get("quiz_passed", False), + created_at=_dict["created_at"], + updated_at=_dict["updated_at"], + ) + + class Config: + extra = "forbid" diff --git a/backend/app/conversations/experience/_types.py b/backend/app/conversations/experience/_types.py index a6fd3399..f3f1dc5d 100644 --- a/backend/app/conversations/experience/_types.py +++ b/backend/app/conversations/experience/_types.py @@ -261,7 +261,7 @@ class UpdateExperienceRequest(BaseModel): description="The type of work. " "If omitted, not updated. If null, cleared. " "If an invalid value is provided, null will be saved.", - examples=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name], + examples=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name], max_length=max(len(e.name) for e in WorkType), json_schema_extra={"enum": [e.name for e in WorkType] + [None]} ) diff --git a/backend/app/conversations/experience/test_routes.py b/backend/app/conversations/experience/test_routes.py index b9baec23..c7c203ac 100644 --- a/backend/app/conversations/experience/test_routes.py +++ b/backend/app/conversations/experience/test_routes.py @@ -176,7 +176,7 @@ async def test_get_experiences_successful(self, authenticated_client_with_mocks: company="Foo Company", location="Foo Location", timeline=Timeline(start="2020-01-01", end="2021-01-01"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, top_skills=[ SkillEntity( id="", @@ -361,7 +361,7 @@ async def test_update_experience_successful(self, authenticated_client_with_mock company="company", location="location", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, top_skills=[], remaining_skills=[], summary="summary", @@ -609,7 +609,7 @@ async def test_get_unedited_experience_successful(self, authenticated_client_wit company="Unedited Company", location="Unedited Location", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, top_skills=[], summary="Unedited summary", ), DiveInPhase.PROCESSED) diff --git a/backend/app/conversations/experience/test_service.py b/backend/app/conversations/experience/test_service.py index 74b772c7..50f219ce 100644 --- a/backend/app/conversations/experience/test_service.py +++ b/backend/app/conversations/experience/test_service.py @@ -74,7 +74,7 @@ def _make_editable_experience_entity(uuid: str, title: str, skills=None) -> Edit company="fooCorp", location="fooVille", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, esco_occupations=[], questions_and_answers=[], summary="fooSummary", @@ -90,7 +90,7 @@ def _make_experience_entity(uuid: str, title: str, skills=None) -> ExperienceEnt company="fooCorp", location="fooVille", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, esco_occupations=[], questions_and_answers=[], summary="fooSummary", @@ -238,8 +238,8 @@ async def test_success(self, mock_metrics_recorder, mock_metrics_service): id="company_and_location" ), pytest.param( - UpdateExperienceRequest(work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, summary="New Sum"), - {"work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, "summary": "New Sum"}, + UpdateExperienceRequest(work_type=WorkType.UNSEEN_UNPAID.name, summary="New Sum"), + {"work_type": WorkType.UNSEEN_UNPAID, "summary": "New Sum"}, id="work_type_and_summary" ), pytest.param( diff --git a/backend/app/conversations/phase_data.py b/backend/app/conversations/phase_data.py index 1abb4c3c..8fc1c320 100644 --- a/backend/app/conversations/phase_data.py +++ b/backend/app/conversations/phase_data.py @@ -3,12 +3,19 @@ PhaseDataStatus drives determine_start_phase; no override or external store. """ +from typing import Sequence + from app.application_state import ApplicationState from app.conversations.phase_state_machine import JourneyPhase, PhaseDataStatus from app.agent.explore_experiences_agent_director import DiveInPhase from app.agent.agent_director.abstract_agent_director import ConversationPhase from app.user_recommendations.services.service import IUserRecommendationsService +CONVERSATION_ALLOWED_PHASES: Sequence[JourneyPhase] = ( + JourneyPhase.SKILLS_ELICITATION, + JourneyPhase.PREFERENCE_ELICITATION, +) + def conversation_phase_for_entry(journey_phase: JourneyPhase) -> ConversationPhase | None: """ diff --git a/backend/app/conversations/poc/poc_routes.py b/backend/app/conversations/poc/poc_routes.py index f9d63f1a..9748ccf7 100644 --- a/backend/app/conversations/poc/poc_routes.py +++ b/backend/app/conversations/poc/poc_routes.py @@ -312,7 +312,7 @@ async def _test_conversation(request: Request, user_input: str, clear_memory: bo if len(state.explore_experiences_director_state.experiences_state) == 0: experience_entity = ExperienceEntity(experience_title="Baker", company="Baker's and Sons", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT) + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK) from app.agent.explore_experiences_agent_director import ExperienceState from app.agent.explore_experiences_agent_director import DiveInPhase state.explore_experiences_director_state.current_experience_uuid = experience_entity.uuid diff --git a/backend/app/conversations/service.py b/backend/app/conversations/service.py index 195dfcc3..471113fa 100644 --- a/backend/app/conversations/service.py +++ b/backend/app/conversations/service.py @@ -7,7 +7,7 @@ from app.agent.agent_director.abstract_agent_director import ConversationPhase from app.agent.agent_director.llm_agent_director import LLMAgentDirector -from app.conversations.phase_state_machine import JourneyPhase, PhaseDataStatus, determine_start_phase +from app.conversations.phase_state_machine import determine_start_phase from app.agent.agent_types import AgentInput from app.agent.explore_experiences_agent_director import DiveInPhase from app.application_state import ApplicationState @@ -22,6 +22,7 @@ from app.conversations.phase_data import ( apply_entry_phase, build_phase_data_status_from_state, + CONVERSATION_ALLOWED_PHASES, ) from app.user_recommendations.services.service import IUserRecommendationsService from app.job_preferences.types import JobPreferences @@ -29,7 +30,7 @@ from app.app_config import get_application_config from app.context_vars import turn_index_ctx_var, detected_language_ctx_var, user_language_ctx_var from app.agent.persona_detector import detect_persona -from app.agent.language_detector import detect_language, get_locale_for_detected_language, DetectedLanguage +from app.agent.language_detector import detect_language, get_locale_for_detected_language from app.i18n.types import Locale class ConversationAlreadyConcludedError(Exception): @@ -108,7 +109,7 @@ async def send(self, user_id: str, session_id: int, user_input: str, clear_memor data = await build_phase_data_status_from_state( state, user_id, self._user_recommendations_service ) - entry_phase = determine_start_phase(data) + entry_phase = determine_start_phase(data, allowed_phases=CONVERSATION_ALLOWED_PHASES) self._logger.info( "Step-skip check: session=%s user=%s entry_phase=%s", session_id, user_id, entry_phase.value, @@ -134,11 +135,6 @@ async def send(self, user_id: str, session_id: int, user_input: str, clear_memor self._agent_director.get_explore_experiences_agent().get_exploring_skills_agent().set_state( state.skills_explorer_agent_state) self._agent_director.get_preference_elicitation_agent().set_state(state.preference_elicitation_agent_state) - - # Prepare recommender state with skills and preferences if not already set - await self._prepare_recommender_state_if_needed(state, user_id) - - self._agent_director.get_recommender_advisor_agent().set_state(state.recommender_advisor_agent_state) self._conversation_memory_manager.set_state(state.conversation_memory_manager_state) # Handle the user input @@ -306,99 +302,3 @@ async def _save_preference_vector_to_job_preferences(self, state: ApplicationSta # Don't fail the conversation - just log the error # This is a denormalized copy; the primary data is already in DB6 self._logger.error(f"Failed to save preference vector to JobPreferences: {e}", exc_info=True) - - async def _prepare_recommender_state_if_needed(self, state: ApplicationState, user_id: str) -> None: - """ - Prepare RecommenderAdvisorAgent state with skills and preferences if not already initialized. - - When skip_to_phase is RECOMMENDATION, loads pre-computed recommendations from - user_recommendations collection and passes them to the agent. - - Otherwise populates: - - Skills vector from explored experiences - - Preference vector from preference elicitation agent - - BWS occupation scores from preference elicitation - - Location data (city/province) - optional for v1 - - Args: - state: Application state containing all agent states - user_id: User ID for loading pre-computed recommendations when skipping - """ - rec_state = state.recommender_advisor_agent_state - - if (state.agent_director_state.skip_to_phase == JourneyPhase.RECOMMENDATION - and rec_state.recommendations is None): - self._logger.info( - "Step-skip: loading pre-computed recommendations for user=%s", user_id - ) - try: - db_recs = await self._user_recommendations_service.get_by_user_id(user_id) - if db_recs: - from app.agent.recommender_advisor_agent.user_recommendations_converter import ( - user_recommendations_to_node2vec, - ) - rec_state.recommendations = user_recommendations_to_node2vec(user_id, db_recs) - rec_state.youth_id = user_id - self._logger.info( - "Loaded pre-computed recommendations for recommender: " - "%d occupations, %d opportunities, %d skill gaps", - len(db_recs.occupation_recommendations), - len(db_recs.opportunity_recommendations), - len(db_recs.skill_gap_recommendations), - ) - except Exception as e: - self._logger.warning( - "Failed to load pre-computed recommendations for skip: %s", e, exc_info=True - ) - - if rec_state.skills_vector is not None and rec_state.preference_vector is not None: - return - - try: - # Extract skills from explored experiences - if rec_state.skills_vector is None: - from app.agent.recommender_advisor_agent.skills_extractor import SkillsExtractor - - explored_experiences = state.explore_experiences_director_state.explored_experiences - extractor = SkillsExtractor() - skills_vector = extractor.extract_skills_vector(explored_experiences) - - rec_state.skills_vector = skills_vector - self._logger.info( - f"Extracted skills vector for recommender: " - f"{len(skills_vector.get('skills', []))} skills from " - f"{skills_vector.get('total_experiences', 0)} experiences" - ) - - # Extract preference vector from preference elicitation agent - if rec_state.preference_vector is None: - pref_state = state.preference_elicitation_agent_state - if pref_state.preference_vector is not None: - rec_state.preference_vector = pref_state.preference_vector - self._logger.info( - f"Loaded preference vector for recommender " - f"(confidence: {pref_state.preference_vector.confidence_score:.2f})" - ) - - # Extract BWS occupation scores from preference elicitation - if rec_state.bws_occupation_scores is None: - pref_state = state.preference_elicitation_agent_state - if pref_state.occupation_scores: - rec_state.bws_occupation_scores = pref_state.occupation_scores - self._logger.info( - f"Loaded BWS occupation scores for recommender: " - f"{len(pref_state.occupation_scores)} occupations" - ) - - # Set youth_id (use session_id as fallback) - if rec_state.youth_id is None: - rec_state.youth_id = f"youth_{state.session_id}" - - # Location data (city/province) - optional for v1 - # TODO: Extract from user profile or welcome agent when implemented - # For now, matching service will use defaults - - except Exception as e: - # Don't fail - just log the error - # Recommender agent will work with whatever data is available - self._logger.warning(f"Error preparing recommender state: {e}", exc_info=True) diff --git a/backend/app/conversations/test_phase_state_machine.py b/backend/app/conversations/test_phase_state_machine.py index 56b121fa..b4b0811d 100644 --- a/backend/app/conversations/test_phase_state_machine.py +++ b/backend/app/conversations/test_phase_state_machine.py @@ -72,3 +72,12 @@ def test_raises_when_allowed_phases_empty_after_filter(): with pytest.raises(ValueError): determine_start_phase(status, allowed_phases=[]) + +def test_conversation_allowed_phases_restricts_to_skills_and_preference(): + from app.conversations.phase_data import CONVERSATION_ALLOWED_PHASES + + assert list(CONVERSATION_ALLOWED_PHASES) == [ + JourneyPhase.SKILLS_ELICITATION, + JourneyPhase.PREFERENCE_ELICITATION, + ] + diff --git a/backend/app/conversations/test_utils.py b/backend/app/conversations/test_utils.py index dc8a053c..7f25aecc 100644 --- a/backend/app/conversations/test_utils.py +++ b/backend/app/conversations/test_utils.py @@ -16,10 +16,8 @@ logger = logging.getLogger(__name__) all_work_types = [ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, - WorkType.UNSEEN_UNPAID + WorkType.UNSEEN_UNPAID, ] @@ -65,10 +63,8 @@ def test_completed_conversation(self): @pytest.mark.parametrize("explored_work_types, expected_percentage", [ (0, COLLECT_EXPERIENCES_PERCENTAGE), - (1, COLLECT_EXPERIENCES_PERCENTAGE + 9), # (1/4) * (40 - 5) - (2, COLLECT_EXPERIENCES_PERCENTAGE + 17), # (2/4) * (40 - 5) - (3, COLLECT_EXPERIENCES_PERCENTAGE + 26), # (3/4) * (40 - 5) - (4, DIVE_IN_EXPERIENCES_PERCENTAGE) + (1, 22), # round((1/2) * (40 - 5) + 5) + (2, DIVE_IN_EXPERIENCES_PERCENTAGE), ]) def test_n_explored_work_types(self, explored_work_types: int, expected_percentage: int): # GIVEN a random session id diff --git a/backend/app/i18n/i18n_manager.py b/backend/app/i18n/i18n_manager.py index 10433637..64a36762 100644 --- a/backend/app/i18n/i18n_manager.py +++ b/backend/app/i18n/i18n_manager.py @@ -1,7 +1,6 @@ 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 diff --git a/backend/app/i18n/locales/en-GB/messages.json b/backend/app/i18n/locales/en-GB/messages.json index f101285b..5311fb69 100644 --- a/backend/app/i18n/locales/en-GB/messages.json +++ b/backend/app/i18n/locales/en-GB/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "Conversation finished, all agents are done!", "forcefullyEnded": "Conversation forcefully ended" }, + "careerExplorer": { + "welcomeMessage": "Welcome to the Career Explorer! Based on {country}'s development priorities, there are {sector_count} key sectors where TEVET graduates are in high demand:\n\n{sectors_list}\n\nBesides these priority sectors, we can also discuss any careers you want to explore. Which sector interests you most?", + "errorRetry": "I'm having trouble right now. Could you try again?" + }, "experience": { "until": "until {end_date}", "noTitleProvidedYet": "No title provided yet", @@ -58,9 +62,77 @@ "exploreSkills": { "finalMessage": "Thank you for sharing these details! I have all the information I need.", "question": { - "formalWaged": "What do you think is important when working in such a company?", - "selfEmployment": "What do you think is important when you are your own boss?", + "unpaidTrainee": "What do you think is important when working as an unpaid trainee?", "unseenUnpaid": "What do you think is most important when helping out in the community or caring for others?" } + }, + "askMeAnything": { + "errorRetry": "I'm having some trouble right now. Please try again in a moment.", + "platformModules": { + "skillsInterests": { + "name": "Skills & Interests", + "description": "A chat-based experience where the user talks with an AI to uncover their skills and interests from their studies, work, and everyday life." + }, + "careerReadiness": { + "name": "Career Readiness (all modules)", + "description": "Lists all Career Readiness modules. Use when the user wants to explore job readiness in general." + }, + "cvDevelopment": { + "name": "CV Development", + "description": "AI-guided module for building and tailoring a professional CV." + }, + "coverLetter": { + "name": "Cover Letter Writing", + "description": "AI-guided module for writing a compelling cover letter." + }, + "interviewPreparation": { + "name": "Interview Preparation", + "description": "AI-guided module for preparing for job interviews." + }, + "professionalIdentity": { + "name": "Professional Identity", + "description": "AI-guided module for developing a professional identity and personal brand." + }, + "workplaceReadiness": { + "name": "Workplace Readiness", + "description": "AI-guided module covering workplace skills and professional conduct." + }, + "entrepreneurship": { + "name": "Entrepreneurship & Enterprise Development", + "description": "AI-guided module for developing an entrepreneurial mindset, starting and managing a business, and pitching ideas." + }, + "careerExplorer": { + "name": "Career Explorer", + "description": "Explore career opportunities in priority sectors and discover pathways to different occupations." + }, + "knowledgeHub": { + "name": "Knowledge Hub (all documents)", + "description": "Lists all Knowledge Hub documents. Use when the user wants to browse resources in general." + }, + "miningPathway": { + "name": "Mining Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the mining industry." + }, + "energyPathway": { + "name": "Energy Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the energy industry." + }, + "hospitalityPathway": { + "name": "Hospitality Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the hospitality industry." + }, + "agriculturePathway": { + "name": "Agriculture Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the agriculture industry." + }, + "waterPathway": { + "name": "Water Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the water industry." + }, + "home": { + "name": "Go to Home", + "description": "The main dashboard where the user can see all available modules and their overall progress." + } + } } } \ No newline at end of file diff --git a/backend/app/i18n/locales/en-US/messages.json b/backend/app/i18n/locales/en-US/messages.json index b8c46d3f..f1ea1394 100644 --- a/backend/app/i18n/locales/en-US/messages.json +++ b/backend/app/i18n/locales/en-US/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "Conversation finished, all agents are done!", "forcefullyEnded": "Conversation forcefully ended" }, + "careerExplorer": { + "welcomeMessage": "Welcome to the Career Explorer! Based on {country}'s development priorities, there are {sector_count} key sectors where TEVET graduates are in high demand:\n\n{sectors_list}\n\nBesides these priority sectors, we can also discuss any careers you want to explore. Which sector interests you most?", + "errorRetry": "I'm having trouble right now. Could you try again?" + }, "experience": { "until": "until {end_date}", "noTitleProvidedYet": "No title provided yet", @@ -58,9 +62,77 @@ "exploreSkills": { "finalMessage": "Thank you for sharing these details! I have all the information I need.", "question": { - "formalWaged": "What do you think is important when working in such a company?", - "selfEmployment": "What do you think is important when you are your own boss?", + "unpaidTrainee": "What do you think is important when working as an unpaid trainee?", "unseenUnpaid": "What do you think is most important when helping out in the community or caring for others?" } + }, + "askMeAnything": { + "errorRetry": "I'm having some trouble right now. Please try again in a moment.", + "platformModules": { + "skillsInterests": { + "name": "Skills & Interests", + "description": "A chat-based experience where the user talks with an AI to uncover their skills and interests from their studies, work, and everyday life." + }, + "careerReadiness": { + "name": "Career Readiness (all modules)", + "description": "Lists all Career Readiness modules. Use when the user wants to explore job readiness in general." + }, + "cvDevelopment": { + "name": "CV Development", + "description": "AI-guided module for building and tailoring a professional CV." + }, + "coverLetter": { + "name": "Cover Letter Writing", + "description": "AI-guided module for writing a compelling cover letter." + }, + "interviewPreparation": { + "name": "Interview Preparation", + "description": "AI-guided module for preparing for job interviews." + }, + "professionalIdentity": { + "name": "Professional Identity", + "description": "AI-guided module for developing a professional identity and personal brand." + }, + "workplaceReadiness": { + "name": "Workplace Readiness", + "description": "AI-guided module covering workplace skills and professional conduct." + }, + "entrepreneurship": { + "name": "Entrepreneurship & Enterprise Development", + "description": "AI-guided module for developing an entrepreneurial mindset, starting and managing a business, and pitching ideas." + }, + "careerExplorer": { + "name": "Career Explorer", + "description": "Explore career opportunities in priority sectors and discover pathways to different occupations." + }, + "knowledgeHub": { + "name": "Knowledge Hub (all documents)", + "description": "Lists all Knowledge Hub documents. Use when the user wants to browse resources in general." + }, + "miningPathway": { + "name": "Mining Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the mining industry." + }, + "energyPathway": { + "name": "Energy Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the energy industry." + }, + "hospitalityPathway": { + "name": "Hospitality Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the hospitality industry." + }, + "agriculturePathway": { + "name": "Agriculture Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the agriculture industry." + }, + "waterPathway": { + "name": "Water Sector Pathway", + "description": "Sector profile, salary data, and qualification pathways for the water industry." + }, + "home": { + "name": "Go to Home", + "description": "The main dashboard where the user can see all available modules and their overall progress." + } + } } } \ No newline at end of file diff --git a/backend/app/i18n/locales/es-AR/messages.json b/backend/app/i18n/locales/es-AR/messages.json index 2acd6b8f..78942187 100644 --- a/backend/app/i18n/locales/es-AR/messages.json +++ b/backend/app/i18n/locales/es-AR/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "¡Conversación finalizada, todos los agentes terminaron!", "forcefullyEnded": "La conversación se terminó de forma forzada" }, + "careerExplorer": { + "welcomeMessage": "¡Bienvenido al Explorador de Carreras! Según las prioridades de desarrollo de {country}, hay {sector_count} sectores clave donde los graduados de TEVET tienen alta demanda:\n\n{sectors_list}\n\nAdemás de estos sectores prioritarios, también podemos hablar de cualquier carrera que quieras explorar. ¿Qué sector te interesa más?", + "errorRetry": "Estoy teniendo problemas ahora. ¿Podés intentar de nuevo?" + }, "experience": { "until": "hasta {end_date}", "noTitleProvidedYet": "Sin título aún", @@ -58,9 +62,77 @@ "exploreSkills": { "finalMessage": "¡Gracias por compartir estos detalles! Tengo toda la información que necesito.", "question": { - "formalWaged": "¿Qué pensás que es importante al trabajar en una empresa así?", - "selfEmployment": "¿Qué pensás que es importante cuando sos tu propio jefe?", + "unpaidTrainee": "¿Qué pensás que es importante al trabajar como aprendiz no remunerado?", "unseenUnpaid": "¿Qué pensás que es más importante al ayudar en la comunidad o cuidar a otras personas?" } + }, + "askMeAnything": { + "errorRetry": "Estoy teniendo algunos problemas ahora. Por favor intentá de nuevo en un momento.", + "platformModules": { + "skillsInterests": { + "name": "Habilidades e Intereses", + "description": "Una experiencia basada en chat donde el usuario habla con una IA para descubrir sus habilidades e intereses de sus estudios, trabajo y vida cotidiana." + }, + "careerReadiness": { + "name": "Preparación para la Carrera (todos los módulos)", + "description": "Lista todos los módulos de Preparación para la Carrera. Usá cuando el usuario quiera explorar la preparación laboral en general." + }, + "cvDevelopment": { + "name": "Desarrollo de CV", + "description": "Módulo guiado por IA para construir y adaptar un CV profesional." + }, + "coverLetter": { + "name": "Escritura de Carta de Presentación", + "description": "Módulo guiado por IA para escribir una carta de presentación convincente." + }, + "interviewPreparation": { + "name": "Preparación para Entrevistas", + "description": "Módulo guiado por IA para prepararse para entrevistas laborales." + }, + "professionalIdentity": { + "name": "Identidad Profesional", + "description": "Módulo guiado por IA para desarrollar una identidad profesional y marca personal." + }, + "workplaceReadiness": { + "name": "Preparación para el Lugar de Trabajo", + "description": "Módulo guiado por IA que cubre habilidades del lugar de trabajo y conducta profesional." + }, + "entrepreneurship": { + "name": "Emprendimiento y Desarrollo Empresarial", + "description": "Módulo guiado por IA para desarrollar una mentalidad emprendedora, iniciar y gestionar un negocio, y presentar ideas." + }, + "careerExplorer": { + "name": "Explorador de Carreras", + "description": "Explorá oportunidades laborales en sectores prioritarios y descubrí rutas hacia diferentes ocupaciones." + }, + "knowledgeHub": { + "name": "Centro de Conocimiento (todos los documentos)", + "description": "Lista todos los documentos del Centro de Conocimiento. Usá cuando el usuario quiera navegar recursos en general." + }, + "miningPathway": { + "name": "Ruta del Sector Minero", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria minera." + }, + "energyPathway": { + "name": "Ruta del Sector Energético", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria energética." + }, + "hospitalityPathway": { + "name": "Ruta del Sector de Hospitalidad", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria de la hospitalidad." + }, + "agriculturePathway": { + "name": "Ruta del Sector Agrícola", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria agrícola." + }, + "waterPathway": { + "name": "Ruta del Sector del Agua", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria del agua." + }, + "home": { + "name": "Ir al Inicio", + "description": "El panel principal donde el usuario puede ver todos los módulos disponibles y su progreso general." + } + } } } diff --git a/backend/app/i18n/locales/es-ES/messages.json b/backend/app/i18n/locales/es-ES/messages.json index 335d3665..9ac327f0 100644 --- a/backend/app/i18n/locales/es-ES/messages.json +++ b/backend/app/i18n/locales/es-ES/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "¡Conversación finalizada, todos los agentes han terminado!", "forcefullyEnded": "La conversación se terminó de manera forzada" }, + "careerExplorer": { + "welcomeMessage": "¡Bienvenido al Explorador de Carreras! Según las prioridades de desarrollo de {country}, hay {sector_count} sectores clave donde los graduados de TEVET tienen alta demanda:\n\n{sectors_list}\n\nAdemás de estos sectores prioritarios, también podemos hablar de cualquier carrera que quieras explorar. ¿Qué sector te interesa más?", + "errorRetry": "Estoy teniendo problemas ahora. ¿Podrías intentar de nuevo?" + }, "experience": { "until": "hasta {end_date}", "noTitleProvidedYet": "Sin título aún", @@ -58,9 +62,77 @@ "exploreSkills": { "finalMessage": "¡Gracias por compartir estos detalles! Tengo toda la información que necesito.", "question": { - "formalWaged": "¿Qué creés que es importante al trabajar en una empresa así?", - "selfEmployment": "¿Qué creés que es importante cuando sos tu propio jefe?", + "unpaidTrainee": "¿Qué creés que es importante al trabajar como aprendiz no remunerado?", "unseenUnpaid": "¿Qué creés que es más importante al ayudar en la comunidad o cuidar a otras personas?" } + }, + "askMeAnything": { + "errorRetry": "Estoy teniendo algunos problemas ahora. Por favor intenta de nuevo en un momento.", + "platformModules": { + "skillsInterests": { + "name": "Habilidades e Intereses", + "description": "Una experiencia basada en chat donde el usuario habla con una IA para descubrir sus habilidades e intereses de sus estudios, trabajo y vida cotidiana." + }, + "careerReadiness": { + "name": "Preparación para la Carrera (todos los módulos)", + "description": "Lista todos los módulos de Preparación para la Carrera. Usa cuando el usuario quiera explorar la preparación laboral en general." + }, + "cvDevelopment": { + "name": "Desarrollo de CV", + "description": "Módulo guiado por IA para construir y adaptar un CV profesional." + }, + "coverLetter": { + "name": "Escritura de Carta de Presentación", + "description": "Módulo guiado por IA para escribir una carta de presentación convincente." + }, + "interviewPreparation": { + "name": "Preparación para Entrevistas", + "description": "Módulo guiado por IA para prepararse para entrevistas laborales." + }, + "professionalIdentity": { + "name": "Identidad Profesional", + "description": "Módulo guiado por IA para desarrollar una identidad profesional y marca personal." + }, + "workplaceReadiness": { + "name": "Preparación para el Lugar de Trabajo", + "description": "Módulo guiado por IA que cubre habilidades del lugar de trabajo y conducta profesional." + }, + "entrepreneurship": { + "name": "Emprendimiento y Desarrollo Empresarial", + "description": "Módulo guiado por IA para desarrollar una mentalidad emprendedora, iniciar y gestionar un negocio, y presentar ideas." + }, + "careerExplorer": { + "name": "Explorador de Carreras", + "description": "Explora oportunidades laborales en sectores prioritarios y descubre rutas hacia diferentes ocupaciones." + }, + "knowledgeHub": { + "name": "Centro de Conocimiento (todos los documentos)", + "description": "Lista todos los documentos del Centro de Conocimiento. Usa cuando el usuario quiera navegar recursos en general." + }, + "miningPathway": { + "name": "Ruta del Sector Minero", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria minera." + }, + "energyPathway": { + "name": "Ruta del Sector Energético", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria energética." + }, + "hospitalityPathway": { + "name": "Ruta del Sector de Hospitalidad", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria de la hospitalidad." + }, + "agriculturePathway": { + "name": "Ruta del Sector Agrícola", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria agrícola." + }, + "waterPathway": { + "name": "Ruta del Sector del Agua", + "description": "Perfil del sector, datos salariales y rutas de calificación para la industria del agua." + }, + "home": { + "name": "Ir al Inicio", + "description": "El panel principal donde el usuario puede ver todos los módulos disponibles y su progreso general." + } + } } } diff --git a/backend/app/i18n/locales/sw-KE/messages.json b/backend/app/i18n/locales/sw-KE/messages.json index ac4a0f18..73424e6c 100644 --- a/backend/app/i18n/locales/sw-KE/messages.json +++ b/backend/app/i18n/locales/sw-KE/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "Mazungumzo yamekamilika, mawakala wote wamemaliza!", "forcefullyEnded": "Mazungumzo yameisha kwa nguvu" }, + "careerExplorer": { + "welcomeMessage": "Karibu kwenye Kuchunguza Kazi! Kulingana na vipaumbele vya maendeleo vya {country}, kuna sekta {sector_count} muhimu ambazo wahitimu wa TEVET wana mahitaji makubwa:\n\n{sectors_list}\n\nLicha ya sekta hizi za kipaumbele, tunaweza pia kujadili kazi yoyote unayotaka kuchunguza. Sekta gani inakuvutia zaidi?", + "errorRetry": "Nina matatizo kwa sasa. Unaweza kujaribu tena?" + }, "experience": { "until": "hadi {end_date}", "noTitleProvidedYet": "Jina la kazi bado halijatolewa", @@ -58,9 +62,77 @@ "exploreSkills": { "finalMessage": "Asante kwa kushiriki maelezo haya! Nina taarifa zote ninazohitaji.", "question": { - "formalWaged": "Unafikiri ni nini muhimu unapofanya kazi katika kampuni kama hiyo?", - "selfEmployment": "Unafikiri ni nini muhimu unapokuwa bosi wako mwenyewe?", + "unpaidTrainee": "Unafikiri ni nini muhimu unapofanya kazi kama mfunzwa asiyelipwa?", "unseenUnpaid": "Unafikiri ni nini muhimu zaidi unaposaidia jamii au kutunza wengine?" } + }, + "askMeAnything": { + "errorRetry": "Nina matatizo fulani kwa sasa. Tafadhali jaribu tena baadaye kidogo.", + "platformModules": { + "skillsInterests": { + "name": "Ujuzi na Maslahi", + "description": "Uzoefu wa mazungumzo ambapo mtumiaji anaongea na AI ili kugundua ujuzi na maslahi yao kutoka masomo, kazi, na maisha ya kila siku." + }, + "careerReadiness": { + "name": "Uandaliwa wa Kazi (mijengo yote)", + "description": "Orodha ya mijengo yote ya Uandaliwa wa Kazi. Tumia wakati mtumiaji anataka kuchunguza uandaliwa wa kazi kwa ujumla." + }, + "cvDevelopment": { + "name": "Maendeleo ya CV", + "description": "Mfumo unaoongozwa na AI wa kuunda na kuunganisha CV ya kitaalamu." + }, + "coverLetter": { + "name": "Kuandika Barua ya Maombi", + "description": "Mfumo unaoongozwa na AI wa kuandika barua ya maombi yenye kuvutia." + }, + "interviewPreparation": { + "name": "Maandalizi ya Mahojiano", + "description": "Mfumo unaoongozwa na AI wa kujiandaa kwa mahojiano ya kazi." + }, + "professionalIdentity": { + "name": "Utambulisho wa Kitaalamu", + "description": "Mfumo unaoongozwa na AI wa kuendeleza utambulisho wa kitaalamu na chapa ya kibinafsi." + }, + "workplaceReadiness": { + "name": "Uandaliwa wa Mahali pa Kazi", + "description": "Mfumo unaoongozwa na AI unaojumuisha ujuzi wa mahali pa kazi na mwenendo wa kitaalamu." + }, + "entrepreneurship": { + "name": "Ujasiriamali na Maendeleo ya Biashara", + "description": "Mfumo unaoongozwa na AI wa kuendeleza mawazo ya ujasiriamali, kuanzisha na kusimamia biashara, na kuwasilisha mawazo." + }, + "careerExplorer": { + "name": "Kuchunguza Kazi", + "description": "Chunguza fursa za kazi katika sekta za kipaumbele na ugundue njia za kufikia kazi mbalimbali." + }, + "knowledgeHub": { + "name": "Kituo cha Maarifa (hifadhidata zote)", + "description": "Orodha ya hifadhidata zote za Kituo cha Maarifa. Tumia wakati mtumiaji anataka kuvinjari rasilimali kwa ujumla." + }, + "miningPathway": { + "name": "Njia ya Sekta ya Madini", + "description": "Wasifu wa sekta, data ya mishahara, na njia za sifa za sekta ya madini." + }, + "energyPathway": { + "name": "Njia ya Sekta ya Nishati", + "description": "Wasifu wa sekta, data ya mishahara, na njia za sifa za sekta ya nishati." + }, + "hospitalityPathway": { + "name": "Njia ya Sekta ya Utalii", + "description": "Wasifu wa sekta, data ya mishahara, na njia za sifa za sekta ya utalii." + }, + "agriculturePathway": { + "name": "Njia ya Sekta ya Kilimo", + "description": "Wasifu wa sekta, data ya mishahara, na njia za sifa za sekta ya kilimo." + }, + "waterPathway": { + "name": "Njia ya Sekta ya Maji", + "description": "Wasifu wa sekta, data ya mishahara, na njia za sifa za sekta ya maji." + }, + "home": { + "name": "Nenda Nyumbani", + "description": "Dashibodi kuu ambapo mtumiaji anaweza kuona mijengo yote inayopatikana na maendeleo yao ya jumla." + } + } } } \ No newline at end of file diff --git a/backend/app/metrics/application_state_metrics_recorder/test_recorder.py b/backend/app/metrics/application_state_metrics_recorder/test_recorder.py index 648d09fb..4333b88a 100644 --- a/backend/app/metrics/application_state_metrics_recorder/test_recorder.py +++ b/backend/app/metrics/application_state_metrics_recorder/test_recorder.py @@ -196,7 +196,7 @@ async def test_multiple_changes_recorded_together( # Add a new experience is discovered collected_data = CollectedData( experience_title=get_random_printable_string(10), - work_type=WorkType.SELF_EMPLOYMENT.name, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, company=None, location=None, start_date=None, diff --git a/backend/app/metrics/test_types.py b/backend/app/metrics/test_types.py index ab4e436f..6652facf 100644 --- a/backend/app/metrics/test_types.py +++ b/backend/app/metrics/test_types.py @@ -593,7 +593,7 @@ def test_fields_are_set_correctly(self, action, setup_application_config: Applic # GIVEN a session id and user id given_session_id = get_random_session_id() given_user_id = get_random_user_id() - given_work_type = WorkType.SELF_EMPLOYMENT.name + given_work_type = WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name given_edited_fields = ["foo", "bar"] # WHEN creating an instance of the event @@ -630,7 +630,7 @@ def test_extra_fields_are_not_allowed(self, action, setup_application_config: Ap # GIVEN all required fields given_user_id = get_random_user_id() given_session_id = get_random_session_id() - given_work_type = WorkType.SELF_EMPLOYMENT.name + given_work_type = WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name given_edited_fields = ["foo", "bar"] # AND an extra field @@ -661,7 +661,7 @@ def test_fields_are_set_correctly(self, action, setup_application_config: Applic given_session_id = get_random_session_id() given_user_id = get_random_user_id() given_uuids = [get_random_printable_string(8) for _ in range(3)] - given_work_type = WorkType.SELF_EMPLOYMENT.name + given_work_type = WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name # WHEN creating an instance of the event actual_event = SkillChangedEvent( diff --git a/backend/app/server.py b/backend/app/server.py index 4411690b..21edcb3e 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -14,6 +14,10 @@ from app.jobs import add_jobs_routes from app.job_preferences import add_job_preferences_routes from app.career_path import add_career_path_routes +from app.career_readiness import add_career_readiness_routes +from app.career_explorer.routes import add_career_explorer_routes +from app.career_explorer.config import parse_career_explorer_config +from app.ask_me_anything.routes import add_ask_me_anything_routes from app.metrics.routes.routes import add_metrics_routes from app.sentry_init import init_sentry, set_sentry_contexts from app.server_dependencies.db_dependencies import CompassDBProvider @@ -154,6 +158,10 @@ def setup_sentry(): raise ValueError("Mandatory USERDATA_MONGODB_URI env variable is not set!") if not os.getenv("USERDATA_DATABASE_NAME"): raise ValueError("Mandatory USERDATA_DATABASE_NAME environment variable is not set") +if not os.getenv('CAREER_EXPLORER_MONGODB_URI'): + raise ValueError("Mandatory CAREER_EXPLORER_MONGODB_URI env variable is not set!") +if not os.getenv("CAREER_EXPLORER_DATABASE_NAME"): + raise ValueError("Mandatory CAREER_EXPLORER_DATABASE_NAME environment variable is not set") if not os.getenv('TAXONOMY_MODEL_ID'): raise ValueError("Mandatory TAXONOMY_MODEL_ID env variable is not set!") if not os.getenv("EMBEDDINGS_SERVICE_NAME"): @@ -234,6 +242,7 @@ def setup_sentry(): matching_service_url=os.getenv("MATCHING_SERVICE_URL"), matching_service_api_key=os.getenv("MATCHING_SERVICE_API_KEY"), inline_phase_transition=os.getenv("COMPASS_INLINE_PHASE_TRANSITION", "").lower() in ("1", "true"), + career_explorer_config=parse_career_explorer_config(os.getenv("CAREER_EXPLORER_CONFIG")), ) set_application_config(application_config) @@ -270,6 +279,7 @@ async def lifespan(_app: FastAPI): taxonomy_db = await CompassDBProvider.get_taxonomy_db() userdata_db = await CompassDBProvider.get_userdata_db() metrics_db = await CompassDBProvider.get_metrics_db() + career_explorer_db = await CompassDBProvider.get_career_explorer_db() app_cfg = get_application_config() # Initialize the MongoDB databases @@ -278,6 +288,7 @@ async def lifespan(_app: FastAPI): CompassDBProvider.initialize_application_mongo_db(application_db, logger), CompassDBProvider.initialize_userdata_mongo_db(userdata_db, logger), CompassDBProvider.initialize_metrics_mongo_db(metrics_db, logger), + CompassDBProvider.initialize_career_explorer_mongo_db(career_explorer_db, logger), validate_taxonomy_model(taxonomy_db=taxonomy_db, taxonomy_model_id=app_cfg.taxonomy_model_id, embeddings_service_name=app_cfg.embeddings_service_name, @@ -304,8 +315,10 @@ async def lifespan(_app: FastAPI): # close the database connections application_db.client.close() + taxonomy_db.client.close() userdata_db.client.close() metrics_db.client.close() + career_explorer_db.client.close() logger.info("Shutting down completed.") # noinspection PyUnresolvedReferences @@ -320,6 +333,7 @@ async def lifespan(_app: FastAPI): version=get_application_config().version_info.to_version_string(), description=f"The {_global_product_name} API is used to interact with the {_global_product_name} conversation agent.", redirect_slashes=False, + swagger_ui_parameters={"docExpansion": "none"}, servers=[ { "url": backend_url or "/", @@ -402,6 +416,17 @@ async def lifespan(_app: FastAPI): ############################################ add_career_path_routes(app) +############################################ +# Add the career readiness routes +############################################ +add_career_readiness_routes(app, auth) +add_career_explorer_routes(app, auth) + +############################################ +# Add the ask me anything routes +############################################ +add_ask_me_anything_routes(app, auth) + ############################################ # Add routes relevant for esco search ############################################ diff --git a/backend/app/server_dependencies/database_collections.py b/backend/app/server_dependencies/database_collections.py index 7c07b4cc..d9f0be38 100644 --- a/backend/app/server_dependencies/database_collections.py +++ b/backend/app/server_dependencies/database_collections.py @@ -1,6 +1,7 @@ class Collections: USER_PREFERENCES: str = "user_preferences" SENSITIVE_PERSONAL_DATA: str = "sensitive_personal_data" + PLAIN_PERSONAL_DATA: str = "plain_personal_data" USER_INVITATIONS: str = "user_invitations" AGENT_DIRECTOR_STATE = "agent_director_state" WELCOME_AGENT_STATE = "welcome_agent_state" @@ -17,4 +18,7 @@ class Collections: JOBS: str = "jobs" JOB_PREFERENCES: str = "job_preferences" CAREER_PATH: str = "career_path" - USER_RECOMMENDATIONS: str = "user_recommendations" \ No newline at end of file + USER_RECOMMENDATIONS: str = "user_recommendations" + CAREER_READINESS_CONVERSATIONS: str = "career_readiness_conversations" + CAREER_EXPLORER_CONVERSATIONS: str = "career_explorer_conversations" + CAREER_EXPLORER_SECTOR_CHUNKS: str = "career_explorer_sector_chunks" diff --git a/backend/app/server_dependencies/db_dependencies.py b/backend/app/server_dependencies/db_dependencies.py index 9dade5af..159c8391 100644 --- a/backend/app/server_dependencies/db_dependencies.py +++ b/backend/app/server_dependencies/db_dependencies.py @@ -73,6 +73,13 @@ def _get_taxonomy_db(mongodb_uri: str, db_name: str) -> AsyncIOMotorDatabase: ).get_database(db_name) +def _get_career_explorer_db(mongodb_uri: str, db_name: str) -> AsyncIOMotorDatabase: + return AsyncIOMotorClient( + mongodb_uri, + tlsAllowInvalidCertificates=True + ).get_database(db_name) + + def _get_metrics_db(mongodb_uri: str, db_name: str) -> AsyncIOMotorDatabase: """ Decouples the database creation from the database provider. @@ -100,6 +107,7 @@ class CompassDBProvider: _taxonomy_mongo_db: Optional[AsyncIOMotorDatabase] = None _userdata_mongo_db: Optional[AsyncIOMotorDatabase] = None _metrics_mongo_db: Optional[AsyncIOMotorDatabase] = None + _career_explorer_mongo_db: Optional[AsyncIOMotorDatabase] = None _lock = asyncio.Lock() _logger = logging.getLogger(__qualname__) @@ -119,6 +127,11 @@ async def initialize_userdata_mongo_db(userdata_db: AsyncIOMotorDatabase, logger ("user_id", 1) ], unique=True) + # Create the plain personal data indexes + await userdata_db.get_collection(Collections.PLAIN_PERSONAL_DATA).create_index([ + ("user_id", 1) + ], unique=True) + # Create the indexes related to the user cv uploads (only if CV upload is enabled) app_config = get_application_config() if app_config.enable_cv_upload: @@ -240,11 +253,32 @@ async def initialize_application_mongo_db(application_db: AsyncIOMotorDatabase, ("user_id", 1) ], unique=True) + # Create the career readiness conversations indexes + await application_db.get_collection(Collections.CAREER_READINESS_CONVERSATIONS).create_index([ + ("conversation_id", 1) + ], unique=True) + + await application_db.get_collection(Collections.CAREER_READINESS_CONVERSATIONS).create_index([ + ("user_id", 1), ("module_id", 1) + ], unique=True) + logger.info("Finished creating indexes for the application database") except Exception as e: logger.exception(e) raise e + @staticmethod + async def initialize_career_explorer_mongo_db(career_explorer_db: AsyncIOMotorDatabase, logger: logging.Logger): + try: + logger.info("Initializing indexes for the Career Explorer database") + await career_explorer_db.get_collection(Collections.CAREER_EXPLORER_CONVERSATIONS).create_index([ + ("user_id", 1) + ], unique=True) + logger.info("Finished creating indexes for the Career Explorer database") + except Exception as e: + logger.exception(e) + raise e + @staticmethod async def initialize_metrics_mongo_db(metrics_db: AsyncIOMotorDatabase, logger: logging.Logger): """ Initialize the MongoDB database.""" @@ -322,6 +356,23 @@ async def get_taxonomy_db(cls) -> AsyncIOMotorDatabase: cls._logger.info("Successfully pinged Taxonomy MongoDB") return cls._taxonomy_mongo_db + @classmethod + async def get_career_explorer_db(cls) -> AsyncIOMotorDatabase: + if cls._career_explorer_mongo_db is None: + async with cls._lock: + if cls._career_explorer_mongo_db is None: + cls._logger.info("Connecting to Career Explorer MongoDB") + cls._career_explorer_mongo_db = _get_career_explorer_db( + cls._get_settings().career_explorer_mongodb_uri, + cls._get_settings().career_explorer_database_name, + ) + cls._logger.info("Connected to Career Explorer MongoDB database: %s", + await _get_database_connection_info(cls._career_explorer_mongo_db)) + if not await check_mongo_health(cls._career_explorer_mongo_db.client): + raise RuntimeError("MongoDB health check failed for Career Explorer database") + cls._logger.info("Successfully pinged Career Explorer MongoDB") + return cls._career_explorer_mongo_db + @classmethod async def get_metrics_db(cls) -> AsyncIOMotorDatabase: if cls._metrics_mongo_db is None: # Check if the database instance has been created @@ -349,5 +400,6 @@ def clear_cache(): CompassDBProvider._taxonomy_mongo_db = None CompassDBProvider._userdata_mongo_db = None CompassDBProvider._metrics_mongo_db = None + CompassDBProvider._career_explorer_mongo_db = None CompassDBProvider._logger.info("Cleared cached database instances") diff --git a/backend/app/store/database_application_state_store_test.py b/backend/app/store/database_application_state_store_test.py index cd1e9044..1cbb55f9 100644 --- a/backend/app/store/database_application_state_store_test.py +++ b/backend/app/store/database_application_state_store_test.py @@ -220,8 +220,8 @@ def generate_collected_data(index) -> CollectedData: def update_collect_experience_state(application_state: ApplicationState): # Set the collect experience state to a new state with a different conversation history application_state.collect_experience_state.collected_data = [generate_collected_data(i) for i in range(5)] - application_state.collect_experience_state.unexplored_types = [WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID] - application_state.collect_experience_state.explored_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT] + application_state.collect_experience_state.unexplored_types = [WorkType.UNSEEN_UNPAID] + application_state.collect_experience_state.explored_types = [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK] application_state.collect_experience_state.first_time_visit = random.choice([True, False]) # nosec B311 # random is used for testing purposes diff --git a/backend/app/test_server.py b/backend/app/test_server.py index 558e6d85..dd49c35a 100644 --- a/backend/app/test_server.py +++ b/backend/app/test_server.py @@ -26,6 +26,7 @@ async def test_server_up(self, in_memory_taxonomy_database: Awaitable[AsyncIOMotorDatabase], in_memory_application_database: Awaitable[AsyncIOMotorDatabase], in_memory_metrics_database: Awaitable[AsyncIOMotorDatabase], + in_memory_career_explorer_database: Awaitable[AsyncIOMotorDatabase], mocker: pytest_mock.MockFixture, setup_env: None ): @@ -63,6 +64,9 @@ async def test_server_up(self, _in_mem_metrics_db = mocker.patch('app.server_dependencies.db_dependencies._get_metrics_db') _in_mem_metrics_db.return_value = await in_memory_metrics_database + _in_mem_career_explorer_db = mocker.patch('app.server_dependencies.db_dependencies._get_career_explorer_db') + _in_mem_career_explorer_db.return_value = await in_memory_career_explorer_database + # Use httpx and AsyncClient to test the application asynchronously. This ensures the application # is fully started and properly shut down, as recommended for async tests in: # https://fastapi.tiangolo.com/advanced/async-tests/#async-tests diff --git a/backend/app/users/plain_personal_data/__init__.py b/backend/app/users/plain_personal_data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/users/plain_personal_data/errors.py b/backend/app/users/plain_personal_data/errors.py new file mode 100644 index 00000000..59b6da8b --- /dev/null +++ b/backend/app/users/plain_personal_data/errors.py @@ -0,0 +1,12 @@ +""" +This module contains domain-specific exceptions for plain personal data. +""" + + +class UserPreferencesNotFoundError(Exception): + """ + Exception raised when user preferences are not found. + """ + + def __init__(self, user_id: str): + super().__init__(f"User preferences not found for user {user_id}") diff --git a/backend/app/users/plain_personal_data/repository.py b/backend/app/users/plain_personal_data/repository.py new file mode 100644 index 00000000..fe6412ca --- /dev/null +++ b/backend/app/users/plain_personal_data/repository.py @@ -0,0 +1,82 @@ +""" +Plain Personal Data Repository +""" + +import logging +from typing import Optional +from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.server_dependencies.database_collections import Collections +from app.users.plain_personal_data.types import PlainPersonalData + + +class IPlainPersonalDataRepository(ABC): + """ + Interface for the Plain Personal Data Repository. + + Allows mocking the repository in tests. + """ + + @abstractmethod + async def find_by_user_id(self, user_id: str) -> Optional[PlainPersonalData]: + """ + Find plain personal data by user_id. + + :param user_id: user_id + :return: The found PlainPersonalData or None if not found + """ + raise NotImplementedError() + + @abstractmethod + async def upsert(self, user_id: str, data: dict) -> None: + """ + Create or update plain personal data for a user. + + If a document for user_id already exists, the provided data keys are merged/updated. + If no document exists, a new one is created. + + :param user_id: user_id + :param data: dict mapping dataKey -> value(s) + """ + raise NotImplementedError() + + +class PlainPersonalDataRepository(IPlainPersonalDataRepository): + def __init__(self, db: AsyncIOMotorDatabase): + self._db = db + self._logger = logging.getLogger(PlainPersonalDataRepository.__name__) + self._collection = db.get_collection(Collections.PLAIN_PERSONAL_DATA) + + async def find_by_user_id(self, user_id: str) -> Optional[PlainPersonalData]: + # Use $eq to prevent NoSQL injection + _doc = await self._collection.find_one({"user_id": {"$eq": user_id}}) + + if _doc is None: + return None + + return PlainPersonalData.from_dict(_doc) + + async def upsert(self, user_id: str, data: dict) -> None: + now = datetime.now(timezone.utc) + + # Build $set payload: update each data key individually so existing keys are preserved + set_payload: dict = { + "updated_at": now.isoformat(), + } + for key, value in data.items(): + set_payload[f"data.{key}"] = value + + await self._collection.update_one( + {"user_id": {"$eq": user_id}}, + { + "$set": set_payload, + "$setOnInsert": { + "user_id": user_id, + "created_at": now.isoformat(), + }, + }, + upsert=True, + ) diff --git a/backend/app/users/plain_personal_data/routes.py b/backend/app/users/plain_personal_data/routes.py new file mode 100644 index 00000000..0c14c5fc --- /dev/null +++ b/backend/app/users/plain_personal_data/routes.py @@ -0,0 +1,124 @@ +""" +This module contains functions to add plain personal data routes to the users router. +""" +import asyncio +import logging +from http import HTTPStatus +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.constants.errors import HTTPErrorResponse +from app.context_vars import user_id_ctx_var +from app.server_dependencies.db_dependencies import CompassDBProvider +from app.users.auth import Authentication, UserInfo +from app.users.repositories import UserPreferenceRepository +from app.users.plain_personal_data.repository import PlainPersonalDataRepository +from app.users.plain_personal_data.types import CreateOrUpdatePlainPersonalDataRequest, PlainPersonalData +from app.users.plain_personal_data.service import PlainPersonalDataService, IPlainPersonalDataService +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError + +# Lock to ensure that the singleton instance is thread-safe +_plain_personal_data_service_lock = asyncio.Lock() +_plain_personal_data_service_singleton: Optional[IPlainPersonalDataService] = None + + +async def get_plain_personal_data_service( + user_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_userdata_db), + application_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_application_db), +) -> IPlainPersonalDataService: + global _plain_personal_data_service_singleton + if _plain_personal_data_service_singleton is None: + async with _plain_personal_data_service_lock: + if _plain_personal_data_service_singleton is None: + _plain_personal_data_service_singleton = PlainPersonalDataService( + repository=PlainPersonalDataRepository(user_db), + user_preference_repository=UserPreferenceRepository(application_db), + ) + return _plain_personal_data_service_singleton + + +def add_user_plain_personal_data_routes(users_router: APIRouter, auth: Authentication): + """ + Adds the plain personal data routes to the users router. + + :param users_router: the users router + :param auth: Authentication + """ + logger = logging.getLogger(__name__) + + router = APIRouter( + prefix="/{user_id}/plain-personal-data", + tags=["user-plain-personal-data"], + ) + + @router.post( + path="", + status_code=200, + response_model=None, + description="Creates or updates the user's plain (unencrypted) personal data. Upsert semantics: existing keys are updated, new keys are added.", + responses={ + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _handle_upsert_plain_personal_data( + plain_personal_data_payload: CreateOrUpdatePlainPersonalDataRequest, + user_id: str = Path(description="the unique identifier of the user", examples=["1"]), + service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), + user_info: UserInfo = Depends(auth.get_user_info()), + ): + user_id_ctx_var.set(user_id) + + if user_info.user_id != user_id: + warning_msg = f"User {user_info.user_id} is not allowed to handle plain personal data for another user {user_id}" + logger.warning(warning_msg) + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=warning_msg) + + try: + await service.upsert(user_id, plain_personal_data_payload.data) + except UserPreferencesNotFoundError as e: + warning_msg = str(e) + logger.warning(warning_msg) + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=warning_msg) + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Opps! Something went wrong.") + + @router.get( + path="", + status_code=200, + response_model=PlainPersonalData, + description="Retrieves the user's plain (unencrypted) personal data.", + responses={ + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _handle_get_plain_personal_data( + user_id: str = Path(description="the unique identifier of the user", examples=["1"]), + service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), + user_info: UserInfo = Depends(auth.get_user_info()), + ): + user_id_ctx_var.set(user_id) + + if user_info.user_id != user_id: + warning_msg = f"User {user_info.user_id} is not allowed to retrieve plain personal data for another user {user_id}" + logger.warning(warning_msg) + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=warning_msg) + + try: + result = await service.get(user_id) + if result is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Plain personal data not found for user {user_id}") + return result + except HTTPException: + raise + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Opps! Something went wrong.") + + users_router.include_router(router) diff --git a/backend/app/users/plain_personal_data/service.py b/backend/app/users/plain_personal_data/service.py new file mode 100644 index 00000000..7f609613 --- /dev/null +++ b/backend/app/users/plain_personal_data/service.py @@ -0,0 +1,64 @@ +""" +This module contains the service layer for the plain personal data module. +""" +import logging +from abc import ABC, abstractmethod +from typing import Optional + +from app.users.plain_personal_data.repository import IPlainPersonalDataRepository +from app.users.plain_personal_data.types import PlainPersonalData +from app.users.repositories import IUserPreferenceRepository +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError + + +class IPlainPersonalDataService(ABC): + """ + Interface for the Plain Personal Data Service. + + Allows mocking the service in tests. + """ + + @abstractmethod + async def upsert(self, user_id: str, data: dict) -> None: + """ + Create or update plain personal data for a user. + + :param user_id: user_id + :param data: dict mapping dataKey -> value(s) + :raises UserPreferencesNotFoundError: if user preferences are not found + :raises Exception: if any other error occurs + """ + raise NotImplementedError() + + @abstractmethod + async def get(self, user_id: str) -> Optional[PlainPersonalData]: + """ + Get plain personal data for a user. + + :param user_id: user_id + :return: PlainPersonalData or None if not found + :raises Exception: if any error occurs + """ + raise NotImplementedError() + + +class PlainPersonalDataService(IPlainPersonalDataService): + def __init__( + self, + repository: IPlainPersonalDataRepository, + user_preference_repository: IUserPreferenceRepository, + ): + self._repository = repository + self._user_preference_repository = user_preference_repository + self._logger = logging.getLogger(PlainPersonalDataService.__name__) + + async def upsert(self, user_id: str, data: dict) -> None: + # Ensure the user has preferences (i.e. the user exists) + user_preferences = await self._user_preference_repository.get_user_preference_by_user_id(user_id) + if user_preferences is None: + raise UserPreferencesNotFoundError(user_id) + + await self._repository.upsert(user_id, data) + + async def get(self, user_id: str) -> Optional[PlainPersonalData]: + return await self._repository.find_by_user_id(user_id) diff --git a/backend/app/users/plain_personal_data/test_repository.py b/backend/app/users/plain_personal_data/test_repository.py new file mode 100644 index 00000000..4e6847ee --- /dev/null +++ b/backend/app/users/plain_personal_data/test_repository.py @@ -0,0 +1,120 @@ +""" +Tests for the PlainPersonalDataRepository class. +""" +from typing import Awaitable + +import pytest + +from app.users.plain_personal_data.repository import PlainPersonalDataRepository +from common_libs.test_utilities.random_data import get_random_printable_string + + +@pytest.fixture(scope="function") +async def get_plain_personal_data_repository(in_memory_userdata_database) -> PlainPersonalDataRepository: + userdata_db = await in_memory_userdata_database + return PlainPersonalDataRepository(userdata_db) + + +class TestFindByUserId: + @pytest.mark.asyncio + async def test_returns_none_when_not_found(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN a user_id that has no document in the collection + given_user_id = get_random_printable_string(10) + + # WHEN find_by_user_id is called + result = await repository.find_by_user_id(given_user_id) + + # THEN None is returned + assert result is None + + @pytest.mark.asyncio + async def test_returns_data_when_found(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN a document that exists in the collection + given_user_id = get_random_printable_string(10) + given_data = {"age": "25", "gender": "Male"} + await repository.upsert(given_user_id, given_data) + + # WHEN find_by_user_id is called + result = await repository.find_by_user_id(given_user_id) + + # THEN the document is returned + assert result is not None + assert result.user_id == given_user_id + assert result.data == given_data + + +class TestUpsert: + @pytest.mark.asyncio + async def test_creates_new_document_when_none_exists(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN no existing document for a user + given_user_id = get_random_printable_string(10) + given_data = {"age": "30"} + + # WHEN upsert is called + await repository.upsert(given_user_id, given_data) + + # THEN a document is created + result = await repository.find_by_user_id(given_user_id) + assert result is not None + assert result.user_id == given_user_id + assert result.data["age"] == "30" + assert result.created_at is not None + assert result.updated_at is not None + + @pytest.mark.asyncio + async def test_updates_existing_document(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN an existing document + given_user_id = get_random_printable_string(10) + await repository.upsert(given_user_id, {"age": "25"}) + + # WHEN upsert is called with updated data + await repository.upsert(given_user_id, {"age": "26"}) + + # THEN the value is updated + result = await repository.find_by_user_id(given_user_id) + assert result is not None + assert result.data["age"] == "26" + + @pytest.mark.asyncio + async def test_merges_new_keys_on_upsert(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN an existing document with key "age" + given_user_id = get_random_printable_string(10) + await repository.upsert(given_user_id, {"age": "25"}) + + # WHEN upsert is called with a new key "gender" + await repository.upsert(given_user_id, {"gender": "Male"}) + + # THEN both keys are present + result = await repository.find_by_user_id(given_user_id) + assert result is not None + assert result.data["age"] == "25" + assert result.data["gender"] == "Male" + + @pytest.mark.asyncio + async def test_preserves_created_at_on_second_upsert(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN an existing document + given_user_id = get_random_printable_string(10) + await repository.upsert(given_user_id, {"age": "25"}) + first_result = await repository.find_by_user_id(given_user_id) + assert first_result is not None + original_created_at = first_result.created_at + + # WHEN upsert is called again + await repository.upsert(given_user_id, {"age": "26"}) + + # THEN created_at is unchanged + second_result = await repository.find_by_user_id(given_user_id) + assert second_result is not None + assert second_result.created_at == original_created_at diff --git a/backend/app/users/plain_personal_data/test_routes.py b/backend/app/users/plain_personal_data/test_routes.py new file mode 100644 index 00000000..a7e5df34 --- /dev/null +++ b/backend/app/users/plain_personal_data/test_routes.py @@ -0,0 +1,152 @@ +""" +Tests for the plain personal data routes. +""" +from http import HTTPStatus +from typing import Optional + +import pytest +import pytest_mock +from fastapi import FastAPI, APIRouter +from fastapi.testclient import TestClient + +from app.users.auth import UserInfo +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError +from app.users.plain_personal_data.routes import add_user_plain_personal_data_routes, get_plain_personal_data_service +from app.users.plain_personal_data.service import IPlainPersonalDataService +from app.users.plain_personal_data.types import PlainPersonalData +from common_libs.test_utilities.mock_auth import MockAuth +from common_libs.test_utilities.random_data import get_random_user_id +from datetime import datetime, timezone + +TestClientWithMocks = tuple[TestClient, IPlainPersonalDataService, UserInfo | None] + + +@pytest.fixture(scope="function") +def client_with_mocks() -> TestClientWithMocks: + class MockPlainPersonalDataService(IPlainPersonalDataService): + async def upsert(self, user_id: str, data: dict) -> None: + return None + + async def get(self, user_id: str) -> Optional[PlainPersonalData]: + return PlainPersonalData( + user_id=user_id, + created_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + data={"age": "25"}, + ) + + _instance_service = MockPlainPersonalDataService() + + def _mocked_get_service() -> IPlainPersonalDataService: + return _instance_service + + _instance_auth = MockAuth() + + api_router = APIRouter() + app = FastAPI() + + app.dependency_overrides[get_plain_personal_data_service] = _mocked_get_service + + add_user_plain_personal_data_routes(api_router, auth=_instance_auth) + app.include_router(api_router) + + yield TestClient(app), _instance_service, _instance_auth.mocked_user + app.dependency_overrides = {} + + +class TestHandleUpsertPlainPersonalData: + @pytest.mark.asyncio + async def test_upsert_success_returns_200(self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture): + client, mocked_service, mocked_authed_user = client_with_mocks + + # GIVEN a valid payload and the authenticated user's id + given_user_id = mocked_authed_user.user_id + given_payload = {"data": {"age": "25"}} + + upsert_spy = mocker.spy(mocked_service, "upsert") + + # WHEN a POST request is made + response = client.post(f"/{given_user_id}/plain-personal-data", json=given_payload) + + # THEN the response is 200 OK + assert response.status_code == HTTPStatus.OK + + # AND upsert was called with the correct arguments + upsert_spy.assert_called_once_with(given_user_id, {"age": "25"}) + + @pytest.mark.asyncio + async def test_upsert_forbidden_when_user_id_mismatch(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # GIVEN a different user_id in the path than the authenticated user + different_user_id = get_random_user_id() + given_payload = {"data": {"age": "25"}} + + # WHEN a POST request is made + response = client.post(f"/{different_user_id}/plain-personal-data", json=given_payload) + + # THEN the response is 403 FORBIDDEN + assert response.status_code == HTTPStatus.FORBIDDEN + + @pytest.mark.asyncio + async def test_upsert_returns_404_when_user_preferences_not_found( + self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture + ): + client, mocked_service, mocked_authed_user = client_with_mocks + + # GIVEN upsert raises UserPreferencesNotFoundError + given_user_id = mocked_authed_user.user_id + mocker.patch.object(mocked_service, "upsert", side_effect=UserPreferencesNotFoundError(given_user_id)) + + # WHEN a POST request is made + response = client.post(f"/{given_user_id}/plain-personal-data", json={"data": {"age": "25"}}) + + # THEN the response is 404 NOT FOUND + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestHandleGetPlainPersonalData: + @pytest.mark.asyncio + async def test_get_success_returns_200(self, client_with_mocks: TestClientWithMocks): + client, _, mocked_authed_user = client_with_mocks + + # GIVEN the authenticated user's id + given_user_id = mocked_authed_user.user_id + + # WHEN a GET request is made + response = client.get(f"/{given_user_id}/plain-personal-data") + + # THEN the response is 200 OK + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["user_id"] == given_user_id + assert body["data"] == {"age": "25"} + + @pytest.mark.asyncio + async def test_get_forbidden_when_user_id_mismatch(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # GIVEN a different user_id in the path + different_user_id = get_random_user_id() + + # WHEN a GET request is made + response = client.get(f"/{different_user_id}/plain-personal-data") + + # THEN the response is 403 FORBIDDEN + assert response.status_code == HTTPStatus.FORBIDDEN + + @pytest.mark.asyncio + async def test_get_returns_404_when_not_found( + self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture + ): + client, mocked_service, mocked_authed_user = client_with_mocks + + # GIVEN get returns None (no data) + given_user_id = mocked_authed_user.user_id + mocker.patch.object(mocked_service, "get", return_value=None) + + # WHEN a GET request is made + response = client.get(f"/{given_user_id}/plain-personal-data") + + # THEN the response is 404 NOT FOUND + assert response.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/app/users/plain_personal_data/test_service.py b/backend/app/users/plain_personal_data/test_service.py new file mode 100644 index 00000000..15fc802c --- /dev/null +++ b/backend/app/users/plain_personal_data/test_service.py @@ -0,0 +1,100 @@ +""" +Tests for the PlainPersonalDataService class. +""" +from typing import Optional + +import pytest + +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError +from app.users.plain_personal_data.repository import IPlainPersonalDataRepository +from app.users.plain_personal_data.service import PlainPersonalDataService +from app.users.plain_personal_data.types import PlainPersonalData +from app.users.repositories import IUserPreferenceRepository +from app.users.sensitive_personal_data.types import SensitivePersonalDataRequirement +from app.users.types import UserPreferences, UserPreferencesRepositoryUpdateRequest +from common_libs.test_utilities.random_data import get_random_printable_string + + +def _make_mock_plain_personal_data_repository(find_result: Optional[PlainPersonalData] = None) -> IPlainPersonalDataRepository: + class MockRepo(IPlainPersonalDataRepository): + def __init__(self): + self.upsert_calls = [] + + async def find_by_user_id(self, user_id: str) -> Optional[PlainPersonalData]: + return find_result + + async def upsert(self, user_id: str, data: dict) -> None: + self.upsert_calls.append((user_id, data)) + + return MockRepo() + + +def _make_mock_user_preference_repository(prefs: Optional[UserPreferences] = None) -> IUserPreferenceRepository: + class MockUserPreferenceRepo(IUserPreferenceRepository): + async def get_user_preference_by_user_id(self, user_id: str) -> Optional[UserPreferences]: + return prefs + + async def update_user_preference(self, user_id: str, request: UserPreferencesRepositoryUpdateRequest) -> Optional[UserPreferences]: + raise NotImplementedError + + async def insert_user_preference(self, user_id: str, user_preference: UserPreferences) -> UserPreferences: + raise NotImplementedError + + async def get_experiments_by_user_id(self, user_id: str) -> dict[str, str]: + raise NotImplementedError + + async def get_experiments_by_user_ids(self, user_ids: list[str]) -> dict[str, dict[str, str]]: + raise NotImplementedError + + async def set_experiment_by_user_id(self, user_id: str, experiment_id: str, experiment_class: str) -> None: + raise NotImplementedError + + return MockUserPreferenceRepo() + + +class TestUpsert: + @pytest.mark.asyncio + async def test_raises_user_preferences_not_found_when_user_has_no_preferences(self): + # GIVEN a user without preferences + given_user_id = get_random_printable_string(10) + mock_repo = _make_mock_plain_personal_data_repository() + mock_pref_repo = _make_mock_user_preference_repository(prefs=None) + service = PlainPersonalDataService(mock_repo, mock_pref_repo) + + # WHEN upsert is called + # THEN UserPreferencesNotFoundError is raised + with pytest.raises(UserPreferencesNotFoundError): + await service.upsert(given_user_id, {"age": "25"}) + + @pytest.mark.asyncio + async def test_calls_repository_upsert_when_user_preferences_exist(self): + # GIVEN a user with valid preferences + given_user_id = get_random_printable_string(10) + given_data = {"age": "30"} + mock_repo = _make_mock_plain_personal_data_repository() + mock_pref_repo = _make_mock_user_preference_repository(prefs=UserPreferences( + sensitive_personal_data_requirement=SensitivePersonalDataRequirement.NOT_REQUIRED, + )) + service = PlainPersonalDataService(mock_repo, mock_pref_repo) + + # WHEN upsert is called + await service.upsert(given_user_id, given_data) + + # THEN the repository upsert is called with the correct arguments + assert (given_user_id, given_data) in mock_repo.upsert_calls + + +class TestGet: + @pytest.mark.asyncio + async def test_returns_none_when_no_data_found(self): + # GIVEN no data exists for the user + given_user_id = get_random_printable_string(10) + mock_repo = _make_mock_plain_personal_data_repository(find_result=None) + mock_pref_repo = _make_mock_user_preference_repository() + service = PlainPersonalDataService(mock_repo, mock_pref_repo) + + # WHEN get is called + result = await service.get(given_user_id) + + # THEN None is returned + assert result is None diff --git a/backend/app/users/plain_personal_data/types.py b/backend/app/users/plain_personal_data/types.py new file mode 100644 index 00000000..f4fe0437 --- /dev/null +++ b/backend/app/users/plain_personal_data/types.py @@ -0,0 +1,68 @@ +""" +This module contains the types used for storing plain (unencrypted) personal data. +""" + +from datetime import datetime, timezone +from typing import Union, Mapping + +from pydantic import BaseModel, Field, field_validator, field_serializer + + +class CreateOrUpdatePlainPersonalDataRequest(BaseModel): + """ + Represents the request body for creating or updating plain personal data. + """ + data: dict[str, Union[str, list[str]]] = Field( + description="Map of field dataKey to value(s). Values are plain strings or lists of strings.", + ) + + class Config: + """ + Pydantic configuration. + """ + extra = "forbid" + + +class PlainPersonalData(BaseModel): + """ + The plain personal data document in the database. + """ + + user_id: str = Field(description="The user id") + created_at: datetime = Field(description="The date and time the database entry was created") + updated_at: datetime = Field(description="The date and time the database entry was last updated") + data: dict[str, Union[str, list[str]]] = Field( + description="Map of field dataKey to value(s).", + default_factory=dict, + ) + + @field_serializer("created_at", "updated_at") + def _serialize_datetime(self, dt: datetime) -> str: + return dt.isoformat() + + @classmethod + @field_validator("created_at", "updated_at", mode="before") + def _deserialize_datetime(cls, value: Union[str, datetime]) -> datetime: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + else: + dt = value + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + @staticmethod + def from_dict(_dict: Mapping[str, any]) -> "PlainPersonalData": + """ + Converts a dictionary to a ``PlainPersonalData`` object. + """ + return PlainPersonalData( + user_id=str(_dict.get("user_id")), + created_at=_dict.get("created_at"), + updated_at=_dict.get("updated_at"), + data=_dict.get("data", {}), + ) + + class Config: + """ + Pydantic configuration. + """ + extra = "forbid" diff --git a/backend/app/users/preferences.py b/backend/app/users/preferences.py index 19ad424b..ae03895f 100644 --- a/backend/app/users/preferences.py +++ b/backend/app/users/preferences.py @@ -15,11 +15,13 @@ from app.server_dependencies.db_dependencies import CompassDBProvider from app.users.auth import Authentication, UserInfo, SignInProvider from app.users.get_user_preferences_repository import get_user_preferences_repository +from app.users.plain_personal_data.routes import get_plain_personal_data_service +from app.users.plain_personal_data.service import IPlainPersonalDataService from app.users.repositories import UserPreferenceRepository from app.users.sensitive_personal_data.routes import get_sensitive_personal_data_service from app.users.sensitive_personal_data.service import ISensitivePersonalDataService from app.users.sensitive_personal_data.types import SensitivePersonalDataRequirement -from app.users.sessions import generate_new_session_id, SessionsService +from app.users.generate_session_id import generate_new_session_id from app.users.types import UserPreferencesUpdateRequest, UserPreferences, \ CreateUserPreferencesRequest, UserPreferencesRepositoryUpdateRequest, UsersPreferencesResponse @@ -33,6 +35,7 @@ async def _get_user_preferences( repository: UserPreferenceRepository, user_feedback_service: UserFeedbackService, sensitive_personal_data_service: ISensitivePersonalDataService, + plain_personal_data_service: IPlainPersonalDataService, user_id: str, authed_user: UserInfo) -> UsersPreferencesResponse: try: @@ -58,14 +61,15 @@ async def _get_user_preferences( ) # Fetch feedback sessions together with if they have sensitive personal data - answered_questions, has_sensitive_personal_data = await asyncio.gather( + answered_questions, has_sensitive_personal_data, plain_personal_data = await asyncio.gather( user_feedback_service.get_answered_questions(user_id), - sensitive_personal_data_service.exists_by_user_id(user_id) + sensitive_personal_data_service.exists_by_user_id(user_id), + plain_personal_data_service.get(user_id) ) return UsersPreferencesResponse( **user_preferences.model_dump(), - has_sensitive_personal_data=has_sensitive_personal_data, + has_sensitive_personal_data=has_sensitive_personal_data or plain_personal_data is not None, user_feedback_answered_questions=answered_questions ) except Exception as e: @@ -155,45 +159,11 @@ async def _create_user_preferences( raise HTTPException(status_code=500, detail="failed to create user preferences") -async def _get_new_session(user_repository: UserPreferenceRepository, - user_feedback_service: UserFeedbackService, - sensitive_personal_data_service: ISensitivePersonalDataService, - user_id: str, - authed_user: UserInfo) -> UsersPreferencesResponse: - """ - Get a new session for the user - :param user_id: id of the user - :param authed_user: authenticated user - :return: UserPreferences - with the new session - """ - try: - # Check if the user is the same as the authenticated user - if user_id != authed_user.user_id: - raise HTTPException(status_code=403, detail="forbidden") - - session_service = SessionsService(user_repository) - - updated_user_preferences, sessions_with_feedback, has_sensitive_personal_data = await asyncio.gather( - session_service.new_session(user_id), - user_feedback_service.get_answered_questions(user_id), - sensitive_personal_data_service.exists_by_user_id(user_id) - ) - - return UsersPreferencesResponse( - **updated_user_preferences.model_dump(), - has_sensitive_personal_data=has_sensitive_personal_data, - user_feedback_answered_questions=sessions_with_feedback - ) - - except Exception as e: - ErrorService.handle(__name__, e) - raise HTTPException(status_code=500, detail="Oops! something went wrong") - - async def _update_user_preferences( repository: UserPreferenceRepository, user_feedback_service: UserFeedbackService, sensitive_personal_data_service: ISensitivePersonalDataService, + plain_personal_data_service: IPlainPersonalDataService, preferences: UserPreferencesUpdateRequest, authed_user: UserInfo) -> UsersPreferencesResponse | None: """ @@ -223,7 +193,7 @@ async def _update_user_preferences( detail="accepted terms and conditions can't be updated once accepted" ) - updated_user_preferences, sessions_with_feedback, has_sensitive_personal_data = await asyncio.gather( + updated_user_preferences, sessions_with_feedback, has_sensitive_personal_data, plain_personal_data = await asyncio.gather( repository.update_user_preference(preferences.user_id, UserPreferencesRepositoryUpdateRequest( language=preferences.language, client_id=preferences.client_id, @@ -231,12 +201,13 @@ async def _update_user_preferences( experiments=preferences.experiments )), user_feedback_service.get_answered_questions(preferences.user_id), - sensitive_personal_data_service.exists_by_user_id(preferences.user_id) + sensitive_personal_data_service.exists_by_user_id(preferences.user_id), + plain_personal_data_service.get(preferences.user_id) ) return UsersPreferencesResponse( **updated_user_preferences.model_dump(), - has_sensitive_personal_data=has_sensitive_personal_data, + has_sensitive_personal_data=has_sensitive_personal_data or plain_personal_data is not None, user_feedback_answered_questions=sessions_with_feedback ) @@ -295,6 +266,7 @@ async def _get_user_preferences_handler( user_preference_repository: UserPreferenceRepository = Depends(get_user_preferences_repository), sensitive_personal_data_service: ISensitivePersonalDataService = Depends( get_sensitive_personal_data_service), + plain_personal_data_service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), user_feedback_service: UserFeedbackService = Depends(_get_user_feedback_service) ) -> UsersPreferencesResponse: # set the user id context variable. @@ -304,6 +276,7 @@ async def _get_user_preferences_handler( user_preference_repository, user_feedback_service, sensitive_personal_data_service, + plain_personal_data_service, user_id, user_info ) @@ -346,6 +319,7 @@ async def _update_user_preferences_handler( user_preference_repository: UserPreferenceRepository = Depends(get_user_preferences_repository), sensitive_personal_data_service: ISensitivePersonalDataService = Depends( get_sensitive_personal_data_service), + plain_personal_data_service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), user_feedback_service: UserFeedbackService = Depends(_get_user_feedback_service) ) -> UsersPreferencesResponse: # set the user id context variable. @@ -355,41 +329,11 @@ async def _update_user_preferences_handler( user_preference_repository, user_feedback_service, sensitive_personal_data_service, + plain_personal_data_service, request, user_info ) - ######################### - # GET /new-session - Get a new session for the user - ######################### - @router.get("/new-session", - response_model=UsersPreferencesResponse, - status_code=201, - responses={403: {"model": HTTPErrorResponse}, 500: {"model": HTTPErrorResponse}}, - description="""Endpoint for starting a new conversation session.""") - async def _get_new_session_handler(user_id: str, user_info: UserInfo = Depends(auth.get_user_info()), - user_preference_repository: UserPreferenceRepository = Depends( - get_user_preferences_repository), - sensitive_personal_data_service: ISensitivePersonalDataService = Depends( - get_sensitive_personal_data_service), - user_feedback_service: UserFeedbackService = Depends(_get_user_feedback_service) - ) -> UsersPreferencesResponse: - """ - Endpoint for starting a new conversation session. - The function creates a new session id and adds it to the user sessions on the top of the list. - - :param user_info: UserInfo - The logged-in user information - :return: UserPreferences - The updated user preferences - """ - # set the user id context variable. - user_id_ctx_var.set(user_id) - - return await _get_new_session(user_preference_repository, - user_feedback_service, - sensitive_personal_data_service, - user_id, - user_info) - ######################### # Add the router to the users router ######################### diff --git a/backend/app/users/routes.py b/backend/app/users/routes.py index 7d0d704e..2871d7dc 100644 --- a/backend/app/users/routes.py +++ b/backend/app/users/routes.py @@ -4,6 +4,7 @@ from app.app_config import get_application_config from app.users.sensitive_personal_data.routes import add_user_sensitive_personal_data_routes +from app.users.plain_personal_data.routes import add_user_plain_personal_data_routes from app.users.cv.routes import add_user_cv_routes from app.users.auth import Authentication from app.users.preferences import add_user_preference_routes @@ -37,6 +38,11 @@ def add_users_routes(app: FastAPI, authentication: Authentication): ############################################ add_user_sensitive_personal_data_routes(users_router, authentication) + ############################################ + # Add the plain personal data routes + ############################################ + add_user_plain_personal_data_routes(users_router, authentication) + # we can add more routes related to the users management here ############################################ diff --git a/backend/app/users/sessions.py b/backend/app/users/sessions.py deleted file mode 100644 index d4c41382..00000000 --- a/backend/app/users/sessions.py +++ /dev/null @@ -1,47 +0,0 @@ -import logging - -from fastapi import HTTPException - -from app.constants.errors import ErrorService -from app.users.generate_session_id import generate_new_session_id -from app.users.repositories import UserPreferenceRepository -from app.users.types import UserPreferences, UserPreferencesRepositoryUpdateRequest - - -logger = logging.getLogger(__name__) - - -class SessionsService: - def __init__(self, user_repository: UserPreferenceRepository): - self.user_repository = user_repository - - async def new_session(self, user_id: str) -> UserPreferences: - """ - Create a new session for the user - :param user_id: str - the user ID - :return: UserPreferences - the updated user preferences - """ - try: - new_session_id = generate_new_session_id() - - # Get the user preferences - user_preferences = await self.user_repository.get_user_preference_by_user_id(user_id) - - # If the user does not exist, raise an HTTPException - if user_preferences is None: - logger.info("User preferences not found. user_id=%s", user_id) - raise HTTPException(status_code=404, detail="User not found") - - # Add the new session to the user preferences - # we are using the new session ID as the first element of the list - # And the client must use the new session ID for the next requests - # :note: This must be in sync with frontend-new/src/chat/Chat.tsx#L179 - new_sessions = [new_session_id, *user_preferences.sessions] - - return await self.user_repository.update_user_preference( - user_id=user_id, - update=UserPreferencesRepositoryUpdateRequest(sessions=new_sessions) - ) - - except Exception as e: - ErrorService.handle(__name__, e) diff --git a/backend/common_libs/environment_settings/mongo_db_settings.py b/backend/common_libs/environment_settings/mongo_db_settings.py index 72710dfc..036edbb0 100644 --- a/backend/common_libs/environment_settings/mongo_db_settings.py +++ b/backend/common_libs/environment_settings/mongo_db_settings.py @@ -45,3 +45,13 @@ class MongoDbSettings(BaseSettings): """ The name of the taxonomy database """ + + career_explorer_mongodb_uri: str = "" + """ + The URI of the Career Explorer MongoDB instance. + """ + + career_explorer_database_name: str = "" + """ + The name of the Career Explorer database (conversations + sector chunks). + """ diff --git a/backend/common_libs/llm/generative_models.py b/backend/common_libs/llm/generative_models.py index 95e42383..752d0232 100644 --- a/backend/common_libs/llm/generative_models.py +++ b/backend/common_libs/llm/generative_models.py @@ -1,7 +1,7 @@ import logging -import traceback -from vertexai.generative_models import GenerativeModel, Content, Part, GenerationConfig +from google.genai.types import GroundingMetadata +from vertexai.generative_models import GenerativeModel, Content, Part, GenerationConfig, Tool from vertexai.language_models import TextGenerationModel from common_libs.llm.models_utils import LLMConfig, LLMInput, LLMResponse, BasicLLM @@ -16,14 +16,19 @@ class GeminiGenerativeLLM(BasicLLM): def __init__(self, *, system_instructions: list[str] | str | None = None, - config: LLMConfig = LLMConfig()): + config: LLMConfig = LLMConfig(), + tools: list[Tool] | None = None): super().__init__(config=config) - self._model = GenerativeModel(model_name=config.language_model_name, - system_instruction=system_instructions, - generation_config=GenerationConfig.from_dict(config.generation_config), - safety_settings=list(config.safety_settings) - ) + model_kwargs = dict( + model_name=config.language_model_name, + system_instruction=system_instructions, + generation_config=GenerationConfig.from_dict(config.generation_config), + safety_settings=list(config.safety_settings), + ) + if tools: + model_kwargs["tools"] = tools + self._model = GenerativeModel(**model_kwargs) # noinspection PyProtectedMember self._resource_name = self._model._prediction_resource_name # pylint: disable=protected-access @@ -31,9 +36,38 @@ async def internal_generate_content(self, llm_input: LLMInput | str) -> LLMRespo contents = llm_input if isinstance(llm_input, str) else [ Content(role=turn.role, parts=[Part.from_text(turn.content)]) for turn in llm_input.turns] response = await self._model.generate_content_async(contents=contents) - return LLMResponse(text=response.text, - prompt_token_count=response.usage_metadata.prompt_token_count, - response_token_count=response.usage_metadata.candidates_token_count) + grounding_metadata = self._extract_grounding_metadata(response) + return LLMResponse( + text=response.text, + prompt_token_count=response.usage_metadata.prompt_token_count, + response_token_count=response.usage_metadata.candidates_token_count, + grounding_metadata=grounding_metadata, + ) + + def _extract_grounding_metadata(self, response) -> GroundingMetadata | None: + if not response.candidates: + return None + candidate = response.candidates[0] + gm = getattr(candidate, "grounding_metadata", None) + if gm is None: + return None + + raw_dict: dict + if isinstance(gm, dict): + raw_dict = gm + elif hasattr(gm, "to_dict"): + raw_dict = gm.to_dict() + else: + try: + from google.protobuf.json_format import MessageToDict + raw_dict = MessageToDict(gm._pb) if hasattr(gm, "_pb") else MessageToDict(gm) + except Exception: + return None + + try: + return GroundingMetadata(**raw_dict) + except Exception: + return None class PalmTextGenerativeLLM(BasicLLM): diff --git a/backend/common_libs/llm/models_utils.py b/backend/common_libs/llm/models_utils.py index 1b747b9a..6b304709 100644 --- a/backend/common_libs/llm/models_utils.py +++ b/backend/common_libs/llm/models_utils.py @@ -4,7 +4,9 @@ import vertexai from dotenv import load_dotenv +from google.genai.types import GroundingMetadata from pydantic import BaseModel + from vertexai.generative_models import HarmCategory, HarmBlockThreshold, SafetySetting from app.agent.config import AgentsConfig @@ -187,6 +189,8 @@ class LLMResponse(BaseModel): """The number of tokens in the prompt.""" response_token_count: int """The number of tokens in the response.""" + grounding_metadata: GroundingMetadata | None = None + """Grounding metadata from Google Search or other retrieval tools, when present.""" class LLM(ABC): diff --git a/backend/common_libs/llm/utils.py b/backend/common_libs/llm/utils.py new file mode 100644 index 00000000..98080c7b --- /dev/null +++ b/backend/common_libs/llm/utils.py @@ -0,0 +1,53 @@ +import logging + +from google.genai.types import GroundingMetadata + +logger = logging.getLogger(__name__) + + +def extract_grounding_metadata_from_genai_response(response) -> GroundingMetadata | None: + """ + Extract grounding metadata from a google-genai SDK response. + + Args: + response: Response object from genai SDK's generate_content + + Returns: + GroundingMetadata if found, None otherwise + """ + if not hasattr(response, "candidates") or not response.candidates: + return None + + candidate = response.candidates[0] + if not hasattr(candidate, "grounding_metadata"): + return None + + gm = candidate.grounding_metadata + if not gm: + return None + + raw_dict: dict + if isinstance(gm, dict): + raw_dict = gm + elif hasattr(gm, "model_dump"): + raw_dict = gm.model_dump() + elif hasattr(gm, "__dict__"): + raw_dict = gm.__dict__ + else: + return None + + if not isinstance(raw_dict, dict): + return None + + for key in list(raw_dict.keys()): + if raw_dict[key] is None: + if key in ["web_search_queries", "webSearchQueries", "grounding_chunks", "groundingChunks", "grounding_supports", "groundingSupports"]: + raw_dict[key] = [] + else: + del raw_dict[key] + + try: + return GroundingMetadata(**raw_dict) + except Exception as e: + logger.debug("Failed to parse grounding metadata: %s. Raw dict: %s", e, raw_dict) + return None \ No newline at end of file diff --git a/backend/common_libs/test_utilities/setup_env_vars.py b/backend/common_libs/test_utilities/setup_env_vars.py index b80a14c4..c01dd6da 100644 --- a/backend/common_libs/test_utilities/setup_env_vars.py +++ b/backend/common_libs/test_utilities/setup_env_vars.py @@ -107,6 +107,8 @@ def setup_env_vars(*, env_vars: dict[str, str] = None): 'METRICS_DATABASE_NAME': "foo", 'USERDATA_MONGODB_URI': "foo", 'USERDATA_DATABASE_NAME': "foo", + 'CAREER_EXPLORER_MONGODB_URI': "foo", + 'CAREER_EXPLORER_DATABASE_NAME': "foo", 'TAXONOMY_MODEL_ID': str(ObjectId()), 'GOOGLE_APPLICATION_CREDENTIALS': "foo", 'VERTEX_API_REGION': "foo", diff --git a/backend/conftest.py b/backend/conftest.py index 8a037676..78f04cd3 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -141,6 +141,15 @@ async def in_memory_application_database(in_memory_mongo_server) -> AsyncIOMotor return application_db +@pytest.fixture(scope='function') +async def in_memory_career_explorer_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: + career_explorer_db = AsyncIOMotorClient(in_memory_mongo_server.connection_string, + tlsAllowInvalidCertificates=True).get_database(random_db_name()) + await CompassDBProvider.initialize_career_explorer_mongo_db(career_explorer_db, logger=logging.getLogger(__name__)) + logging.info(f"Created career explorer database: {career_explorer_db.name}") + return career_explorer_db + + @pytest.fixture(scope='function') async def in_memory_metrics_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: """ diff --git a/backend/evaluation_tests/ask_me_anything_agent/__init__.py b/backend/evaluation_tests/ask_me_anything_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/evaluation_tests/ask_me_anything_agent/ask_me_anything_agent_executors.py b/backend/evaluation_tests/ask_me_anything_agent/ask_me_anything_agent_executors.py new file mode 100644 index 00000000..d1a9301d --- /dev/null +++ b/backend/evaluation_tests/ask_me_anything_agent/ask_me_anything_agent_executors.py @@ -0,0 +1,67 @@ +from app.agent.agent_types import AgentInput, AgentOutput +from app.agent.ask_me_anything_agent import AskMeAnythingAgent +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) + + +class AskMeAnythingExecutor: + """ + Executes the Ask Me Anything agent with a simple in-memory conversation history. + """ + + def __init__(self): + self._agent = AskMeAnythingAgent() + self._turns: list[ConversationTurn] = [] + self._agent_outputs: list[AgentOutput] = [] + self._is_first_call = True + + def _build_context(self) -> ConversationContext: + history = ConversationHistory(turns=list(self._turns)) + return ConversationContext(all_history=history, history=history) + + async def __call__(self, agent_input: AgentInput) -> AgentOutput: + context = self._build_context() + + if self._is_first_call and (not agent_input.message or agent_input.message.strip() == "" or agent_input.message == "(silence)"): + agent_output = await self._agent.generate_intro_message(context) + self._is_first_call = False + else: + agent_output = await self._agent.execute(agent_input, context) + self._is_first_call = False + + turn = ConversationTurn( + index=len(self._turns), + input=agent_input, + output=agent_output, + ) + self._turns.append(turn) + self._agent_outputs.append(agent_output) + return agent_output + + def get_agent_outputs(self) -> list[AgentOutput]: + """Get all agent outputs for accessing metadata.""" + return self._agent_outputs + + +class AskMeAnythingIsFinished: + """ + AMA agent conversation is open-ended, so we stop after a fixed script length. + """ + + def __call__(self, agent_output: AgentOutput) -> bool: + return agent_output.finished + + +class AskMeAnythingGetConversationContextExecutor: + """ + Returns the conversation context for the AMA eval. + """ + + def __init__(self, executor: AskMeAnythingExecutor): + self._executor = executor + + async def __call__(self) -> ConversationContext: + return self._executor._build_context() diff --git a/backend/evaluation_tests/ask_me_anything_agent/ask_me_anything_agent_scripted_user_test.py b/backend/evaluation_tests/ask_me_anything_agent/ask_me_anything_agent_scripted_user_test.py new file mode 100644 index 00000000..6e81713d --- /dev/null +++ b/backend/evaluation_tests/ask_me_anything_agent/ask_me_anything_agent_scripted_user_test.py @@ -0,0 +1,315 @@ +import logging +import os +from dataclasses import dataclass, field + +import pytest + +from app.agent.constants import PlatformRoute, VALID_PLATFORM_ROUTES +from app.i18n.translation_service import get_i18n_manager +from app.i18n.types import Locale +from evaluation_tests.conversation_libs.conversation_generator import generate +from evaluation_tests.conversation_libs.conversation_test_function import ScriptedSimulatedUser +from evaluation_tests.conversation_libs.evaluators.evaluation_result import Actor, ConversationRecord +from evaluation_tests.ask_me_anything_agent.ask_me_anything_agent_executors import ( + AskMeAnythingExecutor, + AskMeAnythingGetConversationContextExecutor, + AskMeAnythingIsFinished, +) +from app.conversation_memory.save_conversation_context import ( + save_conversation_context_to_json, + save_conversation_context_to_markdown, +) + + +@dataclass +class AMATestCase: + """Test case with scripted questions and expected routes in suggested actions.""" + name: str + scripted_user: list[str] + description: str + expected_routes_by_turn: list[list[str]] = field(default_factory=list) + """ + For each agent response, at least one route in the list must appear in suggested_actions. + Turn 0 = welcome message. Turn 1 = response to scripted_user[0], etc. + Routes should be exact paths like "/career-readiness", "/knowledge-hub", etc. + """ + + +TEST_CASES = [ + AMATestCase( + name="greeting_and_general_question", + description="User receives greeting and asks a general question", + scripted_user=[ + "What can you help me with?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_READINESS, PlatformRoute.KNOWLEDGE_HUB, PlatformRoute.SKILLS_INTERESTS, PlatformRoute.CAREER_EXPLORER], # Should suggest relevant modules + ], + ), + AMATestCase( + name="ask_about_career_readiness", + description="User asks about career readiness", + scripted_user=[ + "I want to improve my job readiness", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_READINESS], # Must suggest career readiness route + ], + ), + AMATestCase( + name="ask_about_cv_development", + description="User asks specifically about CV development", + scripted_user=[ + "How can I create a good CV?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_READINESS_CV_DEVELOPMENT], # Must suggest CV development route + ], + ), + AMATestCase( + name="ask_about_knowledge_hub", + description="User asks about knowledge hub", + scripted_user=[ + "What information is available in the knowledge hub?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.KNOWLEDGE_HUB], # Must suggest knowledge hub route + ], + ), + AMATestCase( + name="ask_about_career_explorer", + description="User asks about career explorer", + scripted_user=[ + "I want to explore different careers", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_EXPLORER], # Must suggest career explorer route + ], + ), + AMATestCase( + name="ask_about_interview_preparation", + description="User asks about interview preparation", + scripted_user=[ + "How can I prepare for job interviews?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_READINESS_INTERVIEW_PREPARATION], # Must suggest interview preparation route + ], + ), + AMATestCase( + name="ask_about_skills_interests", + description="User asks about skills and interests module", + scripted_user=[ + "How can I discover my skills?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.SKILLS_INTERESTS], # Must suggest skills & interests route + ], + ), + AMATestCase( + name="multi_turn_navigation", + description="User asks multiple questions about different modules", + scripted_user=[ + "What modules are available?", + "Tell me about interview preparation", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_READINESS, PlatformRoute.KNOWLEDGE_HUB, PlatformRoute.SKILLS_INTERESTS, PlatformRoute.CAREER_EXPLORER], # Should suggest multiple modules + [PlatformRoute.CAREER_READINESS_INTERVIEW_PREPARATION], # Must suggest interview preparation route + ], + ), + AMATestCase( + name="random_conversation", + description="User engages in random, off-topic conversation", + scripted_user=[ + "How's the weather?", + "What's your favorite color?", + "Tell me a joke", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + VALID_PLATFORM_ROUTES, # Should still suggest routes despite off-topic question + VALID_PLATFORM_ROUTES, # Should still suggest routes despite off-topic question + VALID_PLATFORM_ROUTES, # Should still suggest routes despite off-topic question + ], + ), + AMATestCase( + name="asking_for_advice", + description="User asks for career advice instead of navigation", + scripted_user=[ + "What should I do with my career?", + "I'm confused about my future, can you help?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_READINESS, PlatformRoute.CAREER_EXPLORER, PlatformRoute.SKILLS_INTERESTS], # Should suggest relevant modules for career guidance + [PlatformRoute.CAREER_READINESS, PlatformRoute.CAREER_EXPLORER, PlatformRoute.SKILLS_INTERESTS], # Should continue suggesting relevant modules + ], + ), + AMATestCase( + name="cryptic_messages", + description="User sends deliberately cryptic or unclear messages", + scripted_user=[ + "???", + "hmm", + "idk", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + VALID_PLATFORM_ROUTES, # Should handle cryptic messages gracefully and still suggest routes + VALID_PLATFORM_ROUTES, # Should handle cryptic messages gracefully + VALID_PLATFORM_ROUTES, # Should handle cryptic messages gracefully + ], + ), + AMATestCase( + name="long_conversation_history", + description="User has a long conversation with many turns", + scripted_user=[ + "What can you help with?", + "Tell me about CVs", + "What about interviews?", + "How about cover letters?", + "What's in the knowledge hub?", + "Tell me about mining", + "What about agriculture?", + "How do I explore careers?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + [PlatformRoute.CAREER_READINESS, PlatformRoute.KNOWLEDGE_HUB, PlatformRoute.SKILLS_INTERESTS, PlatformRoute.CAREER_EXPLORER], + [PlatformRoute.CAREER_READINESS_CV_DEVELOPMENT], + [PlatformRoute.CAREER_READINESS_INTERVIEW_PREPARATION], + [PlatformRoute.CAREER_READINESS_COVER_LETTER], + [PlatformRoute.KNOWLEDGE_HUB], + [PlatformRoute.KNOWLEDGE_HUB_MINING_PATHWAY], + [PlatformRoute.KNOWLEDGE_HUB_AGRICULTURE_PATHWAY], + [PlatformRoute.CAREER_EXPLORER], + ], + ), + AMATestCase( + name="asking_about_nonexistent_feature", + description="User asks about features that don't exist", + scripted_user=[ + "Do you have a job board?", + "Can you help me find a job?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + VALID_PLATFORM_ROUTES, # Should not suggest non-existent routes, but should still provide helpful response with valid routes + VALID_PLATFORM_ROUTES, # Should redirect to available modules + ], + ), + AMATestCase( + name="empty_and_whitespace_messages", + description="User sends empty or whitespace-only messages", + scripted_user=[ + "", + " ", + "What can you help with?", + ], + expected_routes_by_turn=[ + VALID_PLATFORM_ROUTES, # Welcome message can suggest any valid route + VALID_PLATFORM_ROUTES, # Should handle empty messages gracefully + VALID_PLATFORM_ROUTES, # Should handle whitespace gracefully + [PlatformRoute.CAREER_READINESS, PlatformRoute.KNOWLEDGE_HUB, PlatformRoute.SKILLS_INTERESTS, PlatformRoute.CAREER_EXPLORER], + ], + ), +] + + +def _extract_suggested_routes(agent_output) -> list[str]: + """Extract route strings from agent output metadata.""" + if not agent_output.metadata: + return [] + raw = agent_output.metadata.get("suggested_actions", []) + routes = [] + for item in raw: + if isinstance(item, dict) and "route" in item: + routes.append(item["route"]) + return routes + + +def _assert_ama_routes(executor: AskMeAnythingExecutor, test_case: AMATestCase) -> None: + """Assert that agent responses include expected routes in suggested_actions.""" + agent_outputs = executor.get_agent_outputs() + assert len(agent_outputs) >= len(test_case.expected_routes_by_turn), ( + f"Expected at least {len(test_case.expected_routes_by_turn)} agent responses, " + f"got {len(agent_outputs)}" + ) + for i, expected_routes in enumerate(test_case.expected_routes_by_turn): + if i >= len(agent_outputs): + break + agent_output = agent_outputs[i] + suggested_routes = _extract_suggested_routes(agent_output) + found = any(route in suggested_routes for route in expected_routes) + user_message = "" + if i > 0 and i - 1 < len(test_case.scripted_user): + user_message = f" (in response to '{test_case.scripted_user[i - 1]}')" + assert found, ( + f"Agent response {i}{user_message} should suggest at least one of {expected_routes} " + f"but got suggested routes: {suggested_routes}" + ) + + +@pytest.mark.asyncio +@pytest.mark.evaluation_test("gemini-2.5-flash-lite/") +@pytest.mark.parametrize("test_case", TEST_CASES, ids=[tc.name for tc in TEST_CASES]) +async def test_ask_me_anything_agent( + evals_setup, setup_multi_locale_app_config, test_case: AMATestCase +): + """ + Scripted conversation test for the Ask Me Anything agent. + Asserts agent responses are helpful and suggest relevant platform modules. + """ + logging.info("Running AMA test case: %s", test_case.name) + get_i18n_manager().set_locale(Locale.EN_US) + + executor = AskMeAnythingExecutor() + max_iterations = len(test_case.scripted_user) + 1 + + conversation = await generate( + max_iterations=max_iterations, + execute_evaluated_agent=executor, + execute_simulated_user=ScriptedSimulatedUser(script=test_case.scripted_user), + is_finished=AskMeAnythingIsFinished(), + ) + + _assert_ama_routes(executor, test_case) + + output_folder = os.path.join( + os.getcwd(), + "test_output", + "ask_me_anything_agent", + "scripted", + test_case.name, + ) + os.makedirs(output_folder, exist_ok=True) + + from datetime import datetime, timezone + time_now = datetime.now(timezone.utc).isoformat() + base_name = f"{test_case.name}_{time_now}" + + from evaluation_tests.conversation_libs.evaluators.evaluation_result import ConversationEvaluationRecord + record = ConversationEvaluationRecord( + simulated_user_prompt=test_case.description, + test_case=test_case.name, + ) + record.add_conversation_records(conversation) + record.save_data(folder=output_folder, base_file_name=base_name) + + context = await AskMeAnythingGetConversationContextExecutor(executor)() + ctx_path = os.path.join(output_folder, f"{base_name}_context") + save_conversation_context_to_json(context=context, file_path=ctx_path + ".json") + save_conversation_context_to_markdown( + title=f"Ask Me Anything: {test_case.name}", + context=context, + file_path=ctx_path + ".md", + ) diff --git a/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_executors.py b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_executors.py new file mode 100644 index 00000000..efbf9f56 --- /dev/null +++ b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_executors.py @@ -0,0 +1,127 @@ +from app.agent.agent_types import AgentInput, AgentOutput +from app.agent.career_explorer_agent.agent import CareerExplorerAgent +from app.agent.career_explorer_agent.sector_search_service import SectorChunkEntity, SectorSearchService +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) + + +class MockSectorSearchService: + """ + Mock search that returns predefined chunks based on query keywords. + Content matches the embedded markdown files (agriculture.md, mining.md, etc.). + """ + + _SECTOR_CHUNKS = { + "agriculture": [ + "Agriculture is the largest employer in Zambia. The sector needs professionals who can manage irrigation, crop health, aquaculture, and machinery. Roles include Agricultural Extensionist, Horticulturist, Aquaculture Technician, Farm Machinery Operator. Commercial farms, agri-processing companies like Zambeef, and aquaculture farms are key employers. Central Province is a major commercial farming hub. TEVET qualifications help access higher-paying commercial roles.", + "Skilled Technicians in Irrigation or Machinery earn K4,000 to K8,000 monthly. Precision Agriculture uses drones and data for crop management. Climate-Smart Agriculture techniques help farm sustainably. Value Addition processing turns raw produce into finished goods like peanut butter and jams.", + ], + "mining": [ + "The mining sector is the economic backbone of Zambia. Roles include Heavy Equipment Repair, Driller/Blaster, Mining Surveyor, Ventilation Technician, Geologist. Copperbelt Province has 58.9% of mining employment. Large-scale mining consortiums include Mopani, KCM, FQM, Barrick. Over 48% of mining employees earn above K7,500 per month. Heavy Equipment Repair has a critical shortage of mechanics. Gemstone mines in Lufwanyama extract emeralds.", + "Skilled Artisans and Technicians in mining earn K6,300 to K15,000+ monthly. The sector offers some of the highest earning potential in Zambia for skilled technical professionals. Mining contractor firms supply equipment maintenance and drilling services.", + ], + "energy": [ + "The energy sector in Zambia includes power generation, solar, and renewables. Roles for TEVET graduates include solar technicians, electrical technicians, and power plant operators. The sector is growing with renewable energy projects.", + ], + "hospitality": [ + "Hospitality covers hotels, tourism, and safari lodges. TEVET graduates can work as chefs, hotel front desk staff, tour guides, and lodge attendants. The sector supports Zambia's tourism industry.", + ], + "water": [ + "The water sector includes treatment, supply, and sanitation. Roles include Water Treatment Plant Operator, Plumbing Technician, and Sanitation Technician. Urban and rural water supply projects create demand for skilled workers.", + ], + } + + # mock search implementation that returns relevant chunks from the predefined content based on query keywords + async def search( + self, + *, + query: str | list[float], + filter_spec=None, + k: int = 5, + sector=None, + ) -> list[SectorChunkEntity]: + if isinstance(query, list): + return [] + q = query.lower().strip() + if not q: + return [] + + chunks = [] + for sector_key, texts in self._SECTOR_CHUNKS.items(): + if sector_key in q or any(w in q for w in sector_key.split()): + for j, text in enumerate(texts[:2]): + chunks.append( + SectorChunkEntity( + chunk_id=f"{sector_key}_{j}", + sector=sector_key.title(), + text=text, + score=0.9 - j * 0.1, + ) + ) + break + + if not chunks: + fallback = ( + "Energy covers power generation and solar. Mining includes copper and gemstones. " + "Agriculture involves commercial farming. Hospitality covers hotels and tourism. " + "Water covers treatment and sanitation. Which sector interests you?" + ) + chunks = [ + SectorChunkEntity( + chunk_id="general_0", + sector="General", + text=fallback, + score=0.8, + ) + ] + return chunks[:k] + + +class CareerExplorerExecutor: + """ + Executes the Career Explorer agent with a simple in-memory conversation history. + """ + + def __init__(self, sector_search_service: SectorSearchService | MockSectorSearchService | None = None): + self._search = sector_search_service or MockSectorSearchService() + self._agent = CareerExplorerAgent(sector_search_service=self._search) + self._turns: list[ConversationTurn] = [] + + def _build_context(self) -> ConversationContext: + history = ConversationHistory(turns=list(self._turns)) + return ConversationContext(all_history=history, history=history, summary="") + + async def __call__(self, agent_input: AgentInput) -> AgentOutput: + context = self._build_context() + agent_output = await self._agent.execute(agent_input, context) + turn = ConversationTurn( + index=len(self._turns) + 1, + input=agent_input, + output=agent_output, + ) + self._turns.append(turn) + return agent_output + + +class CareerExplorerIsFinished: + """ + Career Explorer has no definitive end; we stop after a fixed script length. + """ + + def __call__(self, agent_output: AgentOutput) -> bool: + return agent_output.finished + + +class CareerExplorerGetConversationContextExecutor: + """ + Returns the conversation context for the Career Explorer eval. + """ + + def __init__(self, executor: CareerExplorerExecutor): + self._executor = executor + + async def __call__(self) -> ConversationContext: + return self._executor._build_context() diff --git a/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_scripted_user_test.py b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_scripted_user_test.py new file mode 100644 index 00000000..ebe53880 --- /dev/null +++ b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_scripted_user_test.py @@ -0,0 +1,211 @@ +import logging +import os +from dataclasses import dataclass, field + +import pytest + +from app.app_config import get_application_config, set_application_config +from app.career_explorer.config import CareerExplorerConfig +from app.i18n.translation_service import get_i18n_manager +from app.i18n.types import Locale +from evaluation_tests.conversation_libs.conversation_generator import generate +from evaluation_tests.conversation_libs.conversation_test_function import ScriptedSimulatedUser +from evaluation_tests.conversation_libs.evaluators.evaluation_result import Actor, ConversationRecord +from evaluation_tests.career_explorer_agent.career_explorer_agent_executors import ( + CareerExplorerExecutor, + CareerExplorerGetConversationContextExecutor, + CareerExplorerIsFinished, +) +from app.conversation_memory.save_conversation_context import ( + save_conversation_context_to_json, + save_conversation_context_to_markdown, +) + +DEFAULT_SECTORS = [ + {"name": "Agriculture", "description": "Commercial farming", "file": "agriculture.md"}, + {"name": "Mining", "description": "Copper, gold", "file": "mining.md"}, +] + + +@dataclass +class SectorContentTestCase: + """Test case with scripted questions and expected phrases from RAG content.""" + name: str + scripted_user: list[str] + description: str + expected_phrases_by_turn: list[list[str]] = field(default_factory=list) + """ + For each agent response, at least one phrase in the list must appear. + Turn 0 = welcome message. Turn 1 = response to scripted_user[0], etc. + """ + + +TEST_CASES = [ + SectorContentTestCase( + name="ask_agriculture", + description="User asks about Agriculture sector", + scripted_user=[ + "Tell me about Agriculture", + ], + expected_phrases_by_turn=[ + ["Welcome", "priority sectors", "sectors", "careers", "explore"], + ["Agriculture", "irrigation", "Zambeef", "TEVET", "commercial", "aquaculture", "crop"], + ], + ), + SectorContentTestCase( + name="ask_mining", + description="User asks about Mining sector", + scripted_user=[ + "What roles are there in mining?", + ], + expected_phrases_by_turn=[ + ["Welcome", "priority sectors", "sectors", "careers", "explore"], + ["Mining", "Copperbelt", "Barrick", "Heavy Equipment", "gemstone", "K7,500", "Driller"], + ], + ), + SectorContentTestCase( + name="ask_agriculture_then_mining", + description="User asks about Agriculture then Mining", + scripted_user=[ + "I'm interested in Agriculture", + "What about mining?", + ], + expected_phrases_by_turn=[ + ["Welcome", "priority sectors", "sectors", "careers", "explore"], + ["Agriculture", "irrigation", "Zambeef", "TEVET", "commercial", "aquaculture"], + ["Mining", "Copperbelt", "Barrick", "Heavy Equipment", "gemstone", "K7,500"], + ], + ), + SectorContentTestCase( + name="ask_mining_then_reference_previous", + description="User asks about mining, then references previous conversation", + scripted_user=[ + "What roles are there in mining?", + "Tell me more about the roles you mentioned", + ], + expected_phrases_by_turn=[ + ["Welcome", "priority sectors", "sectors", "careers", "explore"], + ["Mining", "roles", "Heavy Equipment", "Driller", "Copperbelt"], + ["roles", "mining", "Heavy Equipment", "Driller", "mentioned"], + ], + ), + SectorContentTestCase( + name="ask_agriculture_then_ask_specific_followup", + description="User asks about agriculture, then asks a specific follow-up question", + scripted_user=[ + "I'm interested in Agriculture", + "What skills do I need for the roles you mentioned?", + ], + expected_phrases_by_turn=[ + ["Welcome", "priority sectors", "sectors", "careers", "explore"], + ["Agriculture", "irrigation", "Zambeef", "TEVET", "commercial", "aquaculture"], + ["skills", "roles", "Agriculture", "irrigation", "TEVET"], + ], + ), + SectorContentTestCase( + name="multi_turn_context_preservation", + description="Multi-turn conversation testing context preservation", + scripted_user=[ + "Tell me about mining", + "What about the salary you mentioned?", + "And what about agriculture?", + ], + expected_phrases_by_turn=[ + ["Welcome", "priority sectors", "sectors", "careers", "explore"], + ["Mining", "Copperbelt", "Barrick", "Heavy Equipment", "gemstone", "K7,500"], + ["salary", "K7,500", "earn", "mining"], + ["Agriculture", "irrigation", "Zambeef", "TEVET", "commercial"], + ], + ), +] + + +def _agent_responses(conversation: list[ConversationRecord]) -> list[str]: + return [r.message for r in conversation if r.actor == Actor.EVALUATED_AGENT] + + +def _assert_rag_content(conversation: list[ConversationRecord], test_case: SectorContentTestCase) -> None: + agent_responses = _agent_responses(conversation) + assert len(agent_responses) >= len(test_case.expected_phrases_by_turn), ( + f"Expected at least {len(test_case.expected_phrases_by_turn)} agent responses, " + f"got {len(agent_responses)}" + ) + for i, expected_phrases in enumerate(test_case.expected_phrases_by_turn): + if i >= len(agent_responses): + break + response_text = agent_responses[i].lower() + found = any(phrase.lower() in response_text for phrase in expected_phrases) + excerpt = agent_responses[i][:400] + "..." if len(agent_responses[i]) > 400 else agent_responses[i] + user_message = "" + if i > 0 and i - 1 < len(test_case.scripted_user): + user_message = f" (in response to '{test_case.scripted_user[i - 1]}')" + assert found, ( + f"Agent response {i}{user_message} should contain " + f"at least one of {expected_phrases} but got: {excerpt}" + ) + + +@pytest.fixture +def career_explorer_config_with_sectors(setup_multi_locale_app_config): + config = get_application_config() + updated = config.model_copy( + update={"career_explorer_config": CareerExplorerConfig(sectors=DEFAULT_SECTORS, country="Zambia")} + ) + set_application_config(updated) + yield updated + + +@pytest.mark.asyncio +@pytest.mark.evaluation_test("gemini-2.5-flash-lite/") +@pytest.mark.parametrize("test_case", TEST_CASES, ids=[tc.name for tc in TEST_CASES]) +async def test_career_explorer_sector_content( + evals_setup, setup_multi_locale_app_config, career_explorer_config_with_sectors, test_case: SectorContentTestCase +): + """ + Scripted conversation test. Asserts agent responses include content from the + embedded sector markdown files (simulated via mock RAG). + """ + logging.info("Running Career Explorer test case: %s", test_case.name) + get_i18n_manager().set_locale(Locale.EN_US) + + executor = CareerExplorerExecutor() + max_iterations = len(test_case.scripted_user) + 1 + + conversation = await generate( + max_iterations=max_iterations, + execute_evaluated_agent=executor, + execute_simulated_user=ScriptedSimulatedUser(script=test_case.scripted_user), + is_finished=CareerExplorerIsFinished(), + ) + + _assert_rag_content(conversation, test_case) + + output_folder = os.path.join( + os.getcwd(), + "test_output", + "career_explorer_agent", + "scripted", + test_case.name, + ) + os.makedirs(output_folder, exist_ok=True) + + from datetime import datetime, timezone + time_now = datetime.now(timezone.utc).isoformat() + base_name = f"{test_case.name}_{time_now}" + + from evaluation_tests.conversation_libs.evaluators.evaluation_result import ConversationEvaluationRecord + record = ConversationEvaluationRecord( + simulated_user_prompt=test_case.description, + test_case=test_case.name, + ) + record.add_conversation_records(conversation) + record.save_data(folder=output_folder, base_file_name=base_name) + + context = await CareerExplorerGetConversationContextExecutor(executor)() + ctx_path = os.path.join(output_folder, f"{base_name}_context") + save_conversation_context_to_json(context=context, file_path=ctx_path + ".json") + save_conversation_context_to_markdown( + title=f"Career Explorer: {test_case.name}", + context=context, + file_path=ctx_path + ".md", + ) diff --git a/backend/evaluation_tests/career_explorer_agent/non_priority_sector_explorer_eval_test.py b/backend/evaluation_tests/career_explorer_agent/non_priority_sector_explorer_eval_test.py new file mode 100644 index 00000000..4b077901 --- /dev/null +++ b/backend/evaluation_tests/career_explorer_agent/non_priority_sector_explorer_eval_test.py @@ -0,0 +1,117 @@ +""" +Evaluation tests for NonPrioritySectorExplorer. +Uses CriteriaEvaluator (LLM) to assess that web search responses for non-priority sectors +are substantive, informative, and stay on career-related topics. +""" + +import logging +from dataclasses import dataclass + +import pytest + +from app.app_config import get_application_config, set_application_config +from app.career_explorer.config import CareerExplorerConfig +from app.i18n.translation_service import get_i18n_manager +from app.i18n.types import Locale +from evaluation_tests.conversation_libs.evaluators.criteria_evaluator import CriteriaEvaluator +from evaluation_tests.conversation_libs.evaluators.evaluation_result import ( + Actor, + ConversationEvaluationRecord, + ConversationRecord, + EvaluationType, +) +from evaluation_tests.conversation_libs.fake_conversation_context import FakeConversationContext +from app.agent.career_explorer_agent.non_priority_sector_explorer import NonPrioritySectorExplorer + +DEFAULT_SECTORS = [ + {"name": "Agriculture", "description": "Commercial farming", "file": "agriculture.md"}, + {"name": "Mining", "description": "Copper, gold", "file": "mining.md"}, +] + +MIN_LLM_SCORE = 60 + + +@dataclass +class NonPriorityExplorerTestCase: + name: str + user_input: str + + +NON_PRIORITY_EXPLORER_TEST_CASES = [ + NonPriorityExplorerTestCase("aeronautical_engineering", "I want to talk about aeronautical engineering"), + NonPriorityExplorerTestCase("software_development", "What about careers in software development?"), +] + + +@pytest.fixture +def career_explorer_config_with_sectors(setup_multi_locale_app_config): + config = get_application_config() + updated = config.model_copy( + update={"career_explorer_config": CareerExplorerConfig(sectors=DEFAULT_SECTORS, country="Zambia")} + ) + set_application_config(updated) + yield updated + + +@pytest.mark.asyncio +@pytest.mark.evaluation_test("gemini-2.5-flash-lite/") +@pytest.mark.parametrize("test_case", NON_PRIORITY_EXPLORER_TEST_CASES, ids=[tc.name for tc in NON_PRIORITY_EXPLORER_TEST_CASES]) +async def test_non_priority_sector_explorer_web_search( + evals_setup, + career_explorer_config_with_sectors, + test_case: NonPriorityExplorerTestCase, +): + """ + Non-priority explorer uses Google Search. Uses CriteriaEvaluator (LLM) to assess response quality: + substantive career information, no deflection to priority sectors. + """ + get_i18n_manager().set_locale(Locale.EN_US) + + context = FakeConversationContext() + context.add_turn("", "Welcome! Which sector interests you?") + context.add_turn(test_case.user_input, "") + + explorer = NonPrioritySectorExplorer() + message, finished, _, _, grounding_metadata = await explorer.explore( + user_input=test_case.user_input, + context=context, + ) + + evaluation_record = ConversationEvaluationRecord( + test_case=test_case.name, + simulated_user_prompt=test_case.user_input, + conversation=[ + ConversationRecord(message="Welcome! Which sector interests you?", actor=Actor.SIMULATED_USER), + ConversationRecord(message=test_case.user_input, actor=Actor.SIMULATED_USER), + ConversationRecord(message=message, actor=Actor.EVALUATED_AGENT), + ], + ) + + evaluator = CriteriaEvaluator(criteria=EvaluationType.NON_PRIORITY_SECTOR_RESPONSE_QUALITY) + eval_result = await evaluator.evaluate(evaluation_record) + + logging.info( + "Non-priority explorer test %s: response message (first 500 chars): %s", + test_case.name, + message[:500] if message else "(empty)", + ) + if grounding_metadata: + logging.info( + "Non-priority explorer test %s: got %d sources from web search, LLM score=%d (%s)", + test_case.name, + len(grounding_metadata.grounding_chunks), + eval_result.score, + eval_result.reasoning[:100], + ) + else: + logging.info( + "Non-priority explorer test %s: LLM score=%d (%s)", + test_case.name, + eval_result.score, + eval_result.reasoning[:100], + ) + + assert eval_result.score >= MIN_LLM_SCORE, ( + f"Expected LLM evaluation score >= {MIN_LLM_SCORE} for substantive career response, " + f"got {eval_result.score}. Reasoning: {eval_result.reasoning}" + ) diff --git a/backend/evaluation_tests/career_explorer_agent/priority_sector_explorer_eval_test.py b/backend/evaluation_tests/career_explorer_agent/priority_sector_explorer_eval_test.py new file mode 100644 index 00000000..46370b55 --- /dev/null +++ b/backend/evaluation_tests/career_explorer_agent/priority_sector_explorer_eval_test.py @@ -0,0 +1,104 @@ +""" +Evaluation tests for PrioritySectorExplorer. +Asserts RAG-based responses contain expected content from sector documents. +""" + +import logging +from dataclasses import dataclass + +import pytest + +from app.app_config import get_application_config, set_application_config +from app.career_explorer.config import CareerExplorerConfig +from app.i18n.translation_service import get_i18n_manager +from app.i18n.types import Locale +from evaluation_tests.conversation_libs.fake_conversation_context import FakeConversationContext +from evaluation_tests.career_explorer_agent.career_explorer_agent_executors import MockSectorSearchService +from app.agent.career_explorer_agent.priority_sector_explorer import PrioritySectorExplorer + +DEFAULT_SECTORS = [ + {"name": "Agriculture", "description": "Commercial farming", "file": "agriculture.md"}, + {"name": "Energy", "description": "Power generation", "file": "energy.md"}, + {"name": "Mining", "description": "Copper, gold", "file": "mining.md"}, + {"name": "Hospitality", "description": "Hotels, tourism", "file": "hospitality.md"}, + {"name": "Water", "description": "Treatment, supply", "file": "water.md"}, +] + + +@dataclass +class PriorityExplorerTestCase: + name: str + user_input: str + expected_phrases: list[str] + + +PRIORITY_EXPLORER_TEST_CASES = [ + PriorityExplorerTestCase( + "agriculture", + "Tell me about Agriculture", + ["Agriculture", "irrigation", "Zambeef", "TEVET", "commercial", "aquaculture", "crop"], + ), + PriorityExplorerTestCase( + "mining", + "What roles are there in mining?", + ["Mining", "Copperbelt", "Barrick", "Heavy Equipment", "gemstone", "K7,500", "Driller"], + ), + PriorityExplorerTestCase( + "hospitality", + "What are the hospitality sectors?", + ["Hospitality", "tourism", "hotel", "restaurant", "bar", "lodge", "guesthouse"], + ), + PriorityExplorerTestCase( + "water", + "What are the water sectors?", + ["Water", "treatment", "supply", "wastewater", "drainage", "river", "lake"], + ), + PriorityExplorerTestCase( + "energy", + "What are the energy sectors?", + ["Energy", "power", "generation", "renewable", "fossil", "hydro", "solar"], + ), + PriorityExplorerTestCase( + "non-priority-sector", + "What are the non-priority sectors?", + ["Non-priority", "sectors", "not", "covered", "by", "the", "priority", "sectors"], + ), +] + + +@pytest.fixture +def career_explorer_config_with_sectors(setup_multi_locale_app_config): + config = get_application_config() + updated = config.model_copy( + update={"career_explorer_config": CareerExplorerConfig(sectors=DEFAULT_SECTORS, country="Zambia")} + ) + set_application_config(updated) + yield updated + + +@pytest.mark.asyncio +@pytest.mark.evaluation_test("gemini-2.5-flash-lite/") +@pytest.mark.parametrize("test_case", PRIORITY_EXPLORER_TEST_CASES, ids=[tc.name for tc in PRIORITY_EXPLORER_TEST_CASES]) +async def test_priority_sector_explorer_rag_content( + evals_setup, + career_explorer_config_with_sectors, + test_case: PriorityExplorerTestCase, +): + get_i18n_manager().set_locale(Locale.EN_US) + + context = FakeConversationContext() + context.add_turn("", "Welcome! Which sector interests you?") + context.add_turn(test_case.user_input, "") + + mock_search = MockSectorSearchService() + explorer = PrioritySectorExplorer(sector_search_service=mock_search) + + message, _, _, _ = await explorer.explore(user_input=test_case.user_input, context=context) + + response_lower = message.lower() + found = any(phrase.lower() in response_lower for phrase in test_case.expected_phrases) + assert found, ( + f"Priority sector response should contain at least one of {test_case.expected_phrases} " + f"but got: {message[:400]}..." + ) + logging.info("Priority explorer test %s: found expected content in response", test_case.name) diff --git a/backend/evaluation_tests/career_explorer_agent/sector_relevance_classifier_eval_test.py b/backend/evaluation_tests/career_explorer_agent/sector_relevance_classifier_eval_test.py new file mode 100644 index 00000000..7e319d29 --- /dev/null +++ b/backend/evaluation_tests/career_explorer_agent/sector_relevance_classifier_eval_test.py @@ -0,0 +1,80 @@ +""" +Evaluation tests for SectorRelevanceClassifier. +Asserts the LLM correctly classifies user input as PRIORITY_SECTOR or NON_PRIORITY_SECTOR. +""" + +import logging +from dataclasses import dataclass + +import pytest + +from app.app_config import get_application_config, set_application_config +from app.career_explorer.config import CareerExplorerConfig +from app.i18n.translation_service import get_i18n_manager +from app.i18n.types import Locale +from evaluation_tests.conversation_libs.fake_conversation_context import FakeConversationContext +from app.agent.career_explorer_agent.sector_relevance_classifier import ( + SectorRelevance, + SectorRelevanceClassifier, +) + +DEFAULT_SECTORS = [ + {"name": "Agriculture", "description": "Commercial farming", "file": "agriculture.md"}, + {"name": "Energy", "description": "Power generation", "file": "energy.md"}, + {"name": "Mining", "description": "Copper, gold", "file": "mining.md"}, + {"name": "Hospitality", "description": "Hotels, tourism", "file": "hospitality.md"}, + {"name": "Water", "description": "Treatment, supply", "file": "water.md"}, +] + + +@dataclass +class ClassifierTestCase: + name: str + user_input: str + expected_relevance: SectorRelevance + + +CLASSIFIER_TEST_CASES = [ + ClassifierTestCase("agriculture", "Tell me about Agriculture", SectorRelevance.PRIORITY_SECTOR), + ClassifierTestCase("mining", "What roles are there in mining?", SectorRelevance.PRIORITY_SECTOR), + ClassifierTestCase("energy_solar", "I want to know about solar careers", SectorRelevance.PRIORITY_SECTOR), + ClassifierTestCase("water", "Careers in water treatment?", SectorRelevance.PRIORITY_SECTOR), + ClassifierTestCase("hospitality", "What about tourism jobs?", SectorRelevance.PRIORITY_SECTOR), + ClassifierTestCase("aeronautical", "I want to talk about aeronautical engineering", SectorRelevance.NON_PRIORITY_SECTOR), + ClassifierTestCase("it_software", "What about software development careers?", SectorRelevance.NON_PRIORITY_SECTOR), + ClassifierTestCase("healthcare", "Careers in nursing or healthcare?", SectorRelevance.NON_PRIORITY_SECTOR), + ClassifierTestCase("general_career", "How do I find a job?", SectorRelevance.NON_PRIORITY_SECTOR), +] + + +@pytest.fixture +def career_explorer_config_with_sectors(setup_multi_locale_app_config): + config = get_application_config() + updated = config.model_copy( + update={"career_explorer_config": CareerExplorerConfig(sectors=DEFAULT_SECTORS, country="Zambia")} + ) + set_application_config(updated) + yield updated + + +@pytest.mark.asyncio +@pytest.mark.evaluation_test("gemini-2.5-flash-lite/") +@pytest.mark.parametrize("test_case", CLASSIFIER_TEST_CASES, ids=[tc.name for tc in CLASSIFIER_TEST_CASES]) +async def test_sector_relevance_classifier( + evals_setup, + career_explorer_config_with_sectors, + test_case: ClassifierTestCase, +): + get_i18n_manager().set_locale(Locale.EN_US) + + context = FakeConversationContext() + context.add_turn("", "Welcome! Which sector interests you?") + context.add_turn(test_case.user_input, "") + + classifier = SectorRelevanceClassifier() + relevance, _reasoning, _ = await classifier.classify(user_input=test_case.user_input, context=context) + + assert relevance == test_case.expected_relevance, ( + f"For '{test_case.user_input}' expected {test_case.expected_relevance.value} but got {relevance.value}" + ) + logging.info("Classifier test %s: %s -> %s (correct)", test_case.name, test_case.user_input[:40], relevance.value) 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 1adf65b8..1e17d907 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 @@ -128,7 +128,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Venta de Zapatos', company='Mercado Local', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -141,7 +141,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": ContainsString("2019"), "end_date": AnyOf('', None, "Present"), - "work_type": 'SELF_EMPLOYMENT' + "work_type": 'UNSEEN_UNPAID' }, ] ), @@ -160,7 +160,7 @@ class _TestCaseDataExtraction(CompassTestCase): collected_data_so_far=[ CollectedData(index=0, experience_title='Venta de Zapatos', company='Mercado Local', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=-1, # The experience should be deleted expected_collected_data_count=0 @@ -204,7 +204,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Asistente de ventas', company='Local de mi viejo', location=None, start_date=None, end_date=None, - paid_work=None, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT') + paid_work=None, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, 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 13301341..f2c0e98a 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 @@ -136,7 +136,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": '2010', "end_date": '2018', "work_type": - AnyOf(None, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name) + AnyOf(None, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name) }, {"index": 1, "defined_at_turn_number": 1, @@ -147,7 +147,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": '2018', "end_date": '2020', "work_type": - AnyOf(None, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name) + AnyOf(None, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name) } ] @@ -167,7 +167,7 @@ class _TestCaseDataExtraction(CompassTestCase): company=None, location=None, start_date='06/2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -181,7 +181,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": ContainsString("06/2020"), "end_date": AnyOf(None, "Present"), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -203,7 +203,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Freelancing', company=None, location=None, start_date='06/2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -217,7 +217,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": ContainsString("06/2020"), "end_date": AnyOf(None, "Present"), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -247,7 +247,7 @@ class _TestCaseDataExtraction(CompassTestCase): company=None, location=None, start_date='06/2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -261,7 +261,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": ContainsString("06/2020"), "end_date": AnyOf(None, "Present"), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -281,7 +281,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Selling Shoes', company='Local Market', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=AnyOf(0, -1), expected_collected_data_count=1, @@ -295,7 +295,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": AnyOf('', None), "end_date": AnyOf('', None), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -314,7 +314,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Selling Shoes', company='Local Market', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -327,7 +327,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": ContainsString("2019"), "end_date": AnyOf('', None, "Present"), - "work_type": 'SELF_EMPLOYMENT' + "work_type": 'UNSEEN_UNPAID' }, ] ), @@ -346,7 +346,7 @@ class _TestCaseDataExtraction(CompassTestCase): collected_data_so_far=[ CollectedData(index=0, experience_title='Selling Shoes', company='Local Market', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=-1, # The experience should be deleted expected_collected_data_count=0 @@ -460,12 +460,12 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, experience_title='delivery job', company='Uber Eats', location='Paris', start_date='2021/01', end_date='2023/03', paid_work=True, - work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, experience_title='Selling old furniture', company='Flea Market of rue Jean Henri Fabre', location='15th arrondissement, near the Eiffel Tower', start_date='2019', end_date='Present', paid_work=True, - work_type='SELF_EMPLOYMENT') + work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=-1, expected_collected_data_count=2 @@ -488,7 +488,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Freelance Work', company=None, location=None, start_date=None, end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT'), + paid_work=True, work_type='UNSEEN_UNPAID'), ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -502,7 +502,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": '06/2020', "end_date": ContainsString('present'), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] @@ -530,11 +530,11 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=2, experience_title='Project Manager', company='University of Oxford', location='Remote', start_date='2018', end_date='2020', paid_work=True, - work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, defined_at_turn_number=6, experience_title='Software Architect', company='ProUbis GmbH', location='Berlin', start_date='2010', end_date='2018', paid_work=True, - work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=2, defined_at_turn_number=9, experience_title='Software Developer', company='Ubis GmbH', location='Berlin', start_date='1998', end_date='', paid_work=False, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK') @@ -695,7 +695,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2020', "end_date": '2022', - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name }, {"index": 1, "defined_at_turn_number": 1, @@ -705,7 +705,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2023', "end_date": "Present", - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ] ), @@ -725,7 +725,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Cashier', company='Walmart', location=None, start_date='2023', end_date=None, - paid_work=True, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT') + paid_work=True, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK') ], expected_last_referenced_experience_index=0, # Should reference the updated experience expected_collected_data_count=2, # Should have both experiences @@ -738,7 +738,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2022', "end_date": '2023', - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name }, {"index": 1, "defined_at_turn_number": 2, # New experience gets current turn number @@ -768,11 +768,11 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Waiter', company='Restaurant', location=None, start_date=None, end_date=None, - paid_work=True, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + paid_work=True, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, defined_at_turn_number=1, experience_title='Freelance Writing', company=None, location=None, start_date='2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, # Should reference the updated writing experience expected_collected_data_count=1, # Should have only the writing experience (waiter deleted) @@ -785,7 +785,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2020', "end_date": AnyOf(None, ContainsString('Present')), - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ] ), @@ -814,7 +814,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": AnyOf(None, ''), "end_date": AnyOf(None, ''), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name }, {"index": 1, "defined_at_turn_number": 1, @@ -862,11 +862,11 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Teacher', company='School', location=None, start_date=None, end_date=None, - paid_work=True, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + paid_work=True, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, defined_at_turn_number=1, experience_title='Consulting', company=None, location=None, start_date=None, end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, # Should reference the updated consulting experience expected_collected_data_count=2, # Should have consulting (updated) and photography (new), teaching deleted @@ -879,7 +879,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": AnyOf(None, ContainsString('2020')), "end_date": AnyOf(None, ContainsString('2022')), - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name }, {"index": 1, "defined_at_turn_number": 2, # New experience gets current turn number @@ -889,7 +889,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": '2021', "end_date": AnyOf(None, ContainsString('Present')), - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ] ), @@ -925,7 +925,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": '06/2020', "end_date": ContainsString("Present"), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 1, @@ -936,7 +936,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "01/2018", "end_date": "05/2020", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 2, @@ -947,7 +947,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2016", "end_date": "2018", - "work_type": AnyOf(None, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(None, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, { "index": 3, @@ -958,7 +958,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2014", "end_date": "2014", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 4, @@ -969,7 +969,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2013", "end_date": "2013", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 5, @@ -980,7 +980,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2013", "end_date": "2013", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 6, @@ -991,7 +991,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2012", "end_date": "2012", - "work_type": AnyOf(WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, None), + "work_type": AnyOf(WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, None), }, ], ) diff --git a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py index d628b5ed..767a3011 100644 --- a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py +++ b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py @@ -62,8 +62,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2)}, matchers=["llm", "matcher"], @@ -87,8 +87,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2)}, matchers=["llm"], @@ -157,13 +157,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1)}, + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1)}, expected_experience_data=[ {"experience_title": ContainsString("project manager"), "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": {"start": "2018", "end": "2020"}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, ] ), @@ -180,8 +180,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe expected_experiences_count_min=0, expected_experiences_count_max=0, country_of_user=Country.UNSPECIFIED, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0) }, @@ -197,8 +197,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe expected_experiences_count_min=1, expected_experiences_count_max=1, country_of_user=Country.UNSPECIFIED, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0) }, @@ -207,7 +207,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": {"start": "2018", "end": "2020"}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }] ), CollectExperiencesAgentTestCase( @@ -232,8 +232,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=7, expected_experiences_count_max=7, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (2, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), + WorkType.UNSEEN_UNPAID: (2, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (2, 2) }, @@ -242,25 +242,25 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": DictContaining({"start": "2018", "end": "2020"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("software architect"), "location": ContainsString("Berlin"), "company": ContainsString("ProUbis GmbH"), "timeline": DictContaining({"start": "2010", "end": "2018"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("Owned a bar/restaurant"), "location": ContainsString("Berlin"), "company": ContainsString("Dinner For Two"), "timeline": DictContaining({"start": "2010", "end": "2020"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("CEO"), "location": ContainsString("DC"), "company": ContainsString("Acme Inc."), "timeline": DictContaining({"start": "2022", "end": AnyOf('', ContainsString("present"))}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("Software Developer"), "location": ContainsString("Berlin"), @@ -302,8 +302,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=7, expected_experiences_count_max=7, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (2, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), + WorkType.UNSEEN_UNPAID: (2, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (2, 2)}, country_of_user=Country.UNSPECIFIED, @@ -312,25 +312,25 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": DictContaining({"start": "2018", "end": "2020"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("software architect"), "location": ContainsString("Berlin"), "company": ContainsString("ProUbis GmbH"), "timeline": DictContaining({"start": "2010", "end": "2018"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("Owned a bar/restaurant"), "location": ContainsString("Berlin"), "company": ContainsString("Dinner For Two"), "timeline": DictContaining({"start": "2010", "end": "2020"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("CEO"), "location": ContainsString("DC"), "company": ContainsString("Acme Inc."), "timeline": DictContaining({"start": "2022", "end": AnyOf('', ContainsString("present"))}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("Software Developer"), "location": ContainsString("Berlin"), @@ -364,8 +364,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, country_of_user=Country.SOUTH_AFRICA @@ -384,15 +384,15 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, expected_experience_data=[ {"experience_title": ContainsString("graphic design"), "location": AnyOf(ContainsString("remote"), ContainsString("Joburg")), "timeline": DictContaining({"start": "06/2020", "end": ContainsString("present")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("English teacher"), "location": ContainsString("Joburg"), @@ -421,8 +421,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe # The simulated user seems to report 3 experiences (help parent, drove grandma, helped neighbours) expected_experiences_count_min=2, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 3)} ), @@ -440,8 +440,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=2, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 2), - WorkType.SELF_EMPLOYMENT: (1, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 2), + WorkType.UNSEEN_UNPAID: (1, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -449,13 +449,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Paris"), "company": ContainsString("Uber Eats"), "timeline": DictContaining({"start": "2021", "end": ContainsString("2023")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, {"experience_title": ContainsString("selling furniture"), "location": AnyOf(ContainsString("Flea Market"), ContainsString("Jean Henri Fabre"), ContainsString("Paris")), "company": ContainsString("Flea Market"), "timeline": DictContaining({"start": "2019", "end": ContainsString("present")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, ], @@ -474,8 +474,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=2, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 2), - WorkType.SELF_EMPLOYMENT: (1, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 2), + WorkType.UNSEEN_UNPAID: (1, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -483,13 +483,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Paris"), "company": ContainsString("Uber Eats"), "timeline": DictContaining({"start": "2021", "end": ContainsString("2023")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, {"experience_title": ContainsString("selling furniture"), "location": AnyOf(ContainsString("Flea Market"), ContainsString("Jean Henri Fabre"), ContainsString("Paris")), "company": ContainsString("Flea Market"), "timeline": DictContaining({"start": "2019", "end": ContainsString("present")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, ], @@ -505,8 +505,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -514,7 +514,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Nairobi"), "company": ContainsString("Chandaria"), "timeline": DictContaining({"start": "2018", "end": ContainsString("present")}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }] ), CollectExperiencesAgentTestCase( @@ -532,8 +532,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -541,7 +541,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Nairobi"), "company": ContainsString("Chandaria"), "timeline": DictContaining({"start": "2018", "end": ContainsString("present")}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }] ), @@ -557,8 +557,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1)}, expected_experience_data=[ @@ -593,8 +593,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=7, expected_experiences_count_max=7, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (2, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), + WorkType.UNSEEN_UNPAID: (2, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (2, 2)}, expected_experience_data=[ @@ -602,25 +602,25 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": DictContaining({"start": "2018", "end": "2020"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("software architect"), "location": ContainsString("Berlin"), "company": ContainsString("ProUbis GmbH"), "timeline": DictContaining({"start": "2010", "end": "2018"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("Owned a bar/restaurant"), "location": ContainsString("Berlin"), "company": ContainsString("Dinner For Two"), "timeline": DictContaining({"start": "2010", "end": "2020"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("CEO"), "location": ContainsString("DC"), "company": ContainsString("Acme Inc."), "timeline": DictContaining({"start": "2022", "end": AnyOf('', ContainsString("present"))}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("Software Developer"), "location": ContainsString("Berlin"), @@ -672,8 +672,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=4, expected_experiences_count_max=4, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, expected_experience_data=[ @@ -682,13 +682,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Provided in follow-up "company": ContainsString("TechCorp"), # Provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Provided in follow-up "company": AnyOf(ContainsString("small businesses"), ContainsString("startups"), ContainsString("clients")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), # Provided in follow-up @@ -734,8 +734,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=20)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1)}, expected_experience_data=[ @@ -744,13 +744,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Provided in follow-up "company": ContainsString("TechCorp"), # Provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Provided in follow-up "company": AnyOf(ContainsString("SmallBiz Solutions"), ContainsString("StartupXYZ"), ContainsString("clients")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), # Provided in follow-up @@ -799,8 +799,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=25)], expected_experiences_count_min=5, expected_experiences_count_max=5, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), # Software Developer + Tutor - WorkType.SELF_EMPLOYMENT: (1, 1), # Freelance Web Designer + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), # Software Developer + Tutor + WorkType.UNSEEN_UNPAID: (1, 1), # Freelance Web Designer WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, # Animal Shelter + Family Restaurant expected_experience_data=[ @@ -808,13 +808,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), "company": ContainsString("TechCorp"), "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": AnyOf(ContainsString("Johannesburg"), ContainsString("remote")), "company": AnyOf(ContainsString("clients"), ContainsString("freelance")), "timeline": DictContaining({"start": "2022", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), @@ -826,7 +826,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Pretoria"), "company": AnyOf(ContainsString("tutoring"), ContainsString("company")), "timeline": DictContaining({"start": "2021", "end": "2023"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": AnyOf(ContainsString("family"), ContainsString("restaurant")), "location": AnyOf(ContainsString("Durban"), ContainsString("Cape Town"), ContainsString("Johannesburg"), ContainsString("Pretoria"), ContainsString("Gqeberha")), @@ -890,8 +890,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=20)], expected_experiences_count_min=10, expected_experiences_count_max=10, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (3, 3), # Software Developer + Tutor + Sales Associate - WorkType.SELF_EMPLOYMENT: (3, 3), # Web Designer + Graphic Designer + Content Writer + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (3, 3), # Software Developer + Tutor + Sales Associate + WorkType.UNSEEN_UNPAID: (3, 3), # Web Designer + Graphic Designer + Content Writer WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), # Marketing Intern WorkType.UNSEEN_UNPAID: (3, 3)}, # Animal Shelter + Family Restaurant + Community Center expected_experience_data=[ @@ -899,13 +899,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), "company": ContainsString("TechCorp"), "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": AnyOf(ContainsString("Johannesburg"), ContainsString("remote")), "company": AnyOf(ContainsString("clients"), ContainsString("freelance")), "timeline": DictContaining({"start": "2022", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), @@ -917,7 +917,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Pretoria"), "company": AnyOf(ContainsString("tutoring"), ContainsString("company")), "timeline": DictContaining({"start": "2021", "end": "2023"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": AnyOf(ContainsString("family"), ContainsString("restaurant")), "location": AnyOf(ContainsString("Durban"), ContainsString("Cape Town"), ContainsString("Johannesburg"), ContainsString("Pretoria"), ContainsString("Gqeberha")), @@ -935,7 +935,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), "company": AnyOf(ContainsString("businesses"), ContainsString("clients")), "timeline": DictContaining({"start": "2021", "end": "2022"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), @@ -947,13 +947,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Pretoria"), "company": AnyOf(ContainsString("retail"), ContainsString("store")), "timeline": DictContaining({"start": "2018", "end": "2019"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("content writer"), "location": AnyOf(ContainsString("remote"), ContainsString("home")), "company": AnyOf(ContainsString("clients"), ContainsString("freelance")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, ], ), @@ -980,8 +980,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=25)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1)}, expected_experience_data=[ @@ -990,13 +990,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Provided in follow-up "company": ContainsString("TechCorp"), # Provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Provided in follow-up "company": AnyOf(ContainsString("SmallBiz Solutions"), ContainsString("StartupXYZ"), ContainsString("clients")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), # Provided in follow-up @@ -1019,7 +1019,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( uuid="test-uuid-2", @@ -1031,7 +1031,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2023", end_date="Present", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ), CollectedData( uuid="test-uuid-3", @@ -1047,7 +1047,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe ) ], unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], first_time_visit=False # Not first time since we have existing data ) ), @@ -1072,8 +1072,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=25)], expected_experiences_count_min=2, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -1082,13 +1082,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Now provided in follow-up "company": ContainsString("TechCorp"), # Now provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Now provided in follow-up "company": ContainsString("SmallBiz Solutions"), # Now provided in follow-up "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, ], # Inject a partially collected state @@ -1106,7 +1106,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( uuid="test-uuid-2", @@ -1118,11 +1118,11 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2023", end_date="Present", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], first_time_visit=False # Not first time since we have existing data ) ), @@ -1176,8 +1176,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe expected_experiences_count_min=5, expected_experiences_count_max=5, expected_work_types={ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (1, 1) }, @@ -1188,21 +1188,21 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Vihiga"), "company": AnyOf(ContainsString("lady"), ContainsString("This Lady")), "timeline": {"start": "2014", "end": ContainsString("2015")}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "experience_title": AnyOf(ContainsString("secretary"), ContainsString("Secretary")), "location": ContainsString("Machakos"), "company": ContainsString("school"), "timeline": {"start": "2016", "end": "2016"}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "experience_title": AnyOf(ContainsString("chapati"), ContainsString("selling")), "location": ContainsString("Vihiga"), "company": "", # Empty string for self-employment "timeline": {"start": "2014", "end": "2015"}, - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, { "experience_title": AnyOf(ContainsString("internship"), ContainsString("Lukola")), 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 0986b565..6ab84054 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 @@ -51,7 +51,7 @@ class TemporalAndWorkTypeClassifierToolTestCase(CompassTestCase): "start_date": "05/2021", "end_date": AnyOf(None, ContainsString("present")), "paid_work": True, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, } ), TemporalAndWorkTypeClassifierToolTestCase( @@ -85,7 +85,7 @@ class TemporalAndWorkTypeClassifierToolTestCase(CompassTestCase): users_input="Yes, as a baker at a local restaurant.", expected_extracted_data={ "paid_work": True, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name } ), TemporalAndWorkTypeClassifierToolTestCase( @@ -107,7 +107,7 @@ class TemporalAndWorkTypeClassifierToolTestCase(CompassTestCase): users_input="Yes, selling tomatoes in local market.", expected_extracted_data={ "paid_work": True, - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ), TemporalAndWorkTypeClassifierToolTestCase( 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 c0564149..29a817a4 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 @@ -72,8 +72,8 @@ class TransitionDecisionToolTestCase(CompassTestCase): work_type=None ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -95,11 +95,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -121,11 +121,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.END_WORKTYPE ), @@ -147,14 +147,14 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], exploring_type=None, unexplored_types=[], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID ], @@ -178,14 +178,14 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], exploring_type=None, unexplored_types=[], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID ], @@ -208,7 +208,7 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( index=1, @@ -219,11 +219,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2022", end_date="2024", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -243,11 +243,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -270,12 +270,12 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], - exploring_type=WorkType.SELF_EMPLOYMENT, - unexplored_types=[WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT], + exploring_type=WorkType.UNSEEN_UNPAID, + unexplored_types=[WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], expected_transition_decision=TransitionDecision.CONTINUE ), TransitionDecisionToolTestCase( @@ -304,8 +304,8 @@ class TransitionDecisionToolTestCase(CompassTestCase): exploring_type=WorkType.UNSEEN_UNPAID, unexplored_types=[WorkType.UNSEEN_UNPAID], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ], expected_transition_decision=TransitionDecision.END_WORKTYPE @@ -329,12 +329,12 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], - exploring_type=WorkType.SELF_EMPLOYMENT, - unexplored_types=[WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT], + exploring_type=WorkType.UNSEEN_UNPAID, + unexplored_types=[WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], expected_transition_decision=TransitionDecision.END_WORKTYPE ), TransitionDecisionToolTestCase( @@ -356,11 +356,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2016", end_date="2016", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.END_WORKTYPE ), @@ -382,12 +382,12 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], - exploring_type=WorkType.SELF_EMPLOYMENT, - unexplored_types=[WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT], + exploring_type=WorkType.UNSEEN_UNPAID, + unexplored_types=[WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], expected_transition_decision=TransitionDecision.END_WORKTYPE ), TransitionDecisionToolTestCase( @@ -408,7 +408,7 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2016", end_date="2016", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( index=1, @@ -419,7 +419,7 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ), CollectedData( index=2, @@ -436,8 +436,8 @@ class TransitionDecisionToolTestCase(CompassTestCase): exploring_type=None, unexplored_types=[], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID ], diff --git a/backend/evaluation_tests/conversation_libs/evaluators/criteria_evaluator.py b/backend/evaluation_tests/conversation_libs/evaluators/criteria_evaluator.py index 03b13077..024bf6c3 100644 --- a/backend/evaluation_tests/conversation_libs/evaluators/criteria_evaluator.py +++ b/backend/evaluation_tests/conversation_libs/evaluators/criteria_evaluator.py @@ -1,5 +1,6 @@ from pydantic import BaseModel +from app.agent.config import AgentsConfig from common_libs.llm.models_utils import LLMConfig from common_libs.llm.generative_models import GeminiGenerativeLLM from common_libs.text_formatters import extract_json @@ -27,7 +28,7 @@ def __init__(self, criteria: EvaluationType): self.criteria = criteria # Use GeminiGenerativeLLM as the LLM for evaluation # as we are not interested in conducting a conversation, with an in-memory state (history). - self.llm = GeminiGenerativeLLM(config=LLMConfig(language_model_name="gemini-2.5-pro")) + self.llm = GeminiGenerativeLLM(config=LLMConfig(language_model_name=AgentsConfig.ultra_high_reasoning_model)) async def evaluate(self, actual: ConversationEvaluationRecord) -> EvaluationResult: prompt = PromptGenerator.generate_prompt(conversation=actual.generate_conversation(), diff --git a/backend/evaluation_tests/conversation_libs/evaluators/evaluation_result.py b/backend/evaluation_tests/conversation_libs/evaluators/evaluation_result.py index 02b747ca..650b0be0 100644 --- a/backend/evaluation_tests/conversation_libs/evaluators/evaluation_result.py +++ b/backend/evaluation_tests/conversation_libs/evaluators/evaluation_result.py @@ -39,6 +39,7 @@ class EvaluationType(Enum): SUMMARY_CONSISTENCY = "Summary Consistency" SUMMARY_RELEVANCE = "Summary Relevance" SINGLE_LANGUAGE = "Single Language" + NON_PRIORITY_SECTOR_RESPONSE_QUALITY = "Non-Priority Sector Response Quality" class EvaluationResult(BaseModel): diff --git a/backend/evaluation_tests/conversation_libs/evaluators/evaluator_builder.py b/backend/evaluation_tests/conversation_libs/evaluators/evaluator_builder.py index bce8ecf5..c30c3a0e 100644 --- a/backend/evaluation_tests/conversation_libs/evaluators/evaluator_builder.py +++ b/backend/evaluation_tests/conversation_libs/evaluators/evaluator_builder.py @@ -16,5 +16,7 @@ def create_evaluator(evaluations_type: EvaluationType) -> BaseEvaluator: return CriteriaEvaluator(EvaluationType.FOCUS) case EvaluationType.SINGLE_LANGUAGE: return CriteriaEvaluator(EvaluationType.SINGLE_LANGUAGE) + case EvaluationType.NON_PRIORITY_SECTOR_RESPONSE_QUALITY: + return CriteriaEvaluator(EvaluationType.NON_PRIORITY_SECTOR_RESPONSE_QUALITY) case _: raise NotImplementedError() diff --git a/backend/evaluation_tests/conversation_libs/evaluators/prompt_generator.py b/backend/evaluation_tests/conversation_libs/evaluators/prompt_generator.py index 250886c6..de9e765b 100644 --- a/backend/evaluation_tests/conversation_libs/evaluators/prompt_generator.py +++ b/backend/evaluation_tests/conversation_libs/evaluators/prompt_generator.py @@ -66,6 +66,24 @@ def _get_criteria_string(criteria: EvaluationType): 2. Check if the conversation was in the same language throughout (eg: English, Spanish, French, Swahili, etc).. 3. Assign a score of 100 if the conversation was in the same language throughout, or 0 otherwise. """) + case EvaluationType.NON_PRIORITY_SECTOR_RESPONSE_QUALITY: + return textwrap.dedent(""" + Evaluation Criteria: + + The EVALUATED_AGENT is a career explorer that answers questions about careers both within and outside + priority sectors. For non-priority sectors (e.g. software, IT, healthcare, aeronautical engineering), + the agent should use web search to provide substantive, informative career advice. + + Score highly if the EVALUATED_AGENT: + - Gives a substantive response with useful career information (roles, pathways, salary context, employers) + - Stays on topic and addresses the user's career question + - Does not deflect or redirect the user to pick a different sector + + Score low if the EVALUATED_AGENT: + - Says it cannot help or redirects to "pick one of our sectors" + - Gives a generic error or placeholder response + - Provides no useful career-specific information + """) case _: raise NotImplementedError() @@ -99,6 +117,11 @@ def _get_example_response(criteria: EvaluationType): - EVALUATED_AGENT used 'Hello' instead of 'Hola' at message 2. """) + case EvaluationType.NON_PRIORITY_SECTOR_RESPONSE_QUALITY: + return textwrap.dedent(""" + The response provides useful career information about the non-priority sector, including roles, + pathways, and context, without deflecting to priority sectors. + """) case _: raise NotImplementedError() diff --git a/backend/evaluation_tests/core_e2e_tests_cases.py b/backend/evaluation_tests/core_e2e_tests_cases.py index 4b0f55dc..bd92b599 100644 --- a/backend/evaluation_tests/core_e2e_tests_cases.py +++ b/backend/evaluation_tests/core_e2e_tests_cases.py @@ -64,11 +64,11 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): conversation_rounds=50, name='asks_about_process_e2e', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. When asked if you are ready to start the conversation, @@ -84,14 +84,12 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -102,25 +100,23 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): conversation_rounds=50, name='single_experience_specific_and_concise_user_e2e', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. """) + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -132,22 +128,20 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): name='single_experience_e2e', locale=Locale.ES_AR, simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - You sold shoes at Zuputaas, a store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Zuputaas, a store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. Be more descriptive and more open. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information.""") + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.SINGLE_LANGUAGE, expected=100)], expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["matcher"], @@ -163,8 +157,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): name='single_experience_e2e_switch_languages', locale=Locale.ES_AR, simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - You sold shoes at Kigali Shoes, a store in Kigali, Rwanda, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Kigali Shoes, a store in Kigali, Rwanda, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. Be more descriptive and more open. @@ -184,9 +178,7 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["matcher"], @@ -210,8 +202,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2), }, @@ -238,8 +230,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1), } @@ -257,14 +249,12 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), }] ), E2ETestCase( @@ -456,8 +446,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=2, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 1), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 1), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 1), + WorkType.UNSEEN_UNPAID: (0, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1), }, diff --git a/backend/evaluation_tests/e2e_chat_executor.py b/backend/evaluation_tests/e2e_chat_executor.py index f24a4638..47e0e811 100644 --- a/backend/evaluation_tests/e2e_chat_executor.py +++ b/backend/evaluation_tests/e2e_chat_executor.py @@ -226,6 +226,7 @@ def _infer_phase_from_agent(self, agent_type: str) -> str: "COLLECT_EXPERIENCES_AGENT": "COUNSELING", "EXPLORE_SKILLS_AGENT": "COUNSELING", "INFER_OCCUPATIONS_AGENT": "COUNSELING", + "PREFERENCE_ELICITATION_AGENT": "COUNSELING", "RECOMMENDER_ADVISOR_AGENT": "COUNSELING", "FAREWELL_AGENT": "CHECKOUT", } diff --git a/backend/evaluation_tests/golden_test_cases.py b/backend/evaluation_tests/golden_test_cases.py index 118f343e..ce61ab71 100644 --- a/backend/evaluation_tests/golden_test_cases.py +++ b/backend/evaluation_tests/golden_test_cases.py @@ -53,33 +53,31 @@ class GoldenTestCase(E2ESpecificTestCase): # Golden Test Set: 7 Representative Personas golden_test_cases = [ - # 1. SIMPLE FORMAL EMPLOYMENT (Baseline) - # Represents: Concise user, single formal job, straightforward conversation - # Coverage: FORMAL_SECTOR_WAGED_EMPLOYMENT + # 1. SIMPLE UNPAID TRAINEE (Baseline) + # Represents: Concise user, single unpaid trainee experience, straightforward conversation + # Coverage: FORMAL_SECTOR_UNPAID_TRAINEE_WORK GoldenTestCase( country_of_user=Country.UNSPECIFIED, conversation_rounds=50, - name='golden_simple_formal_employment', + name='golden_simple_unpaid_trainee', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. """) + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -103,8 +101,6 @@ class GoldenTestCase(E2ESpecificTestCase): expected_experiences_count_min=1, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2), }, @@ -123,46 +119,44 @@ class GoldenTestCase(E2ESpecificTestCase): }] ), - # 3. SELF-EMPLOYMENT (Informal Sector) - # Represents: Informal work, no formal job title - # Coverage: SELF_EMPLOYMENT + # 3. VOLUNTEER TRANSPORT (Informal Sector) + # Represents: Volunteer work in transport sector + # Coverage: UNSEEN_UNPAID GoldenTestCase( country_of_user=Country.KENYA, conversation_rounds=100, - name='golden_self_employed_matatu_conductor', + name='golden_volunteer_matatu_conductor', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. You work as a Matatu conductor. A matatu conductor is a person who collects - fares from passengers in a matatu, a type of public transport. You have never had another job experience, - never did any internship, never run your own business, never volunteered, never did any freelance work. + You're a Gen Y living alone. You volunteer as a Matatu conductor assistant. A matatu conductor is a person who + collects fares from passengers in a matatu, a type of public transport. You help out unpaid at a family member's matatu. + You have never had a paid job experience, never did any internship, never run your own business, never did any freelance work. """) + kenya_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], expected_experiences_count_min=1, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 1), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), - WorkType.UNSEEN_UNPAID: (0, 1), + WorkType.UNSEEN_UNPAID: (1, 2), } ), # 4. MULTI-EXPERIENCE DIVERSE (Complex) - # Represents: Multiple work types, complex conversation - # Coverage: FORMAL_SECTOR_WAGED_EMPLOYMENT, SELF_EMPLOYMENT, FORMAL_SECTOR_UNPAID_TRAINEE_WORK, UNSEEN_UNPAID + # Represents: Multiple unpaid/trainee work types, complex conversation + # Coverage: FORMAL_SECTOR_UNPAID_TRAINEE_WORK, UNSEEN_UNPAID GoldenTestCase( country_of_user=Country.SOUTH_AFRICA, conversation_rounds=100, name='golden_multi_experience_diverse', simulated_user_prompt=dedent(""" - You are a young person from South Africa with a diverse work history. You have multiple experiences across different work types. + You are a young person from South Africa with unpaid work history. You have multiple experiences across different work types. If asked if you want to start the conversation, agree to start without saying anything about your experiences. You have the following experiences: - • Worked as a software developer at TechCorp from 2020 to 2022. It was a full-time paid job in Cape Town. I worked on web applications and mobile apps, then left to pursue freelance work. - • I'm currently freelancing as a web designer since 2022. I'm self-employed, working with various clients. I'm based in Johannesburg but work remotely, and I specialize in e-commerce websites. • Volunteered at a local animal shelter from 2019 to 2021. It was unpaid volunteer work in Durban. I helped with animal care and adoption events, working weekends and holidays. • Did a summer internship at a marketing agency in 2019. It was in Johannesburg and I helped with social media campaigns. It was unpaid trainee work. + • Helped care for elderly grandparents at home from 2020 to 2022. Unpaid caregiving in Cape Town. + • Volunteered at a community center teaching web design to kids from 2022 to present. Unpaid in Johannesburg. When the agent asks about your experiences, provide information naturally as they ask questions. Be specific about dates, locations, and work types when asked. You're proud of your diverse experience @@ -171,13 +165,11 @@ class GoldenTestCase(E2ESpecificTestCase): You can come up with specific activities and details for each experience when asked. """) + sa_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], - expected_experiences_count_min=4, + expected_experiences_count_min=3, expected_experiences_count_max=4, expected_work_types={ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), - WorkType.UNSEEN_UNPAID: (1, 1), + WorkType.UNSEEN_UNPAID: (2, 3), } ), @@ -189,15 +181,15 @@ class GoldenTestCase(E2ESpecificTestCase): conversation_rounds=100, name='golden_cv_upload_style', simulated_user_prompt=dedent(""" - You are a professional with diverse experiences. + You are a professional with diverse unpaid and volunteer experiences. If asked if you want to start the conversation, agree to start without saying anything about your experiences. Only after agreeing to start, wait for the agent to ask you about your experiences, and then you will respond in CV format with bullet points: These are my experiences: - • Worked as a project manager at the University of Oxford, from 2018 to 2020. It was a paid job and you worked remotely. - • Worked as a software architect at ProUbis GmbH in berlin, from 2010 to 2018. It was a full-time job. - • You owned a bar/restaurant called Dinner For Two in Berlin from 2010 until covid-19, then you sold it. + • Volunteered as a project coordinator at the University of Oxford, from 2018 to 2020. It was unpaid and you worked remotely. + • Unpaid internship as a software architect at ProUbis GmbH in Berlin, from 2010 to 2018. + • Volunteered at a community kitchen called Dinner For Two in Berlin from 2010 until covid-19. • In 1998 did an unpaid internship as a Software Developer for Ubis GmbH in Berlin. • Between 2015-2017 volunteer, taught coding to kids in a community center in Berlin. @@ -213,13 +205,11 @@ class GoldenTestCase(E2ESpecificTestCase): You can come up with activities you have done during each experience. """) + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], - expected_experiences_count_min=5, + expected_experiences_count_min=4, expected_experiences_count_max=5, expected_work_types={ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), - WorkType.UNSEEN_UNPAID: (1, 1), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 2), + WorkType.UNSEEN_UNPAID: (2, 3), } ), @@ -231,11 +221,11 @@ class GoldenTestCase(E2ESpecificTestCase): conversation_rounds=50, name='golden_process_questioner', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. When asked if you are ready to start the conversation, @@ -251,14 +241,12 @@ class GoldenTestCase(E2ESpecificTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -282,10 +270,8 @@ class GoldenTestCase(E2ESpecificTestCase): expected_experiences_count_min=1, expected_experiences_count_max=4, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 2), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), - WorkType.UNSEEN_UNPAID: (1, 3), + WorkType.UNSEEN_UNPAID: (1, 4), } ), ] @@ -299,10 +285,8 @@ class GoldenTestCase(E2ESpecificTestCase): "expected_runtime_minutes": 25, "coverage": { "work_types": { - "FORMAL_SECTOR_WAGED_EMPLOYMENT": 3, - "SELF_EMPLOYMENT": 2, - "FORMAL_SECTOR_UNPAID_TRAINEE_WORK": 1, - "UNSEEN_UNPAID": 3, + "FORMAL_SECTOR_UNPAID_TRAINEE_WORK": 3, + "UNSEEN_UNPAID": 4, }, "conversation_styles": { "concise": 4, 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 b418c98e..9a6bb8b0 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 @@ -30,7 +30,7 @@ class ExperiencePipelineTestCase(CompassTestCase): given_company_name="Baker's Delight", given_responsibilities=["I sell bread"], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, expected_top_skills=["advise customers on bread"] ), ExperiencePipelineTestCase( @@ -39,7 +39,7 @@ class ExperiencePipelineTestCase(CompassTestCase): given_company_name="Baker's Delight", given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread", "I talk to customers"], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, expected_top_skills=['communicate with customers', 'maintain work area cleanliness', 'order supplies', @@ -76,7 +76,7 @@ class ExperiencePipelineTestCase(CompassTestCase): "I handle communication with stakeholders", ], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, expected_top_skills=[] ), ExperiencePipelineTestCase( @@ -89,7 +89,7 @@ class ExperiencePipelineTestCase(CompassTestCase): "I clean and disinfect students, teachers, and visitors.", "I put together weekly and monthly reports."], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, expected_top_skills=['communicate health and safety measures', 'disinfect surfaces', 'measure temperature', @@ -257,7 +257,7 @@ class ExperiencePipelineTestCase(CompassTestCase): given_company_name="Local de mi viejo", given_responsibilities=["Limpiar el lugar", "Manejar la guita", "Tratar con proveedores", "Contabilidad básica", "Venta de productos", "Empaquetar pedidos"], given_country_of_interest=Country.ARGENTINA, - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, expected_top_skills=[] ), ExperiencePipelineTestCase( 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 39828360..5dca4fd8 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 @@ -29,7 +29,7 @@ class ContextualizationTestCase(CompassTestCase): name="Self-employed baker with responsibilities", given_experience_title="Baker", given_company="Bread Co.", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -38,7 +38,7 @@ class ContextualizationTestCase(CompassTestCase): name="Foo clarified by responsibilities", given_experience_title="Foo", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -47,7 +47,7 @@ class ContextualizationTestCase(CompassTestCase): name="Baker clarified by Job Title and not responsibilities", given_experience_title="Baker", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=[], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -56,7 +56,7 @@ class ContextualizationTestCase(CompassTestCase): name="Baker contradicting responsibilities", given_experience_title="Baker", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I develop software", "I develop websites", "I build for the web", "I write code", "I bake bread"], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -65,7 +65,7 @@ class ContextualizationTestCase(CompassTestCase): name="Matatu conductor", given_experience_title="Matatu conductor", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I collect fares", "I assist passengers", "I maintain the vehicle", "I ensure safety"], given_country_of_interest=Country.KENYA, given_number_of_titles=5 @@ -75,7 +75,7 @@ class ContextualizationTestCase(CompassTestCase): skip_force="force", given_experience_title="", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I stock shelves", "I serve customers", "I handle cash transactions", "I manage inventory"], given_country_of_interest=Country.KENYA, given_number_of_titles=10 @@ -85,7 +85,7 @@ class ContextualizationTestCase(CompassTestCase): skip_force="force", given_experience_title=" ", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I stock shelves", "I serve customers", "I handle cash transactions", "I manage inventory"], given_country_of_interest=Country.KENYA, given_number_of_titles=10 diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py index 7a8950db..f47ab7b3 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py @@ -38,7 +38,7 @@ class PartialSpecification(BaseModel): list_of_given_partial_specs: list[PartialSpecification] = [ PartialSpecification( given_code="7512.1", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="Baker's Delight", given_country_of_interest=Country.UNSPECIFIED, expected_occupations_found_codes=["7512.1"] diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl index 15f569f5..b6e0ba5d 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl @@ -1,6 +1,6 @@ -{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bakery Assistant", "given_work_type": "Self-employment", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} -{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Pastry Cook", "given_work_type": "Self-employment", "given_company": "Sweet Treats Cafe", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I made a variety of pastries, like croissants and muffins, following recipes carefully.", "I proofed the dough to make sure it was rising correctly before baking.", "I cleaned and maintained the baking equipment, like the mixers and ovens."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} -{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bread Baker", "given_work_type": "Self-employment", "given_company": "The Daily Loaf", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I was in charge of making different types of bread, from sourdough to rye.", "I adjusted recipes depending on the day's humidity and temperature.", "I experimented with new bread recipes and flavor combinations."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bakery Assistant", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Pastry Cook", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Sweet Treats Cafe", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I made a variety of pastries, like croissants and muffins, following recipes carefully.", "I proofed the dough to make sure it was rising correctly before baking.", "I cleaned and maintained the baking equipment, like the mixers and ovens."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bread Baker", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "The Daily Loaf", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I was in charge of making different types of bread, from sourdough to rye.", "I adjusted recipes depending on the day's humidity and temperature.", "I experimented with new bread recipes and flavor combinations."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} {"skip_force":"","name": "I31_0 cooking meals", "given_experience_title": "Making Meals", "given_work_type": "Unpaid other", "given_company": "My household", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I cooked dinner most nights for my family.", "I prepared school lunches for my kids every weekday.", "I often heated up leftovers for a quick meal."], "expected_occupations_found": [{"code": "I31_0", "preferred_label": "cooking meals"}]} {"skip_force":"","name": "I31_0 cooking meals", "given_experience_title": "Cleaning Up After Meals", "given_work_type": "Unpaid other", "given_company": "My household", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I loaded and unloaded the dishwasher daily.", "I cleared the table after every meal.", "I washed the dishes that couldn't go in the dishwasher.", "I wiped down the kitchen counters and table after eating."], "expected_occupations_found": [{"code": "I31_0", "preferred_label": "cooking meals"}]} {"skip_force":"","name": "I31_0 cooking meals", "given_experience_title": "Food Prep and Storage", "given_work_type": "Unpaid other", "given_company": "My household", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I prepared coffee every morning.", "I sorted and stored groceries after shopping.", "I cleaned and prepped fruits and vegetables for meals and snacks."], "expected_occupations_found": [{"code": "I31_0", "preferred_label": "cooking meals"}]} @@ -157,5 +157,5 @@ {"skip_force":"","name": "I52_5 caring for and teaching children in the community", "given_experience_title": "Community Childcare Volunteer", "given_work_type": "Unpaid other", "given_company": "Local Community Center", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped prepare and serve snacks to kids at the local community center after school.", "I supervised kids during playtime at the park, making sure everyone was safe.", "I read stories to groups of children at the library during story hour.", "I helped kids with their homework at the after-school program."], "expected_occupations_found": [{"code": "I52_5", "preferred_label": "caring for and teaching children in the community"}]} {"skip_force":"","name": "I52_5 caring for and teaching children in the community", "given_experience_title": "After-School Tutor", "given_work_type": "Unpaid other", "given_company": "Boys & Girls Club", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I provided one-on-one tutoring to elementary school students in math and reading.", "I helped students with their homework assignments and explained concepts they were struggling with.", "I created fun learning activities to engage the students and make learning more enjoyable.", "I monitored student progress and provided feedback to parents."], "expected_occupations_found": [{"code": "I52_5", "preferred_label": "caring for and teaching children in the community"}]} {"skip_force":"","name": "I52_5 caring for and teaching children in the community", "given_experience_title": "Summer Camp Counselor", "given_work_type": "Unpaid other", "given_company": "YMCA", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I organized and led recreational activities for children at a summer day camp.", "I supervised children during swimming and outdoor games.", "I administered basic first aid to children as needed.", "I helped children with arts and crafts projects."], "expected_occupations_found": [{"code": "I52_5", "preferred_label": "caring for and teaching children in the community"}]} -{"skip_force":"","name": "7512.1 baker with empty title", "given_experience_title": "", "given_work_type": "Self-employment", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} -{"skip_force":"","name": "7512.1 baker with spaces in experience title", "given_experience_title": " ", "given_work_type": "Self-employment", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker with empty title", "given_experience_title": "", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker with spaces in experience title", "given_experience_title": " ", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py index 2d19c3b2..c4864be4 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py @@ -32,7 +32,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Baker", given_experience_title="Baker", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.SOUTH_AFRICA, @@ -43,7 +43,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Baker at the limits of LLM response Context size ", given_experience_title="Baker", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.SOUTH_AFRICA, @@ -59,7 +59,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Title is not useful, infer from responsibilities", given_experience_title="Foo", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I cook street food", "I sell food to the local community"], given_country_of_interest=Country.SOUTH_AFRICA, @@ -68,7 +68,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Title is misleading, infer from responsibilities", given_experience_title="I sell gully to the local community", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I visit the Embassy every day", "I talk with the embassy staff", @@ -80,7 +80,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from responsibilities", given_experience_title="GDE Brigade member", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_company="Gauteng Department of Education", given_responsibilities=[ # https://search67.com/2021/07/19/careers-employment-opportunity-for-youth-as-c0vid-19-screeners/ @@ -96,7 +96,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Should not change title (emtpy responsibilities)", given_experience_title="Software Engineer", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_company="Google", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], @@ -107,7 +107,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from glossary (emtpy responsibilities)", given_experience_title="I sell kota to the local community", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], @@ -116,7 +116,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from glossary (emtpy responsibilities)", given_experience_title="I make bunny chow", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_company="Hungry Lion", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], @@ -125,7 +125,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from glossary & company name (emtpy responsibilities)", given_experience_title="I make bunny chow", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="Hungry Tiger", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], 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 51a73575..cb35f27f 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 @@ -27,21 +27,21 @@ class SkillLinkingToolTestCase(CompassTestCase): SkillLinkingToolTestCase( name="Baker by code", given_occupation_code="7512.1", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies"], expected_skills=['bake goods', 'ensure sanitation'] ), SkillLinkingToolTestCase( name="Baker by title", given_occupation_title="Baker", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread", "I talk to customers"], expected_skills=['bake goods', 'prepare bakery products', 'order supplies', 'ensure sanitation', 'maintain relationship with customers'] ), SkillLinkingToolTestCase( name="GDE Brigade member by title", given_occupation_title="GDE Brigade member", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I make sure everyone follows the Covid-19 rules.", "I keep an eye on the kids to make sure they stay apart from each other.", "I check and record temperatures and other health signs.", 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 cdf988c4..1924c796 100644 --- a/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py @@ -100,7 +100,7 @@ class _TestCaseConversation(CompassTestCase): ], experiences_explored=[], experience_title="Selling Kotas", - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), _TestCaseConversation( country_of_user=Country.SOUTH_AFRICA, @@ -128,7 +128,7 @@ class _TestCaseConversation(CompassTestCase): "Vielen Dank, dass du deine Erfahrungen geteilt hast. Lassen Sie uns zum nächsten Schritt übergehen.")], experiences_explored=[], experience_title="Verkauf von Kotas", - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), _TestCaseConversation( country_of_user=Country.SOUTH_AFRICA, @@ -186,7 +186,7 @@ class _TestCaseConversation(CompassTestCase): ], experiences_explored=[], experience_title="Asistente de ventas", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ) ] diff --git a/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py b/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py index d9168b22..fd3314a3 100644 --- a/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py +++ b/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py @@ -70,7 +70,7 @@ def __init__(self, *, name: str, simulated_user_prompt: str, evaluations: list[E start="2018", end="2020" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), country_of_user=Country.UNSPECIFIED, expected_responsibilities=[], @@ -96,7 +96,7 @@ def __init__(self, *, name: str, simulated_user_prompt: str, evaluations: list[E start="2018", end="2020" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), country_of_user=Country.KENYA, expected_responsibilities=[] @@ -182,7 +182,7 @@ def __init__(self, *, name: str, simulated_user_prompt: str, evaluations: list[E start="2015", end="2022" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), country_of_user=Country.ARGENTINA, expected_responsibilities=['Limpiar el lugar', 'Manejar la guita', 'Tratar con proveedores', 'Contabilidad básica', 'Venta de productos', 'Empaquetar pedidos'], diff --git a/backend/features/test_loader.py b/backend/features/test_loader.py index ecf9ca12..46630fe7 100644 --- a/backend/features/test_loader.py +++ b/backend/features/test_loader.py @@ -238,6 +238,7 @@ async def test_feature_up(self, in_memory_taxonomy_database: Awaitable[AsyncIOMotorDatabase], in_memory_userdata_database: Awaitable[AsyncIOMotorDatabase], in_memory_metrics_database: Awaitable[AsyncIOMotorDatabase], + in_memory_career_explorer_database: Awaitable[AsyncIOMotorDatabase], mocker: pytest_mock.MockFixture, enable_test_feature: None ): @@ -264,6 +265,8 @@ async def test_feature_up(self, return_value=await in_memory_userdata_database) _in_mem_metrics_db = mocker.patch('app.server_dependencies.db_dependencies._get_metrics_db', return_value=await in_memory_metrics_database) + mocker.patch('app.server_dependencies.db_dependencies._get_career_explorer_db', + return_value=await in_memory_career_explorer_database) feature_module = Mock() feature_module.TestFeature = _TestFeature diff --git a/backend/poetry.lock b/backend/poetry.lock index da3cb2d8..022982ca 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.3.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -911,6 +911,20 @@ files = [ {file = "docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e"}, ] +[[package]] +name = "dotenv" +version = "0.9.9" +description = "Deprecated package" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9"}, +] + +[package.dependencies] +python-dotenv = "*" + [[package]] name = "email-validator" version = "2.1.1" @@ -927,6 +941,18 @@ files = [ dnspython = ">=2.0.0" idna = ">=2.0.0" +[[package]] +name = "et-xmlfile" +version = "2.0.0" +description = "An implementation of lxml.xmlfile for the standard library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"}, + {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, +] + [[package]] name = "fastapi" version = "0.111.0" @@ -1004,7 +1030,7 @@ files = [ [package.dependencies] cachecontrol = ">=0.12.6" -google-api-core = {version = ">=1.22.1,<3.0.0.dev0", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} +google-api-core = {version = ">=1.22.1,<3.0.0dev", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} google-api-python-client = ">=1.7.8" google-cloud-firestore = {version = ">=2.9.1", markers = "platform_python_implementation != \"PyPy\""} google-cloud-storage = ">=1.37.1" @@ -1241,14 +1267,14 @@ files = [ [package.dependencies] google-auth = ">=2.14.1,<3.0.dev0" googleapis-common-protos = ">=1.56.2,<2.0.dev0" -grpcio = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} -proto-plus = ">=1.22.3,<2.0.0.dev0" +proto-plus = ">=1.22.3,<2.0.0dev" protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" requests = ">=2.18.0,<3.0.0.dev0" [package.extras] -grpc = ["grpcio (>=1.33.2,<2.0.dev0)", "grpcio (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] @@ -1337,26 +1363,26 @@ files = [ [package.dependencies] docstring-parser = "<1" -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.8.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<3.0.0.dev0" -google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0.dev0" -google-cloud-resource-manager = ">=1.3.3,<3.0.0.dev0" -google-cloud-storage = ">=1.32.0,<3.0.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.8.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<3.0.0dev" +google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0dev" +google-cloud-resource-manager = ">=1.3.3,<3.0.0dev" +google-cloud-storage = ">=1.32.0,<3.0.0dev" packaging = ">=14.3" -proto-plus = ">=1.22.0,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +proto-plus = ">=1.22.0,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" pydantic = "<3" -shapely = "<3.0.0.dev0" +shapely = "<3.0.0dev" [package.extras] autologging = ["mlflow (>=1.27.0,<=2.1.1)"] -cloud-profiler = ["tensorboard-plugin-profile (>=2.4.0,<3.0.0.dev0)", "tensorflow (>=2.4.0,<3.0.0.dev0)", "werkzeug (>=2.0.0,<2.1.0.dev0)"] -datasets = ["pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.dev0) ; python_version < \"3.11\""] +cloud-profiler = ["tensorboard-plugin-profile (>=2.4.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "werkzeug (>=2.0.0,<2.1.0dev)"] +datasets = ["pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0dev) ; python_version < \"3.11\""] endpoint = ["requests (>=2.28.1)"] -full = ["cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.dev0) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorflow (>=2.3.0,<3.0.0.dev0)", "tensorflow (>=2.3.0,<3.0.0.dev0) ; python_version <= \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)"] +full = ["cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0dev) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev) ; python_version <= \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)"] langchain = ["langchain (>=0.1.16,<0.2)", "langchain-core (<0.2)", "langchain-google-vertexai (<2)"] langchain-testing = ["absl-py", "cloudpickle (>=2.2.1,<4.0)", "langchain (>=0.1.16,<0.2)", "langchain-core (<0.2)", "langchain-google-vertexai (<2)", "pydantic (>=2.6.3,<3)", "pytest-xdist"] -lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0.dev0)"] +lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0dev)"] metadata = ["numpy (>=1.15.0)", "pandas (>=1.0.0)"] pipelines = ["pyyaml (>=5.3.1,<7)"] prediction = ["docker (>=5.0.3)", "fastapi (>=0.71.0,<=0.109.1)", "httpx (>=0.23.0,<0.25.0)", "starlette (>=0.17.1)", "uvicorn[standard] (>=0.16.0)"] @@ -1366,10 +1392,10 @@ rapid-evaluation = ["nest-asyncio (>=1.0.0,<1.6.0)", "pandas (>=1.0.0,<2.2.0)"] ray = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=6.0.1)", "pydantic (<2)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "setuptools (<70.0.0)"] ray-testing = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=6.0.1)", "pydantic (<2)", "pytest-xdist", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "ray[train] (==2.9.3)", "scikit-learn", "setuptools (<70.0.0)", "tensorflow", "torch (>=2.0.0,<2.1.0)", "xgboost", "xgboost-ray"] reasoningengine = ["cloudpickle (>=2.2.1,<4.0)", "pydantic (>=2.6.3,<3)"] -tensorboard = ["tensorflow (>=2.3.0,<3.0.0.dev0) ; python_version <= \"3.11\""] -testing = ["bigframes ; python_version >= \"3.10\"", "cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "ipython", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.dev0) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyfakefs", "pytest-asyncio", "pytest-xdist", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "requests-toolbelt (<1.0.0)", "scikit-learn", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<3.0.0.dev0)", "tensorflow (==2.13.0) ; python_version <= \"3.11\"", "tensorflow (==2.16.1) ; python_version > \"3.11\"", "tensorflow (>=2.3.0,<3.0.0.dev0)", "tensorflow (>=2.3.0,<3.0.0.dev0) ; python_version <= \"3.11\"", "tensorflow (>=2.4.0,<3.0.0.dev0)", "torch (>=2.0.0,<2.1.0) ; python_version <= \"3.11\"", "torch (>=2.2.0) ; python_version > \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0.dev0)", "xgboost"] +tensorboard = ["tensorflow (>=2.3.0,<3.0.0dev) ; python_version <= \"3.11\""] +testing = ["bigframes ; python_version >= \"3.10\"", "cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "ipython", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0dev) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyfakefs", "pytest-asyncio", "pytest-xdist", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "requests-toolbelt (<1.0.0)", "scikit-learn", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<3.0.0dev)", "tensorflow (==2.13.0) ; python_version <= \"3.11\"", "tensorflow (==2.16.1) ; python_version > \"3.11\"", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev) ; python_version <= \"3.11\"", "tensorflow (>=2.4.0,<3.0.0dev)", "torch (>=2.0.0,<2.1.0) ; python_version <= \"3.11\"", "torch (>=2.2.0) ; python_version > \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)", "xgboost"] vizier = ["google-vizier (>=0.1.6)"] -xai = ["tensorflow (>=2.3.0,<3.0.0.dev0)"] +xai = ["tensorflow (>=2.3.0,<3.0.0dev)"] [[package]] name = "google-cloud-bigquery" @@ -1384,24 +1410,24 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<3.0.0.dev0" -google-cloud-core = ">=1.6.0,<3.0.0.dev0" -google-resumable-media = ">=0.6.0,<3.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<3.0.0dev" +google-cloud-core = ">=1.6.0,<3.0.0dev" +google-resumable-media = ">=0.6.0,<3.0dev" packaging = ">=20.0.0" -python-dateutil = ">=2.7.2,<3.0.dev0" -requests = ">=2.21.0,<3.0.0.dev0" +python-dateutil = ">=2.7.2,<3.0dev" +requests = ">=2.21.0,<3.0.0dev" [package.extras] -all = ["Shapely (>=1.8.4,<3.0.0.dev0)", "db-dtypes (>=0.3.0,<2.0.0.dev0)", "geopandas (>=0.9.0,<1.0.dev0)", "google-cloud-bigquery-storage (>=2.6.0,<3.0.0.dev0)", "grpcio (>=1.47.0,<2.0.dev0)", "grpcio (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\"", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)", "ipywidgets (>=7.7.0)", "opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)", "pandas (>=1.1.0)", "proto-plus (>=1.15.0,<2.0.0.dev0)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0.dev0)", "pyarrow (>=3.0.0)", "tqdm (>=4.7.4,<5.0.0.dev0)"] -bigquery-v2 = ["proto-plus (>=1.15.0,<2.0.0.dev0)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0.dev0)"] -bqstorage = ["google-cloud-bigquery-storage (>=2.6.0,<3.0.0.dev0)", "grpcio (>=1.47.0,<2.0.dev0)", "grpcio (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\"", "pyarrow (>=3.0.0)"] -geopandas = ["Shapely (>=1.8.4,<3.0.0.dev0)", "geopandas (>=0.9.0,<1.0.dev0)"] +all = ["Shapely (>=1.8.4,<3.0.0dev)", "db-dtypes (>=0.3.0,<2.0.0dev)", "geopandas (>=0.9.0,<1.0dev)", "google-cloud-bigquery-storage (>=2.6.0,<3.0.0dev)", "grpcio (>=1.47.0,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)", "ipywidgets (>=7.7.0)", "opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)", "pandas (>=1.1.0)", "proto-plus (>=1.15.0,<2.0.0dev)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev)", "pyarrow (>=3.0.0)", "tqdm (>=4.7.4,<5.0.0dev)"] +bigquery-v2 = ["proto-plus (>=1.15.0,<2.0.0dev)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev)"] +bqstorage = ["google-cloud-bigquery-storage (>=2.6.0,<3.0.0dev)", "grpcio (>=1.47.0,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "pyarrow (>=3.0.0)"] +geopandas = ["Shapely (>=1.8.4,<3.0.0dev)", "geopandas (>=0.9.0,<1.0dev)"] ipython = ["ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)"] ipywidgets = ["ipykernel (>=6.0.0)", "ipywidgets (>=7.7.0)"] opentelemetry = ["opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)"] -pandas = ["db-dtypes (>=0.3.0,<2.0.0.dev0)", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "pandas (>=1.1.0)", "pyarrow (>=3.0.0)"] -tqdm = ["tqdm (>=4.7.4,<5.0.0.dev0)"] +pandas = ["db-dtypes (>=0.3.0,<2.0.0dev)", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "pandas (>=1.1.0)", "pyarrow (>=3.0.0)"] +tqdm = ["tqdm (>=4.7.4,<5.0.0dev)"] [[package]] name = "google-cloud-core" @@ -1416,11 +1442,11 @@ files = [ ] [package.dependencies] -google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0.dev0" -google-auth = ">=1.25.0,<3.0.dev0" +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" [package.extras] -grpc = ["grpcio (>=1.38.0,<2.0.dev0)", "grpcio-status (>=1.38.0,<2.0.dev0)"] +grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] [[package]] name = "google-cloud-dlp" @@ -1435,10 +1461,10 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -proto-plus = ">=1.22.3,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" [[package]] name = "google-cloud-firestore" @@ -1454,11 +1480,11 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -google-cloud-core = ">=1.4.1,<3.0.0.dev0" -proto-plus = {version = ">=1.22.2,<2.0.0.dev0", markers = "python_version >= \"3.11\""} -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +google-cloud-core = ">=1.4.1,<3.0.0dev" +proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" [[package]] name = "google-cloud-resource-manager" @@ -1473,11 +1499,11 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0" -proto-plus = ">=1.22.3,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" [[package]] name = "google-cloud-storage" @@ -1492,15 +1518,15 @@ files = [ ] [package.dependencies] -google-api-core = ">=2.15.0,<3.0.0.dev0" -google-auth = ">=2.26.1,<3.0.dev0" -google-cloud-core = ">=2.3.0,<3.0.dev0" -google-crc32c = ">=1.0,<2.0.dev0" +google-api-core = ">=2.15.0,<3.0.0dev" +google-auth = ">=2.26.1,<3.0dev" +google-cloud-core = ">=2.3.0,<3.0dev" +google-crc32c = ">=1.0,<2.0dev" google-resumable-media = ">=2.7.2" -requests = ">=2.18.0,<3.0.0.dev0" +requests = ">=2.18.0,<3.0.0dev" [package.extras] -protobuf = ["protobuf (<6.0.0.dev0)"] +protobuf = ["protobuf (<6.0.0dev)"] tracing = ["opentelemetry-api (>=1.1.0)"] [[package]] @@ -1622,11 +1648,11 @@ files = [ ] [package.dependencies] -google-crc32c = ">=1.0,<2.0.dev0" +google-crc32c = ">=1.0,<2.0dev" [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "google-auth (>=1.22.0,<2.0.dev0)"] -requests = ["requests (>=2.18.0,<3.0.0.dev0)"] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] +requests = ["requests (>=2.18.0,<3.0.0dev)"] [[package]] name = "googleapis-common-protos" @@ -1660,9 +1686,9 @@ files = [ ] [package.dependencies] -googleapis-common-protos = {version = ">=1.56.0,<2.0.0.dev0", extras = ["grpc"]} -grpcio = ">=1.44.0,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +googleapis-common-protos = {version = ">=1.56.0,<2.0.0dev", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" [[package]] name = "grpcio" @@ -2832,6 +2858,21 @@ packaging = "*" protobuf = "*" sympy = "*" +[[package]] +name = "openpyxl" +version = "3.1.5" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, + {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, +] + +[package.dependencies] +et-xmlfile = "*" + [[package]] name = "orjson" version = "3.10.3" @@ -3152,7 +3193,7 @@ files = [ ] [package.dependencies] -protobuf = ">=3.19.0,<5.0.0.dev0" +protobuf = ">=3.19.0,<5.0.0dev" [package.extras] testing = ["google-api-core[grpc] (>=1.31.5)"] @@ -3460,7 +3501,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.2.2,<=3.3.0.dev0" +astroid = ">=3.2.2,<=3.3.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, @@ -3934,14 +3975,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "14.3.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.8.0" groups = ["main", "dev"] files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, + {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, ] [package.dependencies] @@ -5007,4 +5048,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "1d1a0082eddcc6cda0656156b190fcdaa91b9ce380a10371743a895372fa0f89" +content-hash = "0b4409c9c9f0485fc90a0ecfc35812a95d7023c605da05b5b899668d9e5406e0" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 67467be8..566b307f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,7 @@ fix-busted-json=">=0.0.17,<1.0.0" json-repair=">=0.44.1,<1.0.0" motor = "^3.4.0" google-cloud-aiplatform = "^1.53.0" +google-genai = "^1.26.0" pydantic = "^2.7.3" pydantic-settings = "^2.3.1" fastapi = "^0.111.0" diff --git a/backend/scripts/export_conversation/import_script.py b/backend/scripts/export_conversation/import_script.py index b0027618..14c866ca 100755 --- a/backend/scripts/export_conversation/import_script.py +++ b/backend/scripts/export_conversation/import_script.py @@ -11,8 +11,9 @@ from pydantic_settings import BaseSettings from app.application_state import ApplicationStateStore, ApplicationState +from app.users.generate_session_id import generate_new_session_id from app.users.repositories import UserPreferenceRepository -from app.users.sessions import SessionsService +from app.users.types import UserPreferencesRepositoryUpdateRequest from _common import StoreType, create_store, get_db_connection from common_libs.logging.log_utilities import setup_logging_config from scripts.export_conversation.constants import SCRIPT_DIR, DEFAULT_EXPORTS_DIR @@ -188,13 +189,12 @@ async def import_conversation( logger.error(f"No state found for session {source_session_id}") return False - # Create a new session for the target user user_repository = UserPreferenceRepository(target_db) - session_service = SessionsService(user_repository) - - # get the new session on the target user preferences with new session id. - new_user_preferences = await session_service.new_session(target_user_id) - target_new_session_id = new_user_preferences.sessions[0] + target_new_session_id = generate_new_session_id() + await user_repository.update_user_preference( + target_user_id, + UserPreferencesRepositoryUpdateRequest(sessions=[target_new_session_id]), + ) # Update session ID in the state state.session_id = target_new_session_id diff --git a/backend/scripts/export_conversation/sample_conversation.json b/backend/scripts/export_conversation/sample_conversation.json index 2f2ab539..51316808 100644 --- a/backend/scripts/export_conversation/sample_conversation.json +++ b/backend/scripts/export_conversation/sample_conversation.json @@ -26,7 +26,7 @@ "start": "2020/01", "end": "2023/08" }, - "work_type": "FORMAL_SECTOR_WAGED_EMPLOYMENT", + "work_type": "FORMAL_SECTOR_UNPAID_TRAINEE_WORK", "responsibilities": { "responsibilities": [ "I am done" @@ -131,12 +131,12 @@ "start_date": "2020/01", "end_date": "2023/08", "paid_work": true, - "work_type": "FORMAL_SECTOR_WAGED_EMPLOYMENT" + "work_type": "FORMAL_SECTOR_UNPAID_TRAINEE_WORK" } ], "unexplored_types": [], "explored_types": [ - "FORMAL_SECTOR_WAGED_EMPLOYMENT" + "FORMAL_SECTOR_UNPAID_TRAINEE_WORK" ], "first_time_visit": false }, diff --git a/backend/scripts/export_conversation/test_roundtrip.py b/backend/scripts/export_conversation/test_roundtrip.py index a7224074..516cc158 100644 --- a/backend/scripts/export_conversation/test_roundtrip.py +++ b/backend/scripts/export_conversation/test_roundtrip.py @@ -119,7 +119,7 @@ async def test_import_and_export_scripts(self, given_imported_import_session_id = new_user_preferences.sessions[0] - # AND the imported conversation is imported for the second time + # AND the imported conversation is imported for the second time (copy to new session, single-session replaces) successfully_imported_2 = await import_conversation( source_session_id=given_imported_import_session_id, source_type="DB", @@ -127,14 +127,13 @@ async def test_import_and_export_scripts(self, target_user_id=given_user_id ) - # AND the conversation is successfully for the second time. assert successfully_imported_2 new_user_preferences = await user_preferences_repository.get_user_preference_by_user_id(user_id=given_user_id) - # GUARD user_preferences.sessions.length should be now two - assert len(new_user_preferences.sessions) == 2 + assert len(new_user_preferences.sessions) == 1 + second_import_session_id = new_user_preferences.sessions[0] - # AND the second conversation is exported from first JSON to md. + # Phase 2: Exporting. await export_conversations( session_ids=[given_first_session_id], source_type="JSON", @@ -143,41 +142,26 @@ async def test_import_and_export_scripts(self, queue_size=1 ) - # Phase 2: Exporting. - # AND the 2nd imported conversation is exported into JSON await export_conversations( - session_ids=new_user_preferences.sessions, + session_ids=[second_import_session_id], source_type="DB", target_type="JSON", output_directory=given_output_directory, queue_size=1 ) - # AND the 2nd imported conversation is exported into markdown await export_conversations( - session_ids=new_user_preferences.sessions, + session_ids=[second_import_session_id], source_type="DB", target_type="MD", output_directory=given_output_directory, queue_size=1 ) - # Phase 3: assertions - - assert new_user_preferences.sessions[0] != new_user_preferences.sessions[1] - assert given_first_session_id != new_user_preferences.sessions[1] - - # Assert given_json = exported_json - _compare_jsons(given_output_directory, given_first_session_id, new_user_preferences.sessions[1]) - - # AND the first imported/exported json should match the second imported/exported json. - _compare_jsons(given_output_directory, new_user_preferences.sessions[0], new_user_preferences.sessions[1]) - - # Assert exported_md = exported_md_1 - _compare_markdowns(given_output_directory, given_first_session_id, new_user_preferences.sessions[1]) - - # AND the first imported/exported md should match the second imported/exported md. - _compare_markdowns(given_output_directory, new_user_preferences.sessions[0], new_user_preferences.sessions[1]) + # Phase 3: assertions - roundtrip: original JSON should match exported-from-DB JSON + assert given_first_session_id != second_import_session_id + _compare_jsons(given_output_directory, given_first_session_id, second_import_session_id) + _compare_markdowns(given_output_directory, given_first_session_id, second_import_session_id) # clean up created files shutil.rmtree(given_output_directory) diff --git a/backend/scripts/ingest_sector_document.py b/backend/scripts/ingest_sector_document.py new file mode 100644 index 00000000..1c097ffe --- /dev/null +++ b/backend/scripts/ingest_sector_document.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +Ingest Knowledge Hub documents into the Career Explorer vector store. + +Supports markdown files directly. For other formats (PDF, DOCX, etc.) uses markitdown +to convert to markdown before processing. + +Usage (single sector): + poetry run python -m scripts.ingest_sector_document \ + --markdown-path ../frontend-new/src/knowledgeHub/documents/agriculture.md \ + --sector Agriculture \ + --hot-run + +Usage (all sectors): + poetry run python -m scripts.ingest_sector_document \ + --ingest-all \ + --hot-run + +Environment: + CAREER_EXPLORER_MONGODB_URI, CAREER_EXPLORER_DATABASE_NAME - for the database + CAREER_EXPLORER_CONFIG - JSON with sectors, country (sectors list used for --ingest-all) + VERTEX_API_REGION - for embeddings + EMBEDDINGS_MODEL_NAME - same as app config for consistency +""" +import argparse +import asyncio +import json +import logging +import os +import re +from pathlib import Path + +from dotenv import load_dotenv +from motor.motor_asyncio import AsyncIOMotorClient +from pymongo.operations import SearchIndexModel + +load_dotenv() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +CHUNK_SIZE = 500 +CHUNK_OVERLAP = 50 +INDEX_NAME = "sector_chunks_embedding_index" +EMBEDDING_KEY = "embedding" +MARKDOWN_EXTENSIONS = {".md"} + +DEFAULT_SECTORS_CONFIG = [ + {"name": "Agriculture", "description": "Commercial farming, agriprocessing", "file": "agriculture.md"}, + {"name": "Energy", "description": "Power generation, solar, renewables", "file": "energy.md"}, + {"name": "Mining", "description": "Copper, gold, gemstones", "file": "mining.md"}, + {"name": "Hospitality", "description": "Hotels, tourism, safari lodges", "file": "hospitality.md"}, + {"name": "Water", "description": "Treatment, supply, sanitation", "file": "water.md"}, +] + + +def load_document_text(file_path: Path) -> str: + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + suffix = file_path.suffix.lower() + if suffix in MARKDOWN_EXTENSIONS: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + logger.info("Converting %s to markdown via markitdown", file_path.name) + from markitdown import MarkItDown + converter = MarkItDown() + result = converter.convert(str(file_path)) + return getattr(result, "markdown", None) or getattr(result, "text_content", "") or "" + + +def strip_yaml_frontmatter(content: str) -> str: + frontmatter_regex = re.compile(r"^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$", re.MULTILINE) + match = frontmatter_regex.match(content) + if match: + return match.group(2) + return content + + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]: + words = text.split() + chunks = [] + for i in range(0, len(words), chunk_size - overlap): + chunk_words = words[i : i + chunk_size] + if chunk_words: + chunks.append(" ".join(chunk_words)) + return chunks + + +async def ensure_vector_index(collection, num_dimensions: int, hot_run: bool) -> None: + existing = False + async for idx in collection.list_search_indexes(): + if idx.get("name") == INDEX_NAME: + existing = True + break + + definition = { + "fields": [ + {"numDimensions": num_dimensions, "path": EMBEDDING_KEY, "similarity": "cosine", "type": "vector"}, + {"path": "sector", "type": "filter"}, + ] + } + + if existing: + if hot_run: + await collection.update_search_index(INDEX_NAME, definition) + logger.info("Updated vector index") + else: + logger.info("Would update vector index") + else: + if hot_run: + await collection.create_search_index( + model=SearchIndexModel(definition=definition, name=INDEX_NAME, type="vectorSearch") + ) + logger.info("Created vector index") + else: + logger.info("Would create vector index") + + +async def ingest_sector(file_path: Path, sector: str, collection, embedding_service, num_dimensions: int, hot_run: bool, clear_first: bool): + logger.info("Loading document: %s", file_path) + content = load_document_text(file_path) + + text = strip_yaml_frontmatter(content) + if not text or len(text.strip()) < 100: + raise ValueError(f"Document produced insufficient text: {len(text)} chars") + + chunks = chunk_text(text) + logger.info("Created %d chunks from %d chars for sector %s", len(chunks), len(text), sector) + + if clear_first and hot_run: + deleted = await collection.delete_many({"sector": sector}) + logger.info("Cleared %d existing chunks for sector %s", deleted.deleted_count, sector) + + if not hot_run: + logger.info("Dry run - would ingest %d chunks for sector %s", len(chunks), sector) + return + + batch_size = 50 + + if len(chunks) > 0: + first_batch = chunks[0:min(batch_size, len(chunks))] + first_embeddings = await embedding_service.embed_batch(first_batch) + first_docs = [] + for j, (chunk_content, embedding) in enumerate(zip(first_batch, first_embeddings)): + chunk_id = f"{sector.lower()}_{j:05d}" + first_docs.append({ + "chunk_id": chunk_id, + "sector": sector, + "text": chunk_content, + "metadata": {"source": file_path.name, "chunk_index": j}, + "embedding": embedding, + }) + await collection.insert_many(first_docs) + logger.info("Inserted first batch (%d chunks) to create collection", len(first_docs)) + + if len(chunks) > batch_size: + for i in range(batch_size, len(chunks), batch_size): + batch = chunks[i : i + batch_size] + embeddings = await embedding_service.embed_batch(batch) + docs = [] + for j, (chunk_content, embedding) in enumerate(zip(batch, embeddings)): + chunk_id = f"{sector.lower()}_{i + j:05d}" + docs.append({ + "chunk_id": chunk_id, + "sector": sector, + "text": chunk_content, + "metadata": {"source": file_path.name, "chunk_index": i + j}, + "embedding": embedding, + }) + await collection.insert_many(docs) + logger.info("Inserted chunks %d-%d", i, i + len(batch) - 1) + + logger.info("Done. Ingested %d chunks for sector %s", len(chunks), sector) + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--markdown-path", help="Path to a document file (markdown, PDF, Word, etc.) - required if --ingest-all not set") + parser.add_argument("--sector", help="Sector name (e.g. Agriculture) - required if --markdown-path is set") + parser.add_argument("--ingest-all", action="store_true", help="Ingest all Knowledge Hub markdown files") + parser.add_argument("--hot-run", action="store_true", help="Actually write to DB") + parser.add_argument("--clear-first", action="store_true", help="Delete existing chunks for sector(s) before ingesting") + args = parser.parse_args() + + if not args.ingest_all and (not args.markdown_path or not args.sector): + parser.error("Either --ingest-all or both --markdown-path and --sector must be provided") + + mongodb_uri = os.getenv("CAREER_EXPLORER_MONGODB_URI") + db_name = os.getenv("CAREER_EXPLORER_DATABASE_NAME") + if not mongodb_uri or not db_name: + raise ValueError("Set CAREER_EXPLORER_MONGODB_URI and CAREER_EXPLORER_DATABASE_NAME") + + client = AsyncIOMotorClient(mongodb_uri, tlsAllowInvalidCertificates=True) + db = client.get_database(db_name) + collection = db["career_explorer_sector_chunks"] + + embedding_model = os.getenv("EMBEDDINGS_MODEL_NAME", "text-embedding-005") + + from app.vector_search.embeddings_model import GoogleEmbeddingService + + embedding_service = GoogleEmbeddingService(model_name=embedding_model) + num_dimensions = 768 + + if args.ingest_all: + base_path = Path(__file__).parent.parent.parent / "frontend-new" / "src" / "knowledgeHub" / "documents" + config_json = os.getenv("CAREER_EXPLORER_CONFIG") + if config_json: + try: + config_data = json.loads(config_json) + sectors_config = config_data.get("sectors", DEFAULT_SECTORS_CONFIG) + except json.JSONDecodeError: + logger.warning("Invalid CAREER_EXPLORER_CONFIG JSON, falling back to default sectors") + sectors_config = DEFAULT_SECTORS_CONFIG + else: + sectors_config = DEFAULT_SECTORS_CONFIG + + sector_files = {} + for sector in sectors_config: + sector_name = sector.get("name") + sector_file = sector.get("file") + if sector_name and sector_file: + sector_files[sector_name] = base_path / sector_file + else: + logger.warning("Skipping invalid sector config entry: %s", sector) + + if args.clear_first and args.hot_run: + deleted = await collection.delete_many({}) + logger.info("Cleared all existing chunks") + + first_sector_processed = False + for sector, md_path in sector_files.items(): + if args.hot_run and not first_sector_processed: + await ingest_sector(md_path, sector, collection, embedding_service, num_dimensions, args.hot_run, False) + await ensure_vector_index(collection, num_dimensions, args.hot_run) + first_sector_processed = True + else: + await ingest_sector(md_path, sector, collection, embedding_service, num_dimensions, args.hot_run, False) + else: + markdown_path = Path(args.markdown_path) + if args.clear_first and args.hot_run: + deleted = await collection.delete_many({"sector": args.sector}) + logger.info("Cleared %d existing chunks for sector %s", deleted.deleted_count, args.sector) + + if args.hot_run: + await ingest_sector(markdown_path, args.sector, collection, embedding_service, num_dimensions, args.hot_run, False) + await ensure_vector_index(collection, num_dimensions, args.hot_run) + else: + await ingest_sector(markdown_path, args.sector, collection, embedding_service, num_dimensions, args.hot_run, False) + + client.close() + await asyncio.sleep(0.1) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/backend/scripts/populate_sample_conversation.py b/backend/scripts/populate_sample_conversation.py index 28003f88..5e603377 100644 --- a/backend/scripts/populate_sample_conversation.py +++ b/backend/scripts/populate_sample_conversation.py @@ -186,7 +186,7 @@ def create_explore_experiences_director_state(_session_id: int) -> ExploreExperi experience_title="Baker at Sweet Delights Bakery", company="Sweet Delights Bakery", location="Cape Town", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, responsibilities=ResponsibilitiesData( responsibilities=[ "Prepared a variety of breads, pastries, and desserts daily", @@ -245,7 +245,7 @@ def create_explore_experiences_director_state(_session_id: int) -> ExploreExperi experience_title="Freelance Cake Designer", company="Self-employed", location="Johannesburg", - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.UNSEEN_UNPAID, responsibilities=ResponsibilitiesData( responsibilities=[ "Designed and created custom cakes for special events and weddings", @@ -337,7 +337,7 @@ def create_collect_experience_state(_session_id: int) -> CollectExperiencesAgent start_date="January 2019", end_date="December 2021", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( index=1, @@ -348,7 +348,7 @@ def create_collect_experience_state(_session_id: int) -> CollectExperiencesAgent start_date="January 2022", end_date="Present", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ] @@ -356,7 +356,7 @@ def create_collect_experience_state(_session_id: int) -> CollectExperiencesAgent session_id=_session_id, collected_data=collected_data, unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], first_time_visit=False ) diff --git a/backend/scripts/test_preference_agent_interactive.py b/backend/scripts/test_preference_agent_interactive.py index e6a85ad9..ec03552d 100755 --- a/backend/scripts/test_preference_agent_interactive.py +++ b/backend/scripts/test_preference_agent_interactive.py @@ -185,7 +185,7 @@ def create_sample_experiences() -> List[ExperienceEntity]: company="Alliance High School", location="Kikuyu", timeline=Timeline(start="2018", end="2023"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), ExperienceEntity( uuid="exp-2", @@ -193,7 +193,7 @@ def create_sample_experiences() -> List[ExperienceEntity]: company="Self-employed", location="Nairobi", timeline=Timeline(start="2023", end="Present"), - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ), ExperienceEntity( uuid="exp-3", @@ -201,7 +201,7 @@ def create_sample_experiences() -> List[ExperienceEntity]: company="Mang'u High School", location="Thika", timeline=Timeline(start="2017", end="2017"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/config/njira.json b/config/njira.json new file mode 100644 index 00000000..679d4e79 --- /dev/null +++ b/config/njira.json @@ -0,0 +1,127 @@ +{ + "branding": { + "appName": "Njira", + "browserTabTitle": "Njira", + "metaDescription": "Welcome to Njira. An AI-powered career assistant that helps jobseekers identify and showcase their skills. Join now to create a digital profile and connect with new opportunities.", + "seo": { + "name": "Njira", + "url": "https://www.example.org/njira", + "image": "https://www.example.org/assets/logo.svg", + "description": "Njira is an AI-powered conversational tool that helps jobseekers discover and articulate their skills. Through natural dialogue, Njira guides users to identify abilities gained through all types of work." + }, + "assets": { + "logo": "/nijra_logo.svg", + "favicon": "/nijra_logo.svg", + "appIcon": "/nijra_logo.svg" + }, + "theme": { + "brand-primary": "239 123 0", + "brand-primary-light": "250 166 39", + "brand-primary-dark": "233 108 0", + "brand-primary-contrast-text": "0 0 0", + "brand-secondary": "25 138 0", + "brand-secondary-light": "87 183 69", + "brand-secondary-dark": "1 121 0", + "brand-secondary-contrast-text": "255 255 255", + "text-primary": "0 33 71", + "text-secondary": "65 64 61", + "text-accent": "38 94 167" + } + }, + "auth": { + "disableLoginCode": true, + "disableRegistrationCode": true + }, + "cv": { + "enabled": true + }, + "skillsReport": { + "logos": [ + { + "url": "/logo.png", + "docxStyles": { + "width": 250, + "height": 62 + }, + "pdfStyles": { + "height": 46 + } + } + ], + "downloadFormats": [ + "DOCX", + "PDF" + ], + "report": { + "summary": { + "show": true + }, + "experienceDetails": { + "title": true, + "summary": true, + "location": true, + "dateRange": true, + "companyName": true + } + } + }, + "i18n": { + "ui": { + "defaultLocale": "en-US", + "supportedLocales": [ + "en-US", + "en-GB", + "ny-ZM" + ] + }, + "conversation": { + "default_locale": "en-US", + "available_locales": [ + { + "locale": "en-US", + "date_format": "YYYY/MM/DD" + } + ] + } + }, + "modules": { + "enabled": true, + "list": [ + { + "id": "skills_discovery", + "labelKey": "home.modules.skillsDiscovery", + "descriptionKey": "home.modules.skillsDiscoveryDesc", + "route": "skills-discovery", + "isStub": false + }, + { + "id": "career_explorer", + "labelKey": "home.modules.careerExplorer", + "descriptionKey": "home.modules.careerExplorerDesc", + "route": "career-explorer", + "isStub": true + }, + { + "id": "job_readiness", + "labelKey": "home.modules.jobReadiness", + "descriptionKey": "home.modules.jobReadinessDesc", + "route": "job-readiness", + "isStub": true + }, + { + "id": "knowledge_hub", + "labelKey": "home.modules.knowledgeHub", + "descriptionKey": "home.modules.knowledgeHubDesc", + "route": "knowledge-hub", + "isStub": true + }, + { + "id": "job_matching", + "labelKey": "home.modules.jobMatching", + "descriptionKey": "home.modules.jobMatchingDesc", + "route": "job-matching", + "isStub": true + } + ] + } +} \ No newline at end of file diff --git a/frontend-new/package.json b/frontend-new/package.json index 86e187b2..d0c73d6f 100644 --- a/frontend-new/package.json +++ b/frontend-new/package.json @@ -76,13 +76,16 @@ "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "notistack": "^3.0.1", + "raw-loader": "^4.0.2", "react": "18.3.1", "react-device-detect": "^2.2.3", "react-dom": "~18.2.0", "react-i18next": "^14.1.2", + "react-markdown": "^10.1.0", "react-router-dom": "^6.23.1", "react-scripts": "^5.0.1", "react-swipeable": "^7.0.2", + "remark-gfm": "^4.0.1", "source-map-explorer": "^2.5.3", "web-vitals": "^2.1.4" }, @@ -123,6 +126,12 @@ "transformIgnorePatterns": [ "node_modules/(?!p-limit)/" ], + "transform": { + "^.+\\.md$": "/src/_test_utilities/rawLoaderTransformer.js" + }, + "moduleNameMapper": { + "^!!raw-loader!(.*)$": "$1" + }, "collectCoverageFrom": [ "src/**/*.{js,jsx,ts,tsx}", "!/node_modules/", diff --git a/frontend-new/public/nijra_logo.svg b/frontend-new/public/nijra_logo.svg new file mode 100644 index 00000000..e8caad10 --- /dev/null +++ b/frontend-new/public/nijra_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend-new/public/tabiya-logo.svg b/frontend-new/public/tabiya-logo.svg new file mode 100644 index 00000000..70dd8ba5 --- /dev/null +++ b/frontend-new/public/tabiya-logo.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend-new/public/world-bank-logo.svg b/frontend-new/public/world-bank-logo.svg new file mode 100644 index 00000000..485b2334 --- /dev/null +++ b/frontend-new/public/world-bank-logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + diff --git a/frontend-new/src/_test_utilities/rawLoaderTransformer.js b/frontend-new/src/_test_utilities/rawLoaderTransformer.js new file mode 100644 index 00000000..e1c69ab0 --- /dev/null +++ b/frontend-new/src/_test_utilities/rawLoaderTransformer.js @@ -0,0 +1,19 @@ +// Custom Jest transformer for raw-loader imports +// This handles imports like: import content from "!!raw-loader!./file.md" +const fs = require("fs"); +const path = require("path"); + +module.exports = { + process(sourceText, sourcePath, options) { + // Extract the actual file path from the import + // The sourcePath will be something like: /path/to/project/src/knowledgeHub/documents/mining.md + + // Read the file content + const content = fs.readFileSync(sourcePath, "utf8"); + + // Return as a module that exports the content as default + return { + code: `module.exports = ${JSON.stringify(content)};`, + }; + }, +}; diff --git a/frontend-new/src/app/PersistentStorageService/PersistentStorageService.ts b/frontend-new/src/app/PersistentStorageService/PersistentStorageService.ts index a90f1384..7df4ed67 100644 --- a/frontend-new/src/app/PersistentStorageService/PersistentStorageService.ts +++ b/frontend-new/src/app/PersistentStorageService/PersistentStorageService.ts @@ -1,6 +1,7 @@ import { Invitation } from "src/auth/services/invitationsService/invitations.types"; import { StoredPersonalInfo } from "src/sensitiveData/types"; import { FeedbackItem } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; +import type { PersistedCareerReadinessQuizResult, QuizQuestionResponse } from "src/careerReadiness/types"; const PERSISTENT_STORAGE_VERSION = "0.0.1"; export const TOKEN_KEY = `token_${PERSISTENT_STORAGE_VERSION}`; @@ -19,6 +20,20 @@ export const FEEDBACK_NOTIFICATION_KEY = `feedback_notification_${PERSISTENT_STO export const CLIENT_ID_KEY = `client_id_${PERSISTENT_STORAGE_VERSION}`; +export const CAREER_READINESS_QUIZ_DATA_KEY = `career_readiness_quiz_data_${PERSISTENT_STORAGE_VERSION}`; + +export const CAREER_READINESS_QUIZ_RESULT_KEY = `career_readiness_quiz_result_${PERSISTENT_STORAGE_VERSION}`; + +interface PersistedCareerReadinessQuizData { + questions?: QuizQuestionResponse[]; + answers?: Record; +} + +export interface CareerReadinessQuizData { + questions?: QuizQuestionResponse[]; + answers?: Record; +} + /** * This class is used to store the tokens in the session storage. * eg: refresh token @@ -26,6 +41,36 @@ export const CLIENT_ID_KEY = `client_id_${PERSISTENT_STORAGE_VERSION}`; export class PersistentStorageService { static readonly storage = localStorage; + private static getCareerReadinessQuizStorageKey(baseKey: string, moduleId: string, conversationId: string): string { + return `${baseKey}_${moduleId}_${conversationId}`; + } + + private static getCareerReadinessQuizDataRaw( + moduleId: string, + conversationId: string + ): PersistedCareerReadinessQuizData | null { + const item = this.storage.getItem( + this.getCareerReadinessQuizStorageKey(CAREER_READINESS_QUIZ_DATA_KEY, moduleId, conversationId) + ); + if (!item) return null; + try { + return JSON.parse(item) as PersistedCareerReadinessQuizData; + } catch { + return null; + } + } + + private static setCareerReadinessQuizDataRaw( + moduleId: string, + conversationId: string, + data: PersistedCareerReadinessQuizData + ): void { + this.storage.setItem( + this.getCareerReadinessQuizStorageKey(CAREER_READINESS_QUIZ_DATA_KEY, moduleId, conversationId), + JSON.stringify(data) + ); + } + /** * Returns the token from the storage * @returns string | null - The token @@ -232,4 +277,71 @@ export class PersistentStorageService { static setClientID(clientId: string): void { this.storage.setItem(CLIENT_ID_KEY, clientId); } + + static getCareerReadinessQuizData(moduleId: string, conversationId: string): CareerReadinessQuizData | null { + const raw = this.getCareerReadinessQuizDataRaw(moduleId, conversationId); + if (!raw) return null; + + const answers = raw.answers + ? (Object.fromEntries( + Object.entries(raw.answers) + .map(([k, v]) => [Number(k), v] as const) + .filter(([k, v]) => !Number.isNaN(k) && typeof v === "string") + ) as Record) + : undefined; + + return { + questions: Array.isArray(raw.questions) && raw.questions.length > 0 ? raw.questions : undefined, + answers: answers && Object.keys(answers).length > 0 ? answers : undefined, + }; + } + + static setCareerReadinessQuizData(moduleId: string, conversationId: string, data: CareerReadinessQuizData): void { + const answers = data.answers + ? Object.fromEntries(Object.entries(data.answers).map(([k, v]) => [String(k), v])) + : undefined; + + this.setCareerReadinessQuizDataRaw(moduleId, conversationId, { + questions: data.questions, + answers, + }); + } + + static clearCareerReadinessQuizData(moduleId: string, conversationId: string): void { + this.storage.removeItem( + this.getCareerReadinessQuizStorageKey(CAREER_READINESS_QUIZ_DATA_KEY, moduleId, conversationId) + ); + } + + static getCareerReadinessQuizResult( + moduleId: string, + conversationId: string + ): PersistedCareerReadinessQuizResult | null { + const item = this.storage.getItem( + this.getCareerReadinessQuizStorageKey(CAREER_READINESS_QUIZ_RESULT_KEY, moduleId, conversationId) + ); + if (!item) return null; + try { + return JSON.parse(item) as PersistedCareerReadinessQuizResult; + } catch { + return null; + } + } + + static setCareerReadinessQuizResult( + moduleId: string, + conversationId: string, + result: PersistedCareerReadinessQuizResult + ): void { + this.storage.setItem( + this.getCareerReadinessQuizStorageKey(CAREER_READINESS_QUIZ_RESULT_KEY, moduleId, conversationId), + JSON.stringify(result) + ); + } + + static clearCareerReadinessQuizResult(moduleId: string, conversationId: string): void { + this.storage.removeItem( + this.getCareerReadinessQuizStorageKey(CAREER_READINESS_QUIZ_RESULT_KEY, moduleId, conversationId) + ); + } } diff --git a/frontend-new/src/app/index.tsx b/frontend-new/src/app/index.tsx index 90cd4a6e..53a21666 100644 --- a/frontend-new/src/app/index.tsx +++ b/frontend-new/src/app/index.tsx @@ -24,12 +24,28 @@ import { TokenValidationFailureCause } from "src/auth/services/Authentication.se import { AuthBroadcastChannel, AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel"; import { getRegistrationDisabled } from "src/envService"; import { useTranslation } from "react-i18next"; +import Home from "src/home/Home"; const LazyLoadedSensitiveDataForm = lazyWithPreload( () => import("src/sensitiveData/components/sensitiveDataForm/SensitiveDataForm") ); const LazyLoadedChat = lazyWithPreload(() => import("src/chat/Chat")); +const LazyLoadedKnowledgeHubDocument = lazyWithPreload(() => import("src/knowledgeHub/pages/KnowledgeHubDocument")); +const LazyLoadedKnowledgeHubList = lazyWithPreload(() => import("src/knowledgeHub/pages/KnowledgeHubList")); +const LazyLoadedCareerExplorer = lazyWithPreload( + () => import("src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage") +); + +const LazyLoadedCareerReadinessList = lazyWithPreload( + () => import("src/careerReadiness/pages/CareerReadinessList/CareerReadinessList") +); +const LazyLoadedCareerReadinessModule = lazyWithPreload( + () => import("src/careerReadiness/pages/CareerReadinessModule/CareerReadinessModule") +); + +const LazyLoadedProfile = lazyWithPreload(() => import("src/profile/ProfileContainer")); + // Wrap the createHashRouter function with Sentry to capture errors that occur during router initialization const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createHashRouter); @@ -41,13 +57,20 @@ export const SNACKBAR_KEYS = { const ProtectedRouteKeys = { ROOT: "ROOT", + SKILLS_INTERESTS: "SKILLS_INTERESTS", LANDING: "LANDING", SETTINGS: "SETTINGS", REGISTER: "REGISTER", LOGIN: "LOGIN", VERIFY_EMAIL: "VERIFY_EMAIL", + PROFILE: "PROFILE", CONSENT: "CONSENT", SENSITIVE_DATA: "SENSITIVE_DATA", + KNOWLEDGE_HUB: "KNOWLEDGE_HUB", + KNOWLEDGE_HUB_DOCUMENT: "KNOWLEDGE_HUB_DOCUMENT", + CAREER_EXPLORER: "CAREER_EXPLORER", + CAREER_READINESS: "CAREER_READINESS", + CAREER_READINESS_MODULE: "CAREER_READINESS_MODULE", }; const NotFound: React.FC = () => { @@ -250,6 +273,14 @@ const App = () => { path: routerPaths.ROOT, element: ( + + + ), + }, + { + path: routerPaths.SKILLS_INTERESTS, + element: ( + ), @@ -307,6 +338,54 @@ const App = () => { ), }, + { + path: routerPaths.KNOWLEDGE_HUB, + element: ( + + + + ), + }, + { + path: routerPaths.KNOWLEDGE_HUB_DOCUMENT, + element: ( + + + + ), + }, + { + path: routerPaths.CAREER_EXPLORER, + element: ( + + + + ), + }, + { + path: routerPaths.CAREER_READINESS, + element: ( + + + + ), + }, + { + path: routerPaths.CAREER_READINESS_MODULE, + element: ( + + + + ), + }, + { + path: routerPaths.PROFILE, + element: ( + + + + ), + }, { path: "*", element: , diff --git a/frontend-new/src/app/routerPaths.ts b/frontend-new/src/app/routerPaths.ts index 6c99220a..b6ab0924 100644 --- a/frontend-new/src/app/routerPaths.ts +++ b/frontend-new/src/app/routerPaths.ts @@ -2,10 +2,17 @@ export const routerPaths = { ROOT: "/", LANDING: "/landing", SETTINGS: "/settings", + PROFILE: "/profile", REGISTER: "/register", LOGIN: "/login", FORGOT_PASSWORD: "/reset", VERIFY_EMAIL: "/verify-email", CONSENT: "/consent", SENSITIVE_DATA: "/sensitive-data", + CAREER_EXPLORER: "/career-explorer", + KNOWLEDGE_HUB: "/knowledge-hub", + KNOWLEDGE_HUB_DOCUMENT: "/knowledge-hub/:documentId", + SKILLS_INTERESTS: "/skills-interests", + CAREER_READINESS: "/career-readiness", + CAREER_READINESS_MODULE: "/career-readiness/:moduleId", }; diff --git a/frontend-new/src/askMeAnything/components/AMAAgentMessage/AMAAgentMessage.tsx b/frontend-new/src/askMeAnything/components/AMAAgentMessage/AMAAgentMessage.tsx new file mode 100644 index 00000000..46b8aed4 --- /dev/null +++ b/frontend-new/src/askMeAnything/components/AMAAgentMessage/AMAAgentMessage.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Box, styled } from "@mui/material"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import Timestamp from "src/chat/chatMessage/components/chatMessageFooter/components/timestamp/Timestamp"; +import ChatMessageFooterLayout from "src/chat/chatMessage/components/chatMessageFooter/ChatMessageFooterLayout"; +import AMASuggestedActions from "src/askMeAnything/components/AMASuggestedActions/AMASuggestedActions"; +import type { SuggestedAction } from "src/askMeAnything/types"; + +const uniqueId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + +export const DATA_TEST_ID = { + AMA_AGENT_MESSAGE_CONTAINER: `ama-agent-message-container-${uniqueId}`, +}; + +export const AMA_AGENT_MESSAGE_TYPE = `ama-agent-message-${uniqueId}`; + +export interface AMAAgentMessageProps { + message_id: string; + message: string; + sent_at: string; + suggested_actions?: SuggestedAction[]; + isLatestAgentMessage?: boolean; +} + +const MessageContainer = styled(Box)<{ origin: ConversationMessageSender }>(({ theme, origin }) => ({ + display: "flex", + flexDirection: "column", + alignItems: origin === ConversationMessageSender.USER ? "flex-end" : "flex-start", + marginBottom: theme.spacing(theme.tabiyaSpacing.sm), + width: "100%", +})); + +const AMAAgentMessage: React.FC = ({ + message_id, + message, + sent_at, + suggested_actions = [], + isLatestAgentMessage = false, +}) => { + return ( + + + + + + + + + + {isLatestAgentMessage && suggested_actions.length > 0 && } + + + ); +}; + +export default AMAAgentMessage; diff --git a/frontend-new/src/askMeAnything/components/AMAChat/AMAChat.tsx b/frontend-new/src/askMeAnything/components/AMAChat/AMAChat.tsx new file mode 100644 index 00000000..3b701299 --- /dev/null +++ b/frontend-new/src/askMeAnything/components/AMAChat/AMAChat.tsx @@ -0,0 +1,173 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Box, useTheme } from "@mui/material"; +import { nanoid } from "nanoid"; +import ChatList from "src/chat/chatList/ChatList"; +import ChatMessageField from "src/chat/ChatMessageField/ChatMessageField"; +import { generateSomethingWentWrongMessage, generateUserMessage } from "src/chat/util"; +import type { IChatMessage } from "src/chat/Chat.types"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import AMAService from "src/askMeAnything/services/AMAService"; +import AMAAgentMessage, { + AMA_AGENT_MESSAGE_TYPE, + type AMAAgentMessageProps, +} from "src/askMeAnything/components/AMAAgentMessage/AMAAgentMessage"; +import type { AMAMessage } from "src/askMeAnything/types"; +import { generateCareerReadinessTypingMessage } from "src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage"; +import { useTranslation } from "react-i18next"; +import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; + +const uniqueId = "c3d4e5f6-a7b8-9012-cdef-123456789012"; + +export const DATA_TEST_ID = { + AMA_CHAT_CONTAINER: `ama-chat-container-${uniqueId}`, +}; + +/** + * Map AMA API messages to IChatMessage objects for ChatList. + * The latestAgentMessageId is used to show suggested actions only on the last agent message. + */ +function mapAMAMessagesToChatMessages( + messages: AMAMessage[], + latestAgentMessageId: string | null +): IChatMessage[] { + return messages.map((msg) => { + if (msg.sender === "USER") { + return generateUserMessage(msg.message, msg.sent_at, msg.message_id); + } + const isLatest = msg.message_id === latestAgentMessageId; + const payload: AMAAgentMessageProps = { + message_id: msg.message_id, + message: msg.message, + sent_at: msg.sent_at, + suggested_actions: msg.suggested_actions ?? [], + isLatestAgentMessage: isLatest, + }; + return { + type: AMA_AGENT_MESSAGE_TYPE, + message_id: msg.message_id, + sender: ConversationMessageSender.COMPASS, + payload, + component: (p: AMAAgentMessageProps) => , + }; + }); +} + +const AMAChat: React.FC = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + + const [amaMessages, setAMAMessages] = useState([]); + const [aiIsTyping, setAiIsTyping] = useState(true); // true while loading intro + const [isInitializing, setIsInitializing] = useState(true); + + const typingMessage = useMemo(() => generateCareerReadinessTypingMessage(), []); + + // ID of the latest agent message — used to render suggested actions only on that one + const latestAgentMessageId = useMemo(() => { + const agentMessages = amaMessages.filter((m) => m.sender === "AGENT"); + return agentMessages.length > 0 ? agentMessages[agentMessages.length - 1].message_id : null; + }, [amaMessages]); + + const chatMessages = useMemo(() => { + const mapped = mapAMAMessagesToChatMessages(amaMessages, latestAgentMessageId); + if (aiIsTyping || isInitializing) { + return [...mapped, typingMessage]; + } + return mapped; + }, [amaMessages, latestAgentMessageId, aiIsTyping, isInitializing, typingMessage]); + + // Get initial greeting on mount + const initializeConversation = useCallback(async () => { + setIsInitializing(true); + try { + const res = await AMAService.getInstance().sendMessage(null, []); + setAMAMessages(res.messages); + } catch (e) { + console.error("Failed to initialize AMA conversation", e); + enqueueSnackbar(t("askMeAnything.loadError"), { variant: "error" }); + } finally { + setIsInitializing(false); + setAiIsTyping(false); + } + }, [t, enqueueSnackbar]); + + const initializeConversationRef = useRef(initializeConversation); + initializeConversationRef.current = initializeConversation; + + useEffect(() => { + initializeConversationRef.current(); + }, []); + + const handleSend = useCallback( + async (userMessageText: string) => { + // Optimistic user message for immediate UI feedback + const optimisticId = `optimistic-${nanoid()}`; + const optimisticUserMessage: AMAMessage = { + message_id: optimisticId, + message: userMessageText, + sender: "USER", + sent_at: new Date().toISOString(), + }; + + setAMAMessages((prev) => [...prev, optimisticUserMessage]); + setAiIsTyping(true); + + try { + // Send current messages (minus the optimistic one) as history + const history = amaMessages; // state snapshot before optimistic update + const res = await AMAService.getInstance().sendMessage(userMessageText, history); + setAMAMessages(res.messages); + } catch (e) { + console.error("Failed to send AMA message", e); + setAMAMessages((prev) => [...prev, generateSomethingWentWrongMessage() as unknown as AMAMessage]); + } finally { + setAiIsTyping(false); + } + }, + [amaMessages] + ); + + return ( + + + + + + + + + + ); +}; + +export default AMAChat; diff --git a/frontend-new/src/askMeAnything/components/AMASuggestedActions/AMASuggestedActions.tsx b/frontend-new/src/askMeAnything/components/AMASuggestedActions/AMASuggestedActions.tsx new file mode 100644 index 00000000..1f5e9130 --- /dev/null +++ b/frontend-new/src/askMeAnything/components/AMASuggestedActions/AMASuggestedActions.tsx @@ -0,0 +1,46 @@ +import React, { startTransition } from "react"; +import { Box, Button, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import type { SuggestedAction } from "src/askMeAnything/types"; + +const uniqueId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +export const DATA_TEST_ID = { + AMA_SUGGESTED_ACTIONS_CONTAINER: `ama-suggested-actions-container-${uniqueId}`, + AMA_SUGGESTED_ACTION_BUTTON: `ama-suggested-action-button-${uniqueId}`, +}; + +export interface AMASuggestedActionsProps { + actions: SuggestedAction[]; +} + +const AMASuggestedActions: React.FC = ({ actions }) => { + const theme = useTheme(); + const navigate = useNavigate(); + + if (actions.length === 0) return null; + + return ( + + {actions.map((action) => ( + + ))} + + ); +}; + +export default AMASuggestedActions; diff --git a/frontend-new/src/askMeAnything/pages/AMAPage/AMAPage.tsx b/frontend-new/src/askMeAnything/pages/AMAPage/AMAPage.tsx new file mode 100644 index 00000000..cd38a1ed --- /dev/null +++ b/frontend-new/src/askMeAnything/pages/AMAPage/AMAPage.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Box, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { routerPaths } from "src/app/routerPaths"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; +import AMAChat from "src/askMeAnything/components/AMAChat/AMAChat"; + +const uniqueId = "d4e5f6a7-b8c9-0123-def0-234567890123"; + +export const DATA_TEST_ID = { + AMA_PAGE_CONTAINER: `ama-page-container-${uniqueId}`, +}; + +const AMAPage: React.FC = () => { + const theme = useTheme(); + const navigate = useNavigate(); + + return ( + + navigate(routerPaths.ROOT)} + /> + + + + + + ); +}; + +export default AMAPage; diff --git a/frontend-new/src/askMeAnything/services/AMAService.ts b/frontend-new/src/askMeAnything/services/AMAService.ts new file mode 100644 index 00000000..3be1cbda --- /dev/null +++ b/frontend-new/src/askMeAnything/services/AMAService.ts @@ -0,0 +1,54 @@ +import { getRestAPIErrorFactory } from "src/error/restAPIError/RestAPIError"; +import { StatusCodes } from "http-status-codes"; +import ErrorConstants from "src/error/restAPIError/RestAPIError.constants"; +import { customFetch } from "src/utils/customFetch/customFetch"; +import { getBackendUrl } from "src/envService"; +import type { AMAConversationInput, AMAConversationResponse } from "src/askMeAnything/types"; + +const SERVICE_NAME = "AMAService"; + +export default class AMAService { + private static instance: AMAService; + private readonly baseUrl: string; + + private constructor() { + this.baseUrl = `${getBackendUrl()}/ask-me-anything`; + } + + static getInstance(): AMAService { + if (!AMAService.instance) { + AMAService.instance = new AMAService(); + } + return AMAService.instance; + } + + async sendMessage( + userInput: string | null | undefined, + history: AMAConversationResponse["messages"] + ): Promise { + const url = `${this.baseUrl}/messages`; + const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "sendMessage", "POST", url); + const requestBody: AMAConversationInput = { user_input: userInput ?? null, history }; + const response = await customFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + expectedStatusCode: StatusCodes.CREATED, + serviceName: SERVICE_NAME, + serviceFunction: "sendMessage", + failureMessage: "Failed to send AMA message", + expectedContentType: "application/json", + }); + const responseBody = await response.text(); + try { + return JSON.parse(responseBody) as AMAConversationResponse; + } catch (e: unknown) { + throw errorFactory( + response.status, + ErrorConstants.ErrorCodes.INVALID_RESPONSE_BODY, + "Response did not contain valid JSON", + { responseBody, error: e } + ); + } + } +} diff --git a/frontend-new/src/askMeAnything/types.ts b/frontend-new/src/askMeAnything/types.ts new file mode 100644 index 00000000..93a749e9 --- /dev/null +++ b/frontend-new/src/askMeAnything/types.ts @@ -0,0 +1,23 @@ +export interface SuggestedAction { + label: string; + route: string; +} + +export type AMAMessageSender = "USER" | "AGENT"; + +export interface AMAMessage { + message_id: string; + message: string; + sent_at: string; + sender: AMAMessageSender; + suggested_actions?: SuggestedAction[]; +} + +export interface AMAConversationInput { + user_input?: string | null; + history: AMAMessage[]; +} + +export interface AMAConversationResponse { + messages: AMAMessage[]; +} diff --git a/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap b/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap index 912e537a..0978c79f 100644 --- a/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap +++ b/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap @@ -40,171 +40,195 @@ exports[`Landing Page Continue as Guest button should not show guest button when style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -250,171 +274,195 @@ exports[`Landing Page Continue as Guest button should not show guest button when style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -460,171 +508,195 @@ exports[`Landing Page Continue as Guest button should not show guest button when style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -670,171 +742,195 @@ exports[`Landing Page Continue as Guest button should show guest button when app style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- + - - - - -
-
-
-
- + + + + + - + + + + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -880,171 +976,195 @@ exports[`Landing Page Register button should not show register link when registr style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1090,171 +1210,195 @@ exports[`Landing Page Register button should not show register link when registr style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1300,171 +1444,195 @@ exports[`Landing Page Register button should show register link when registratio style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1510,171 +1678,195 @@ exports[`Landing Page Register button should show register link when registratio style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
diff --git a/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap b/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap index bc339f38..8a304ccc 100644 --- a/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap +++ b/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap @@ -105,171 +105,195 @@ exports[`Testing Login component Render tests should hide login code related ele style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + - + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -381,171 +405,195 @@ exports[`Testing Login component Render tests should hide login code related ele style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -671,171 +719,195 @@ exports[`Testing Login component Render tests should show continue as guest if l style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -969,171 +1041,195 @@ exports[`Testing Login component it should show login form successfully 1`] = ` style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1257,171 +1353,195 @@ exports[`Testing Login component should handle application login code 1`] = ` style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + - + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1543,171 +1663,195 @@ exports[`Testing Login component should remove registration link if registration style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
diff --git a/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap b/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap index 8fbf2de9..0ab75110 100644 --- a/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap +++ b/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap @@ -111,171 +111,195 @@ exports[`Testing Register component it should show register form successfully 1` style="opacity: 0; visibility: hidden;" >
-
-
-
- Registering you... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Registering you... +
+
+
+ + + + + +
+
+
+
@@ -322,171 +346,195 @@ exports[`Testing Register component should handle application registration code style="opacity: 0; visibility: hidden;" >
-
-
-
- Registering you... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - + - - - - - + - - - - - - - + - - - - - + + - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Registering you... +
+
+
+ + + + + +
+
+
+
diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.stories.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.stories.tsx new file mode 100644 index 00000000..6042e8e8 --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerExplorerAgentMessage from "src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage"; + +const meta: Meta = { + title: "CareerExplorer/CareerExplorerAgentMessage", + component: CareerExplorerAgentMessage, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + message_id: "msg-1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand.", + sent_at: new Date().toISOString(), + }, +}; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.tsx new file mode 100644 index 00000000..e6a7048a --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Box, styled } from "@mui/material"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import Timestamp from "src/chat/chatMessage/components/chatMessageFooter/components/timestamp/Timestamp"; +import ChatMessageFooterLayout from "src/chat/chatMessage/components/chatMessageFooter/ChatMessageFooterLayout"; + +const uniqueId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +export const DATA_TEST_ID = { + CAREER_EXPLORER_AGENT_MESSAGE_CONTAINER: `career-explorer-agent-message-container-${uniqueId}`, +}; + +export const CAREER_EXPLORER_AGENT_MESSAGE_TYPE = `career-explorer-agent-message-${uniqueId}`; + +export interface CareerExplorerAgentMessageProps { + message_id: string; + message: string; + sent_at: string; +} + +const MessageContainer = styled(Box)<{ origin: ConversationMessageSender }>(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + marginBottom: theme.spacing(theme.tabiyaSpacing.sm), + width: "100%", +})); + +const CareerExplorerAgentMessage: React.FC = ({ message_id, message, sent_at }) => { + return ( + + + + + + + + + + + ); +}; + +export default CareerExplorerAgentMessage; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.stories.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.stories.tsx new file mode 100644 index 00000000..26d48ee8 --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.stories.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; +import CareerExplorerChat from "src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; + +const getTimestamp = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60 * 1000).toISOString(); + +const mockMessages: CareerExplorerMessage[] = [ + { + message_id: "m1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand. Which sector interests you most?", + sent_at: getTimestamp(5), + sender: "AGENT", + }, + { + message_id: "m2", + message: "I'm interested in Agriculture.", + sent_at: getTimestamp(4), + sender: "USER", + }, + { + message_id: "m3", + message: + "Great choice. In Agriculture, commercial farming and agriprocessing offer strong opportunities for TEVET graduates in Zambia.", + sent_at: getTimestamp(3), + sender: "AGENT", + }, +]; + +const meta: Meta = { + title: "CareerExplorer/CareerExplorerChat", + component: CareerExplorerChat, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const WithConversation: Story = { + args: { + initialMessages: mockMessages, + placeholderKey: "careerExplorer.placeholder", + }, +}; + +export const WelcomeOnly: Story = { + args: { + initialMessages: [ + { + message_id: "w1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand. Which sector interests you most?", + sent_at: getTimestamp(0), + sender: "AGENT", + }, + ], + placeholderKey: "careerExplorer.placeholder", + }, +}; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.tsx new file mode 100644 index 00000000..8e3f00c6 --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, useTheme } from "@mui/material"; +import ChatList from "src/chat/chatList/ChatList"; +import ChatMessageField from "src/chat/ChatMessageField/ChatMessageField"; +import { generateSomethingWentWrongMessage, generateUserMessage } from "src/chat/util"; +import type { IChatMessage } from "src/chat/Chat.types"; +import type { TranslationKey } from "src/react-i18next"; +import CareerExplorerService from "src/careerExplorer/services/CareerExplorerService"; +import { generateCareerExplorerTypingMessage } from "src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage"; +import { mapCareerExplorerMessagesToChatMessages } from "src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; + +export interface CareerExplorerChatProps { + initialMessages: CareerExplorerMessage[]; + placeholderKey: TranslationKey; + isLoading?: boolean; +} + +const CareerExplorerChat: React.FC = ({ + initialMessages, + placeholderKey, + isLoading = false, +}) => { + const theme = useTheme(); + const [messages, setMessages] = useState[]>(() => + mapCareerExplorerMessagesToChatMessages(initialMessages) + ); + const [aiIsTyping, setAiIsTyping] = useState(false); + const [chatFinished, setChatFinished] = useState(false); + + const typingMessage = useMemo(() => generateCareerExplorerTypingMessage(), []); + + const displayMessages = useMemo(() => { + if (aiIsTyping || (isLoading && messages.length === 0)) { + return [...messages, typingMessage]; + } + return messages; + }, [messages, aiIsTyping, typingMessage, isLoading]); + + useEffect(() => { + setMessages(mapCareerExplorerMessagesToChatMessages(initialMessages)); + }, [initialMessages]); + + const handleSend = useCallback(async (userMessage: string) => { + const optimisticUserMessage = generateUserMessage( + userMessage, + new Date().toISOString(), + `optimistic-${Date.now()}` + ); + setMessages((prev) => [...prev, optimisticUserMessage]); + setAiIsTyping(true); + try { + const res = await CareerExplorerService.getInstance().sendMessage(userMessage); + setMessages(mapCareerExplorerMessagesToChatMessages(res.messages)); + setChatFinished(res.finished); + } catch (e) { + console.error("Failed to send message", e); + setMessages((prev) => [...prev, generateSomethingWentWrongMessage()]); + } finally { + setAiIsTyping(false); + } + }, []); + + return ( + + + + + + + + + ); +}; + +export default CareerExplorerChat; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage.tsx new file mode 100644 index 00000000..a9571bc7 --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Box, keyframes, Typography } from "@mui/material"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import { MessageContainer } from "src/chat/chatMessage/userChatMessage/UserChatMessage"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { nanoid } from "nanoid"; +import type { IChatMessage } from "src/chat/Chat.types"; + +const TYPING_KEY = "chat.chatMessage.typingChatMessage.typing"; + +const uniqueId = "b2c3d4e5-f6a7-8901-bcde-f23456789012"; + +export const CAREER_EXPLORER_TYPING_MESSAGE_TYPE = `career-explorer-typing-${uniqueId}`; + +const dotAnimation = keyframes` + 0%, 100% { + transform: translateY(+0.5px); + opacity: 0.5; + } + 50% { + transform: translateY(-1px); + opacity: 1; + } +`; + +export interface CareerExplorerTypingMessageProps { + _?: never; +} + +const CareerExplorerTypingMessage: React.FC = () => { + const { t } = useTranslation(); + const displayText = t(TYPING_KEY); + + return ( + + + + {displayText} + + {[0, 1, 2].map((i) => ( + + . + + ))} + + + + + ); +}; + +export function generateCareerExplorerTypingMessage(): IChatMessage { + return { + type: CAREER_EXPLORER_TYPING_MESSAGE_TYPE, + message_id: nanoid(), + sender: ConversationMessageSender.COMPASS, + payload: {}, + component: (props: CareerExplorerTypingMessageProps) => , + }; +} + +export default CareerExplorerTypingMessage; diff --git a/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.stories.tsx b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.stories.tsx new file mode 100644 index 00000000..b1acae3d --- /dev/null +++ b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.stories.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; +import CareerExplorerPage from "src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage"; +import CareerExplorerService from "src/careerExplorer/services/CareerExplorerService"; +import authenticationStateService from "src/auth/services/AuthenticationState.service"; + +const getTimestamp = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60 * 1000).toISOString(); + +const meta: Meta = { + title: "CareerExplorer/CareerExplorerPage", + component: CareerExplorerPage, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const mockResponse = { + messages: [ + { + message_id: "m1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand. Which sector interests you most?", + sent_at: getTimestamp(0), + sender: "AGENT" as const, + }, + ], + finished: false, +}; + +export const Default: Story = { + decorators: [ + (Story) => { + authenticationStateService.getInstance().getUser = () => ({ + id: "user-123", + name: "foo", + email: "foo@bar.baz", + }); + const service = CareerExplorerService.getInstance() as ReturnType & { + getOrCreateConversation: typeof CareerExplorerService.prototype.getOrCreateConversation; + sendMessage: typeof CareerExplorerService.prototype.sendMessage; + }; + (service as any).getOrCreateConversation = async () => mockResponse; + (service as any).sendMessage = async (userInput: string) => ({ + ...mockResponse, + messages: [ + ...mockResponse.messages, + { + message_id: "u1", + message: userInput, + sent_at: getTimestamp(0), + sender: "USER" as const, + }, + { + message_id: "a1", + message: "Thanks for your interest. Let me share more about that sector.", + sent_at: getTimestamp(0), + sender: "AGENT" as const, + }, + ], + }); + return ; + }, + ], +}; diff --git a/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.tsx b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.tsx new file mode 100644 index 00000000..4cccb1e6 --- /dev/null +++ b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.tsx @@ -0,0 +1,81 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Box, Typography, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; +import { routerPaths } from "src/app/routerPaths"; +import CareerExplorerChat from "src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat"; +import CareerExplorerService from "src/careerExplorer/services/CareerExplorerService"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; + +const uniqueId = "career-explorer-page-001"; +export const DATA_TEST_ID = { + CONTAINER: `career-explorer-container-${uniqueId}`, + MESSAGE_LIST: `career-explorer-messages-${uniqueId}`, +}; + +const CareerExplorerPage: React.FC = () => { + const theme = useTheme(); + const navigate = useNavigate(); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadOrCreateConversation = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await CareerExplorerService.getInstance().getOrCreateConversation(); + setMessages(res.messages); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadOrCreateConversation(); + }, [loadOrCreateConversation]); + + return ( + + navigate(routerPaths.ROOT)} + /> + {error && ( + + + {error} + + + )} + + + + + ); +}; + +export default CareerExplorerPage; diff --git a/frontend-new/src/careerExplorer/services/CareerExplorerService.ts b/frontend-new/src/careerExplorer/services/CareerExplorerService.ts new file mode 100644 index 00000000..0b856377 --- /dev/null +++ b/frontend-new/src/careerExplorer/services/CareerExplorerService.ts @@ -0,0 +1,95 @@ +import { StatusCodes } from "http-status-codes"; +import { customFetch } from "src/utils/customFetch/customFetch"; +import { getBackendUrl } from "src/envService"; +import { getRestAPIErrorFactory } from "src/error/restAPIError/RestAPIError"; +import ErrorConstants from "src/error/restAPIError/RestAPIError.constants"; +import type { CareerExplorerConversationResponse, CareerExplorerConversationInput } from "src/careerExplorer/types"; + +const SERVICE_NAME = "CareerExplorerService"; + +export default class CareerExplorerService { + private static instance: CareerExplorerService; + private readonly baseUrl: string; + + private constructor() { + this.baseUrl = `${getBackendUrl()}/career-explorer`; + } + + static getInstance(): CareerExplorerService { + if (!CareerExplorerService.instance) { + CareerExplorerService.instance = new CareerExplorerService(); + } + return CareerExplorerService.instance; + } + + async getOrCreateConversation(): Promise { + const url = `${this.baseUrl}/conversation`; + const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "getOrCreateConversation", "POST", url); + const response = await customFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + expectedStatusCode: StatusCodes.CREATED, + serviceName: SERVICE_NAME, + serviceFunction: "getOrCreateConversation", + failureMessage: "Failed to start career explorer conversation", + expectedContentType: "application/json", + }); + const body = await response.text(); + try { + return JSON.parse(body) as CareerExplorerConversationResponse; + } catch (e) { + throw errorFactory(response.status, ErrorConstants.ErrorCodes.INVALID_RESPONSE_BODY, "Invalid JSON", { + responseBody: body, + error: e, + }); + } + } + + async sendMessage(userInput: string): Promise { + const url = `${this.baseUrl}/conversation/messages`; + const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "sendMessage", "POST", url); + const body: CareerExplorerConversationInput = { user_input: userInput }; + const response = await customFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + expectedStatusCode: StatusCodes.CREATED, + serviceName: SERVICE_NAME, + serviceFunction: "sendMessage", + failureMessage: "Failed to send message", + expectedContentType: "application/json", + }); + const responseBody = await response.text(); + try { + return JSON.parse(responseBody) as CareerExplorerConversationResponse; + } catch (e) { + throw errorFactory(response.status, ErrorConstants.ErrorCodes.INVALID_RESPONSE_BODY, "Invalid JSON", { + responseBody, + error: e, + }); + } + } + + async getConversation(): Promise { + const url = `${this.baseUrl}/conversation`; + const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "getConversation", "GET", url); + const response = await customFetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + expectedStatusCode: StatusCodes.OK, + serviceName: SERVICE_NAME, + serviceFunction: "getConversation", + failureMessage: "Failed to get conversation", + expectedContentType: "application/json", + }); + const body = await response.text(); + try { + return JSON.parse(body) as CareerExplorerConversationResponse; + } catch (e) { + throw errorFactory(response.status, ErrorConstants.ErrorCodes.INVALID_RESPONSE_BODY, "Invalid JSON", { + responseBody: body, + error: e, + }); + } + } +} diff --git a/frontend-new/src/careerExplorer/types.ts b/frontend-new/src/careerExplorer/types.ts new file mode 100644 index 00000000..08d61f17 --- /dev/null +++ b/frontend-new/src/careerExplorer/types.ts @@ -0,0 +1,17 @@ +export type CareerExplorerMessageSender = "USER" | "AGENT"; + +export interface CareerExplorerMessage { + message_id: string; + message: string; + sent_at: string; + sender: CareerExplorerMessageSender; +} + +export interface CareerExplorerConversationResponse { + messages: CareerExplorerMessage[]; + finished: boolean; +} + +export interface CareerExplorerConversationInput { + user_input: string; +} diff --git a/frontend-new/src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages.tsx b/frontend-new/src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages.tsx new file mode 100644 index 00000000..df3f789f --- /dev/null +++ b/frontend-new/src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import type { IChatMessage } from "src/chat/Chat.types"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { generateUserMessage } from "src/chat/util"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; +import CareerExplorerAgentMessage, { + CAREER_EXPLORER_AGENT_MESSAGE_TYPE, + type CareerExplorerAgentMessageProps, +} from "src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage"; + +export const mapCareerExplorerMessageToChatMessage = ( + msg: CareerExplorerMessage +): IChatMessage | ReturnType => { + const sentAt = msg.sent_at; + if (msg.sender === "USER") { + return generateUserMessage(msg.message, sentAt, msg.message_id); + } + const payload: CareerExplorerAgentMessageProps = { + message_id: msg.message_id, + message: msg.message, + sent_at: sentAt, + }; + return { + type: CAREER_EXPLORER_AGENT_MESSAGE_TYPE, + message_id: msg.message_id, + sender: ConversationMessageSender.COMPASS, + payload, + component: (p: CareerExplorerAgentMessageProps) => , + }; +}; + +export const mapCareerExplorerMessagesToChatMessages = (messages: CareerExplorerMessage[]): IChatMessage[] => { + return messages.map(mapCareerExplorerMessageToChatMessage); +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.stories.tsx new file mode 100644 index 00000000..c89445ec --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessAgentMessage from "src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessAgentMessage", + component: CareerReadinessAgentMessage, + tags: ["autodocs"], + argTypes: { + message: { control: "text" }, + sent_at: { control: "text" }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const now = new Date().toISOString(); + +export const Default: Story = { + args: { + message_id: "msg-1", + message: + "This module helps you understand what professional identity means and how to articulate your skills to employers.", + sent_at: now, + }, +}; + +export const ShortMessage: Story = { + args: { + message_id: "msg-2", + message: "Yes, that's a good place to start.", + sent_at: now, + }, +}; + +export const LongMessage: Story = { + args: { + message_id: "msg-3", + message: `Your professional identity is how you present yourself in a work context. It includes your skills, values, and how you communicate your value to employers. + +There are three main types of skills to consider: +- **Technical skills** – job-specific abilities +- **Transferable skills** – useful across roles +- **Personal attributes** – how you work and communicate + +Think about examples from your experience for each category.`, + sent_at: now, + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.tsx new file mode 100644 index 00000000..64837039 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Box, styled } from "@mui/material"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; + +const uniqueId = "e46487bc-8ba0-4e0d-960a-b76897fb5aa9"; + +export const DATA_TEST_ID = { + CAREER_READINESS_AGENT_MESSAGE_CONTAINER: `career-readiness-agent-message-container-${uniqueId}`, +}; + +export const CAREER_READINESS_AGENT_MESSAGE_TYPE = `career-readiness-agent-message-${uniqueId}`; + +export interface CareerReadinessAgentMessageProps { + message_id: string; + message: string; + sent_at: string; +} + +const MessageContainer = styled(Box)<{ origin: ConversationMessageSender }>(({ theme, origin }) => ({ + display: "flex", + flexDirection: "column", + alignItems: origin === ConversationMessageSender.USER ? "flex-end" : "flex-start", + marginBottom: theme.spacing(theme.tabiyaSpacing.sm), + width: "100%", +})); + +const CareerReadinessAgentMessage: React.FC = ({ message_id, message }) => { + return ( + + + + + + + + ); +}; + +export default CareerReadinessAgentMessage; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.stories.tsx new file mode 100644 index 00000000..58177b69 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.stories.tsx @@ -0,0 +1,267 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; +import CareerReadinessChat from "src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import type { CareerReadinessConversationResponse } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessChat", + component: CareerReadinessChat, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( + + + + ), + (Story, context) => { + const storyName = context.name ?? ""; + const mockService = CareerReadinessService.getInstance(); + + const getTimestamp = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60 * 1000).toISOString(); + + const mockHistory: CareerReadinessConversationResponse = { + conversation_id: "conv-1", + module_id: "professional-identity", + module_completed: false, + messages: [ + { + message_id: "m1", + message: + "Welcome! In this module we'll explore your professional identity and skills. How would you describe yourself in a work context?", + sent_at: getTimestamp(5), + sender: "AGENT", + }, + { + message_id: "m2", + message: "I'm not sure where to start.", + sent_at: getTimestamp(4), + sender: "USER", + }, + { + message_id: "m3", + message: + "That's okay. Think about your past roles, volunteer work, or studies. What skills did you use? Start with one example.", + sent_at: getTimestamp(3), + sender: "AGENT", + }, + ], + }; + + (mockService as any).createConversation = async (moduleId: string) => ({ + conversation_id: "conv-new", + module_id: moduleId, + module_completed: false, + messages: [ + { + message_id: "w1", + message: "Let's get started. What would you like to work on first?", + sent_at: getTimestamp(0), + sender: "AGENT", + }, + ], + }); + + (mockService as any).getConversationHistory = async (_moduleId: string, _conversationId: string) => { + if (storyName.includes("Loading")) { + await new Promise((r) => setTimeout(r, 2000)); + } + return mockHistory; + }; + + (mockService as any).sendMessage = async (_moduleId: string, _conversationId: string, userInput: string) => ({ + ...mockHistory, + messages: [ + ...mockHistory.messages, + { + message_id: "u-new", + message: userInput, + sent_at: getTimestamp(0), + sender: "USER", + }, + { + message_id: "a-new", + message: "Thanks for sharing. Can you tell me more about how you used that skill?", + sent_at: getTimestamp(0), + sender: "AGENT", + }, + ], + }); + + return ; + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +const defaultArgs = { + moduleId: "professional-identity", + moduleTitle: "Professional Identity & Skills Mapping", + inputPlaceholder: "Ask about professional identity and skills…", +}; + +export const WithConversation: Story = { + args: { + ...defaultArgs, + initialConversationId: "conv-1", + }, +}; + +export const LoadingHistory: Story = { + args: { + ...defaultArgs, + initialConversationId: "conv-1", + }, +}; + +const completedHistory: CareerReadinessConversationResponse = { + conversation_id: "conv-completed", + module_id: "professional-identity", + module_completed: true, + quiz_passed: true, + conversation_mode: "SUPPORT", + covered_topics: ["Professional identity", "Transferable skills"], + messages: [ + { + message_id: "m1", + message: + "Welcome! In this module we'll explore your professional identity and skills. How would you describe yourself in a work context?", + sent_at: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + sender: "AGENT", + }, + { + message_id: "m2", + message: "I have experience in teamwork and problem-solving.", + sent_at: new Date(Date.now() - 8 * 60 * 1000).toISOString(), + sender: "USER", + }, + { + message_id: "m3", + message: + "Well done! You've passed the quiz for this module. The next module is now unlocked. You can continue to ask me follow-up questions here.", + sent_at: new Date(Date.now() - 1 * 60 * 1000).toISOString(), + sender: "AGENT", + }, + ], +}; + +/** Simulates backend having just delivered the quiz (quiz_available true); chat will fetch quiz and show quiz UI. */ +const historyWithQuizAvailable: CareerReadinessConversationResponse = { + conversation_id: "conv-quiz-delivered", + module_id: "professional-identity", + module_completed: false, + quiz_available: true, + messages: [ + { + message_id: "m1", + message: + "Welcome! In this module we'll explore your professional identity and skills. How would you describe yourself in a work context?", + sent_at: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + sender: "AGENT", + }, + { + message_id: "m2", + message: "I have experience in teamwork and problem-solving.", + sent_at: new Date(Date.now() - 8 * 60 * 1000).toISOString(), + sender: "USER", + }, + { + message_id: "m3", + message: "Great work! You've covered the key topics. It's time for a short quiz to check your understanding.", + sent_at: new Date(Date.now() - 1 * 60 * 1000).toISOString(), + sender: "AGENT", + }, + ], +}; + +const mockQuizQuestions = [ + { + question: "What best describes your professional identity?", + options: [ + "A. Your job title alone", + "B. Your unique combination of values, skills, experiences, and aspirations", + "C. The company you work for", + "D. Your salary and benefits", + ], + }, + { + question: "Which of the following is a technical skill?", + options: ["A. Teamwork", "B. Leadership", "C. Data analysis", "D. Time management"], + }, +]; + +export const QuizDelivered: Story = { + args: { + ...defaultArgs, + initialConversationId: "conv-quiz-delivered", + }, + decorators: [ + (Story) => { + const mockService = CareerReadinessService.getInstance() as any; + mockService.getConversationHistory = async () => historyWithQuizAvailable; + mockService.getQuiz = async () => ({ questions: mockQuizQuestions }); + mockService.sendMessage = async () => historyWithQuizAvailable; + mockService.submitQuiz = async () => ({ + score: 2, + total: 2, + passed: true, + question_results: [ + { question_index: 1, is_correct: true }, + { question_index: 2, is_correct: true }, + ], + module_completed: true, + conversation_mode: "SUPPORT", + }); + return ; + }, + ], +}; + +export const ModuleCompleted: Story = { + args: { + ...defaultArgs, + initialConversationId: "conv-completed", + }, + decorators: [ + (Story) => { + const mockService = CareerReadinessService.getInstance() as any; + mockService.getConversationHistory = async () => completedHistory; + mockService.sendMessage = async () => completedHistory; + return ; + }, + ], +}; + +/** Quiz is shown; submit returns failed so user sees "Your answers" + feedback and quiz with result card + Retry. */ +export const QuizFailedThenRetry: Story = { + args: { + ...defaultArgs, + initialConversationId: "conv-quiz-delivered", + }, + decorators: [ + (Story) => { + const mockService = CareerReadinessService.getInstance() as any; + mockService.getConversationHistory = async () => historyWithQuizAvailable; + mockService.getQuiz = async () => ({ questions: mockQuizQuestions }); + mockService.sendMessage = async () => historyWithQuizAvailable; + mockService.submitQuiz = async () => ({ + score: 0, + total: 2, + passed: false, + question_results: [ + { question_index: 1, is_correct: false }, + { question_index: 2, is_correct: false }, + ], + module_completed: false, + }); + return ; + }, + ], +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.tsx new file mode 100644 index 00000000..7346afa7 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.tsx @@ -0,0 +1,468 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Box, useTheme } from "@mui/material"; +import { useSnackbar } from "notistack"; +import { useTranslation } from "react-i18next"; +import ChatList from "src/chat/chatList/ChatList"; +import ChatMessageField from "src/chat/ChatMessageField/ChatMessageField"; +import { generateSomethingWentWrongMessage } from "src/chat/util"; +import type { IChatMessage } from "src/chat/Chat.types"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import { generateCareerReadinessTypingMessage } from "src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage"; +import { + getLatestQuizHistorySummary, + mapCareerReadinessMessagesToChatMessages, +} from "src/careerReadiness/utils/mapCareerReadinessMessagesToChatMessages"; +import CareerReadinessAgentMessage, { + CAREER_READINESS_AGENT_MESSAGE_TYPE, + type CareerReadinessAgentMessageProps, +} from "src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage"; +import CareerReadinessQuizChatMessage, { + CAREER_READINESS_QUIZ_CHAT_MESSAGE_TYPE, + type CareerReadinessQuizChatMessageProps, + type QuizSubmissionResult, +} from "src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage"; +import CareerReadinessUserMessage, { + CAREER_READINESS_USER_MESSAGE_TYPE, + type CareerReadinessUserMessageProps, +} from "src/careerReadiness/components/CareerReadinessUserMessage/CareerReadinessUserMessage"; +import type { QuizSubmissionResponse } from "src/careerReadiness/types"; + +export interface CareerReadinessChatProps { + moduleId: string; + moduleTitle: string; + initialConversationId: string | null; + inputPlaceholder: string; + onModuleCompleted?: () => void; +} + +const CareerReadinessChat: React.FC = ({ + moduleId, + moduleTitle, + initialConversationId, + inputPlaceholder, + onModuleCompleted, +}) => { + const theme = useTheme(); + const { enqueueSnackbar } = useSnackbar(); + const { t } = useTranslation(); + const [messages, setMessages] = useState[]>([]); + const [conversationId, setConversationId] = useState(initialConversationId); + const [isLoadingHistory, setIsLoadingHistory] = useState(Boolean(initialConversationId)); + const [aiIsTyping, setAiIsTyping] = useState(false); + const [isChatLockedForQuiz, setIsChatLockedForQuiz] = useState(false); + + const typingMessage = useMemo(() => generateCareerReadinessTypingMessage(), []); + + const displayMessages = useMemo(() => { + if (aiIsTyping) { + return [...messages, typingMessage]; + } + return messages; + }, [messages, aiIsTyping, typingMessage]); + + const showTyping = isLoadingHistory || !conversationId ? [typingMessage] : displayMessages; + + const buildCorrectAnswersSummary = useCallback( + (answers: Record, questionResults: { question_index: number; is_correct: boolean }[]) => { + const correctAnswers = questionResults + .filter((result) => result.is_correct) + .map((result) => `${result.question_index}.${answers[result.question_index]}`) + .filter(Boolean); + + return correctAnswers.length > 0 + ? t("careerReadiness.quizCorrectAnswersSummary", { answers: correctAnswers.join(", ") }) + : undefined; + }, + [t] + ); + + const buildResultAgentMessage = useCallback( + ( + targetConversationId: string, + message: string, + options?: { messageId?: string; sentAt?: string } + ): IChatMessage => { + const messageId = options?.messageId ?? `quiz-result-${targetConversationId}-${Date.now()}`; + const sentAt = options?.sentAt ?? new Date().toISOString(); + return { + type: CAREER_READINESS_AGENT_MESSAGE_TYPE, + message_id: messageId, + sender: ConversationMessageSender.COMPASS, + payload: { + message_id: messageId, + message, + sent_at: sentAt, + }, + component: (p: CareerReadinessAgentMessageProps) => , + }; + }, + [] + ); + + const buildPassedQuizMessage = useCallback( + (score: number, total: number): string => { + return t("careerReadiness.quizPassedAgentMessage", { score, total }); + }, + [t] + ); + + const buildUserMessage = useCallback( + (message: string, messageId?: string): IChatMessage => { + return { + type: CAREER_READINESS_USER_MESSAGE_TYPE, + message_id: messageId ?? `career-readiness-user-${Date.now()}`, + sender: ConversationMessageSender.USER, + payload: { + message, + }, + component: (p: CareerReadinessUserMessageProps) => , + }; + }, + [] + ); + + const serializeAnswers = useCallback((answers: Record): Record => { + const serialized: Record = {}; + Object.entries(answers).forEach(([k, v]) => { + serialized[String(k)] = v; + }); + return serialized; + }, []); + + const persistQuizQuestions = useCallback( + (targetConversationId: string, questions: CareerReadinessQuizChatMessageProps["questions"]) => { + const existing = PersistentStorageService.getCareerReadinessQuizData(moduleId, targetConversationId) ?? {}; + PersistentStorageService.setCareerReadinessQuizData(moduleId, targetConversationId, { + ...existing, + questions, + }); + }, + [moduleId] + ); + + const buildQuizSubmissionResult = useCallback( + (answers: Record, submission: QuizSubmissionResponse): QuizSubmissionResult => { + return { + score: submission.score, + total: submission.total, + passed: submission.passed, + submittedAt: Date.now(), + correctAnswersSummary: buildCorrectAnswersSummary(answers, submission.question_results), + }; + }, + [buildCorrectAnswersSummary] + ); + + const persistQuizResult = useCallback( + (targetConversationId: string, submissionResult: QuizSubmissionResult) => { + PersistentStorageService.setCareerReadinessQuizResult(moduleId, targetConversationId, { + score: submissionResult.score, + total: submissionResult.total, + passed: submissionResult.passed, + submitted_at: submissionResult.submittedAt, + correct_answers_summary: submissionResult.correctAnswersSummary, + }); + }, + [moduleId] + ); + + const appendOrReplaceQuizMessage = useCallback( + ( + prev: IChatMessage[], + targetConversationId: string, + questions: CareerReadinessQuizChatMessageProps["questions"], + onSubmit: CareerReadinessQuizChatMessageProps["onSubmit"], + extraPayload: Partial = {} + ) => { + const quizMessage: IChatMessage = { + type: CAREER_READINESS_QUIZ_CHAT_MESSAGE_TYPE, + message_id: `quiz-${targetConversationId}`, + sender: ConversationMessageSender.COMPASS, + payload: { + message_id: `quiz-${targetConversationId}`, + questions, + onSubmit, + moduleId, + conversationId: targetConversationId, + ...extraPayload, + }, + component: (p: CareerReadinessQuizChatMessageProps) => , + }; + + const quizIndex = prev.findIndex((m) => m.type === CAREER_READINESS_QUIZ_CHAT_MESSAGE_TYPE); + if (quizIndex === -1) { + return [...prev, quizMessage]; + } + return [...prev.slice(0, quizIndex), quizMessage, ...prev.slice(quizIndex + 1)]; + }, + [moduleId] + ); + + const createQuizSubmitHandler = useCallback( + ( + targetConversationId: string, + questions: CareerReadinessQuizChatMessageProps["questions"] + ): CareerReadinessQuizChatMessageProps["onSubmit"] => { + const handleQuizSubmit: CareerReadinessQuizChatMessageProps["onSubmit"] = async (answers) => { + const submission = await CareerReadinessService.getInstance().submitQuiz( + moduleId, + targetConversationId, + serializeAnswers(answers) + ); + const submissionResult = buildQuizSubmissionResult(answers, submission); + + if (submission.passed) { + enqueueSnackbar(t("careerReadiness.quizPassedNotification"), { variant: "success" }); + setIsChatLockedForQuiz(false); + } else { + enqueueSnackbar(t("careerReadiness.quizFailedNotification"), { variant: "error" }); + setIsChatLockedForQuiz(true); + } + + setMessages((prev) => { + const updatedMessages = appendOrReplaceQuizMessage(prev, targetConversationId, questions, handleQuizSubmit, { + submissionResult, + lastAnswers: answers, + }); + + return submission.passed + ? [ + ...updatedMessages, + buildResultAgentMessage( + targetConversationId, + buildPassedQuizMessage(submission.score, submission.total) + ), + ] + : updatedMessages; + }); + + persistQuizResult(targetConversationId, submissionResult); + + if (submission.passed && submission.module_completed) { + onModuleCompleted?.(); + } + }; + + return handleQuizSubmit; + }, + [ + moduleId, + serializeAnswers, + buildQuizSubmissionResult, + enqueueSnackbar, + t, + appendOrReplaceQuizMessage, + buildResultAgentMessage, + buildPassedQuizMessage, + persistQuizResult, + onModuleCompleted, + ] + ); + + const loadHistory = useCallback( + async (conversationIdOverride?: string | null, getIsCancelled?: () => boolean) => { + const id = conversationIdOverride ?? conversationId; + if (!id) return; + setIsLoadingHistory(true); + try { + const res = await CareerReadinessService.getInstance().getConversationHistory(moduleId, id); + if (getIsCancelled?.()) return; + let chatMessages = mapCareerReadinessMessagesToChatMessages(res.messages); + const latestQuizSummary = getLatestQuizHistorySummary(res.messages); + + const storedQuizData = PersistentStorageService.getCareerReadinessQuizData(moduleId, id); + const storedQuestions = storedQuizData?.questions; + const storedResult = PersistentStorageService.getCareerReadinessQuizResult(moduleId, id); + const shouldShowQuiz = res.quiz_available || (Boolean(latestQuizSummary) && Boolean(storedQuestions?.length)); + const hasPassedQuiz = latestQuizSummary?.passed ?? storedResult?.passed ?? false; + setIsChatLockedForQuiz(shouldShowQuiz && !hasPassedQuiz); + + if (shouldShowQuiz) { + try { + const questions = res.quiz_available + ? (await CareerReadinessService.getInstance().getQuiz(moduleId, id)).questions + : storedQuestions ?? []; + if (res.quiz_available) { + persistQuizQuestions(id, questions); + } + if (getIsCancelled?.()) return; + const handleQuizSubmit = createQuizSubmitHandler(id, questions); + + chatMessages = appendOrReplaceQuizMessage(chatMessages, id, questions, handleQuizSubmit, { + submissionResult: + latestQuizSummary || storedResult + ? { + score: latestQuizSummary?.score ?? storedResult?.score ?? 0, + total: latestQuizSummary?.total ?? storedResult?.total ?? 0, + passed: latestQuizSummary?.passed ?? storedResult?.passed ?? false, + submittedAt: storedResult?.submitted_at ?? Date.now(), + correctAnswersSummary: storedResult?.correct_answers_summary, + } + : undefined, + lastAnswers: latestQuizSummary?.answers ?? storedQuizData?.answers ?? undefined, + }); + + if (latestQuizSummary?.passed) { + chatMessages = [ + ...chatMessages, + buildResultAgentMessage( + id, + latestQuizSummary.feedbackMessage ?? + buildPassedQuizMessage( + latestQuizSummary.score ?? storedResult?.score ?? 0, + latestQuizSummary.total ?? storedResult?.total ?? 0 + ), + { + messageId: latestQuizSummary.feedbackMessageId, + sentAt: latestQuizSummary.feedbackSentAt, + } + ), + ]; + } + } catch (quizErr) { + console.error("Failed to load quiz", quizErr); + } + } + + setMessages(chatMessages); + const completed = res.module_completed; + if (completed) onModuleCompleted?.(); + } catch (e) { + console.error("Failed to load conversation history", e); + if (getIsCancelled?.()) return; + setMessages([generateSomethingWentWrongMessage()]); + } finally { + if (!getIsCancelled?.()) { + setIsLoadingHistory(false); + } + } + }, + [ + moduleId, + conversationId, + onModuleCompleted, + appendOrReplaceQuizMessage, + buildPassedQuizMessage, + buildResultAgentMessage, + createQuizSubmitHandler, + persistQuizQuestions, + ] + ); + + const loadHistoryRef = useRef(loadHistory); + loadHistoryRef.current = loadHistory; + + useEffect(() => { + if (initialConversationId) { + setConversationId(initialConversationId); + let cancelled = false; + loadHistoryRef + .current(initialConversationId, () => cancelled) + .catch(() => { + // Error already logged and state handled in loadHistory + }); + return () => { + cancelled = true; + }; + } + setConversationId(null); + setMessages([]); + setIsLoadingHistory(false); + setIsChatLockedForQuiz(false); + }, [initialConversationId]); + + const handleSend = useCallback( + async (userMessage: string) => { + if (!conversationId) return; + if (isChatLockedForQuiz) return; + const optimisticUserMessage = buildUserMessage(userMessage, `optimistic-${Date.now()}`); + setMessages((prev) => [...prev, optimisticUserMessage]); + setAiIsTyping(true); + try { + const res = await CareerReadinessService.getInstance().sendMessage(moduleId, conversationId, userMessage); + let chatMessages = mapCareerReadinessMessagesToChatMessages(res.messages); + + if (res.quiz_available) { + try { + const quizRes = await CareerReadinessService.getInstance().getQuiz(moduleId, conversationId); + persistQuizQuestions(conversationId, quizRes.questions); + const handleQuizSubmit = createQuizSubmitHandler(conversationId, quizRes.questions); + + chatMessages = appendOrReplaceQuizMessage( + chatMessages, + conversationId, + quizRes.questions, + handleQuizSubmit + ); + } catch (quizErr) { + console.error("Failed to load quiz", quizErr); + } + } + + setMessages(chatMessages); + const completed = res.module_completed; + if (completed) onModuleCompleted?.(); + } catch (e) { + console.error("Failed to send message", e); + setMessages((prev) => [...prev, generateSomethingWentWrongMessage()]); + } finally { + setAiIsTyping(false); + } + }, + [ + moduleId, + conversationId, + isChatLockedForQuiz, + onModuleCompleted, + appendOrReplaceQuizMessage, + buildUserMessage, + persistQuizQuestions, + createQuizSubmitHandler, + ] + ); + + return ( + + + + + + + + + ); +}; + +export default CareerReadinessChat; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.stories.tsx new file mode 100644 index 00000000..ce55133c --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessModuleCard from "src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard"; +import type { ModuleSummary } from "src/careerReadiness/types"; +import type { ModuleStatusDisplay } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessModuleCard", + component: CareerReadinessModuleCard, + tags: ["autodocs"], + argTypes: { + status: { + control: "select", + options: ["locked", "unlocked", "in_progress", "done"], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const mockModule: ModuleSummary = { + id: "cv-development", + title: "CV Development", + description: + "Learn to build and tailor a professional CV that highlights your skills and experience for different employers.", + icon: "cv", + status: "UNLOCKED", + sort_order: 1, + input_placeholder: "Ask about CVs...", +}; + +export const Locked: Story = { + args: { + module: mockModule, + status: "locked" as ModuleStatusDisplay, + }, +}; + +export const Unlocked: Story = { + args: { + module: mockModule, + status: "unlocked" as ModuleStatusDisplay, + }, +}; + +export const InProgress: Story = { + args: { + module: { ...mockModule, status: "IN_PROGRESS" }, + status: "in_progress" as ModuleStatusDisplay, + }, +}; + +export const Done: Story = { + args: { + module: { ...mockModule, status: "COMPLETED" }, + status: "done" as ModuleStatusDisplay, + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.tsx new file mode 100644 index 00000000..3c51bbc6 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.tsx @@ -0,0 +1,257 @@ +import React, { startTransition } from "react"; +import { Box, Card, CardActionArea, Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { ModuleStatusDisplay } from "src/careerReadiness/types"; +import type { ModuleSummary } from "src/careerReadiness/types"; +import { routerPaths } from "src/app/routerPaths"; +import { TranslationKey } from "src/react-i18next"; +import FingerprintOutlined from "@mui/icons-material/FingerprintOutlined"; +import DescriptionOutlined from "@mui/icons-material/DescriptionOutlined"; +import MailOutlineOutlined from "@mui/icons-material/MailOutlineOutlined"; +import MicOutlined from "@mui/icons-material/MicOutlined"; +import PeopleOutlined from "@mui/icons-material/PeopleOutlined"; +import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined"; +import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; +import AutorenewIcon from "@mui/icons-material/Autorenew"; +import LockOutlined from "@mui/icons-material/LockOutlined"; + +const uniqueId = "a7b8c9d0-e1f2-3a4b-5c6d-7e8f9a0b1c2d"; + +export const DATA_TEST_ID = { + MODULE_CARD: `career-readiness-module-card-${uniqueId}`, + MODULE_CARD_TITLE: `career-readiness-module-card-title-${uniqueId}`, + MODULE_CARD_DESCRIPTION: `career-readiness-module-card-description-${uniqueId}`, + MODULE_CARD_ICON: `career-readiness-module-card-icon-${uniqueId}`, + MODULE_CARD_STATUS: `career-readiness-module-card-status-${uniqueId}`, +}; + +const MODULE_ICONS: Record = { + identity: , + cv: , + letter: , + interview: , + workplace: , +}; + +export const getModuleIcon = (iconId: string): React.ReactNode => { + return MODULE_ICONS[iconId] ?? ; +}; + +const STATUS_ICONS = { + done: , + in_progress: , + locked: , +}; + +const getStatusLabel = (status: ModuleStatusDisplay, t: (key: TranslationKey) => string): string => { + if (status === "done") return t("careerReadiness.statusDone"); + if (status === "in_progress") return t("careerReadiness.statusInProgress"); + if (status === "locked") return t("careerReadiness.statusLocked"); + return t("careerReadiness.statusUnlocked"); +}; + +const getStatusIcon = (status: ModuleStatusDisplay): React.ReactNode => { + if (status === "done") return STATUS_ICONS.done; + if (status === "in_progress") return STATUS_ICONS.in_progress; + if (status === "locked") return STATUS_ICONS.locked; + return null; +}; + +export interface CareerReadinessModuleCardProps { + module: ModuleSummary; + status: ModuleStatusDisplay; +} + +const useStatusStyles = (status: ModuleStatusDisplay) => { + const theme = useTheme(); + + if (status === "done") { + return { + borderColor: theme.palette.secondary.main, + iconBg: `color-mix(in srgb, ${theme.palette.secondary.main} 12%, transparent)`, + iconColor: theme.palette.secondary.main, + statusColor: theme.palette.secondary.main, + hoverBorderColor: theme.palette.secondary.main, + hoverBg: `color-mix(in srgb, ${theme.palette.secondary.light} 16%, transparent)`, + }; + } + + if (status === "in_progress") { + return { + borderColor: theme.palette.primary.main, + iconBg: `color-mix(in srgb, ${theme.palette.primary.main} 12%, transparent)`, + iconColor: theme.palette.primary.main, + statusColor: theme.palette.primary.main, + hoverBorderColor: theme.palette.primary.main, + hoverBg: `color-mix(in srgb, ${theme.palette.primary.light} 16%, transparent)`, + }; + } + + if (status === "locked") { + return { + borderColor: theme.palette.grey[300], + iconBg: theme.palette.grey[200], + iconColor: theme.palette.text.disabled, + statusColor: theme.palette.text.disabled, + hoverBorderColor: theme.palette.grey[300], + hoverBg: "transparent", + }; + } + + // unlocked (available, not started) + return { + borderColor: theme.palette.grey[200], + iconBg: theme.palette.grey[200], + iconColor: theme.palette.text.primary, + statusColor: theme.palette.text.secondary, + hoverBorderColor: theme.palette.primary.main, + hoverBg: `color-mix(in srgb, ${theme.palette.primary.light} 16%, transparent)`, + }; +}; + +const CareerReadinessModuleCard: React.FC = ({ module, status = "unlocked" }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const styles = useStatusStyles(status); + const isLocked = status === "locked"; + + const handleClick = () => { + if (isLocked) return; + startTransition(() => { + navigate(`${routerPaths.CAREER_READINESS}/${module.id}`); + }); + }; + + const icon = getModuleIcon(module.icon); + + const statusLabel = getStatusLabel(status, t); + const statusIcon = getStatusIcon(status); + + const cardContent = ( + <> + + {icon} + + + + + + {module.title} + + + + {statusIcon} + + {statusLabel} + + + + + + {module.description} + + + + ); + + return ( + + {isLocked ? ( + + {cardContent} + + ) : ( + + {cardContent} + + )} + + ); +}; + +export default CareerReadinessModuleCard; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.stories.tsx new file mode 100644 index 00000000..8c484415 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessModuleCardSkeleton from "src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessModuleCardSkeleton", + component: CareerReadinessModuleCardSkeleton, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.tsx new file mode 100644 index 00000000..81e0e4cc --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Box, Card, Skeleton, useTheme } from "@mui/material"; + +const uniqueId = "50bfbbdc-4850-4026-8dd0-8e95d26f24bf"; + +export const DATA_TEST_ID = { + MODULE_CARD_SKELETON: `career-readiness-module-card-skeleton-${uniqueId}`, +}; + +const CareerReadinessModuleCardSkeleton: React.FC = () => { + const theme = useTheme(); + return ( + + + {/* Icon placeholder */} + + + {/* Text content */} + + {/* Title row and status badge */} + + + + + + {/* Description lines */} + + + + + + + ); +}; + +export default CareerReadinessModuleCardSkeleton; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner.stories.tsx new file mode 100644 index 00000000..900c7c39 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessProgressBanner from "src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner"; +import type { ModuleSummary } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessProgressBanner", + component: CareerReadinessProgressBanner, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const baseModules: ModuleSummary[] = [ + { + id: "1", + title: "Professional Identity & Skills Mapping", + description: "", + icon: "identity", + status: "COMPLETED", + sort_order: 1, + input_placeholder: "", + }, + { + id: "2", + title: "CV Development", + description: "", + icon: "cv", + status: "COMPLETED", + sort_order: 2, + input_placeholder: "", + }, + { + id: "3", + title: "Cover Letter & Motivation Statement", + description: "", + icon: "letter", + status: "UNLOCKED", + sort_order: 3, + input_placeholder: "", + }, + { + id: "4", + title: "Interview Preparation", + description: "", + icon: "interview", + status: "NOT_STARTED", + sort_order: 4, + input_placeholder: "", + }, + { + id: "5", + title: "Workplace Readiness", + description: "", + icon: "workplace", + status: "NOT_STARTED", + sort_order: 5, + input_placeholder: "", + }, +]; + +export const TwoCompleted: Story = { + args: { modules: baseModules }, +}; + +export const NoneCompleted: Story = { + args: { + modules: baseModules.map((m) => ({ + ...m, + status: m.sort_order === 1 ? ("UNLOCKED" as const) : ("NOT_STARTED" as const), + })), + }, +}; + +export const AllCompleted: Story = { + args: { + modules: baseModules.map((m) => ({ ...m, status: "COMPLETED" as const })), + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner.tsx new file mode 100644 index 00000000..ce191a20 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { Box, LinearProgress, Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; +import MenuBookOutlined from "@mui/icons-material/MenuBookOutlined"; +import type { ModuleSummary } from "src/careerReadiness/types"; + +const uniqueId = "f1a2b3c4-d5e6-7f8a-9b0c-1d2e3f4a5b6c"; + +export const DATA_TEST_ID = { + PROGRESS_BANNER: `career-readiness-progress-banner-${uniqueId}`, + PROGRESS_BAR: `career-readiness-progress-bar-${uniqueId}`, + PROGRESS_COMPLETED: `career-readiness-progress-completed-${uniqueId}`, + PROGRESS_REMAINING: `career-readiness-progress-remaining-${uniqueId}`, +}; + +interface CareerReadinessProgressBannerProps { + modules: ModuleSummary[]; +} + +const CareerReadinessProgressBanner: React.FC = ({ modules }) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const total = modules.length; + const completed = modules.filter((m) => m.status === "COMPLETED").length; + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( + + + + {t("careerReadiness.progressTitle")} + + + {percentage}% + + + + + + + + + + + {completed} + {" "} + {t("careerReadiness.progressCompleted")} + + + + + + + {total - completed} + {" "} + {t("careerReadiness.progressRemaining")} + + + + + ); +}; + +export default CareerReadinessProgressBanner; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz.stories.tsx new file mode 100644 index 00000000..38214cfb --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessQuiz from "src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz"; +import type { QuizQuestionResponse } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessQuiz", + component: CareerReadinessQuiz, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const mockQuestions: QuizQuestionResponse[] = [ + { + question: "What best describes your professional identity?", + options: [ + "A. Your job title alone", + "B. Your unique combination of values, skills, experiences, and aspirations", + "C. The company you work for", + "D. Your salary and benefits", + ], + }, + { + question: "Which of the following is a technical skill?", + options: ["A. Teamwork", "B. Leadership", "C. Data analysis", "D. Time management"], + }, +]; + +export const Default: Story = { + args: { + questions: mockQuestions, + onComplete: (answers) => { + console.log("Quiz answers:", answers); + }, + }, +}; + +export const SingleQuestion: Story = { + args: { + questions: mockQuestions.slice(0, 1), + onComplete: (answers) => { + console.log("Quiz answers:", answers); + }, + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz.tsx new file mode 100644 index 00000000..ede632fe --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz.tsx @@ -0,0 +1,292 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, Card, FormControlLabel, LinearProgress, Radio, RadioGroup, Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import ArrowBackIosNewOutlined from "@mui/icons-material/ArrowBackIosNewOutlined"; +import ArrowForwardIosOutlined from "@mui/icons-material/ArrowForwardIosOutlined"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; +import type { QuizQuestionResponse } from "src/careerReadiness/types"; +import PrimaryButton from "src/theme/PrimaryButton/PrimaryButton"; + +const uniqueId = "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e"; + +const OPTION_PATTERN = /^([A-D])\.\s*(.+)$/; + +/** Parse backend option "A. Text" into { key: "A", text: "Text" }. */ +const parseOption = (option: string): { key: string; text: string } => { + const match = OPTION_PATTERN.exec(option.trim()); + if (match) { + return { key: match[1].toUpperCase(), text: match[2].trim() || option }; + } + return { key: option.charAt(0).toUpperCase(), text: option }; +}; + +export const DATA_TEST_ID = { + QUIZ_CONTAINER: `career-readiness-quiz-container-${uniqueId}`, + QUIZ_QUESTION: `career-readiness-quiz-question-${uniqueId}`, + QUIZ_OPTION: `career-readiness-quiz-option-${uniqueId}`, + QUIZ_SUBMIT: `career-readiness-quiz-submit-${uniqueId}`, + QUIZ_PROGRESS: `career-readiness-quiz-progress-${uniqueId}`, + QUIZ_NEXT: `career-readiness-quiz-next-${uniqueId}`, + QUIZ_PREV: `career-readiness-quiz-prev-${uniqueId}`, +}; + +export interface CareerReadinessQuizProps { + questions: QuizQuestionResponse[]; + onComplete: (answers: Record) => void | Promise; + initialAnswers?: Record; + moduleId?: string; + conversationId?: string; + submissionResult?: { + score?: number; + total?: number; + passed: boolean; + correctAnswersSummary?: string; + }; + onRetry?: () => void; +} + +const CareerReadinessQuiz: React.FC = ({ + questions, + onComplete, + initialAnswers, + moduleId, + conversationId, + submissionResult, + onRetry, +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + const isPassed = Boolean(submissionResult?.passed); + const [currentStep, setCurrentStep] = useState(0); + const [answers, setAnswers] = useState>(() => { + if (initialAnswers && Object.keys(initialAnswers).length > 0) return initialAnswers; + if (moduleId && conversationId) { + const stored = PersistentStorageService.getCareerReadinessQuizData(moduleId, conversationId)?.answers; + if (stored) return stored; + } + return {}; + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (initialAnswers && Object.keys(initialAnswers).length > 0) { + setAnswers(initialAnswers); + } + }, [initialAnswers]); + + const total = questions.length; + const question = questions[currentStep]; + const isLastStep = currentStep === total - 1; + const isFirstStep = currentStep === 0; + const questionNumber = currentStep + 1; + const currentAnswer = answers[questionNumber]; + + const parsedOptions = useMemo(() => (question ? question.options.map(parseOption) : []), [question]); + + const setAnswer = useCallback( + (qNumber: number, value: string) => { + setAnswers((prev) => { + const next = { ...prev, [qNumber]: value }; + if (moduleId && conversationId) { + const existing = PersistentStorageService.getCareerReadinessQuizData(moduleId, conversationId) ?? {}; + PersistentStorageService.setCareerReadinessQuizData(moduleId, conversationId, { + ...existing, + answers: next, + }); + } + return next; + }); + }, + [moduleId, conversationId] + ); + + const canGoNext = Boolean(currentAnswer?.length); + + const handleNext = useCallback(() => { + if (isLastStep) return; + setCurrentStep((s) => Math.min(s + 1, total - 1)); + }, [isLastStep, total]); + + const handlePrev = useCallback(() => { + setCurrentStep((s) => Math.max(0, s - 1)); + }, []); + + const handleSubmit = useCallback(async () => { + setIsSubmitting(true); + try { + await onComplete(answers); + } finally { + setIsSubmitting(false); + } + }, [answers, onComplete]); + + if (total === 0) return null; + + return ( + + 0 ? ((currentStep + 1) / total) * 100 : 0} + sx={{ + height: 4, + flexShrink: 0, + "& .MuiLinearProgress-bar": { borderRadius: 0 }, + }} + data-testid={DATA_TEST_ID.QUIZ_PROGRESS} + /> + + + + {t("careerReadiness.quizQuestionOf", { current: currentStep + 1, total })} + + + + + {question.question} + + + setAnswer(questionNumber, value)}> + {parsedOptions.map((opt) => ( + } + label={{`${opt.key}. ${opt.text}`}} + data-testid={`${DATA_TEST_ID.QUIZ_OPTION}-${questionNumber}-${opt.key}`} + sx={{ mx: 0, mb: 0.5 }} + disabled={isPassed} + /> + ))} + + + + + } + onClick={handlePrev} + disabled={isFirstStep} + aria-label={t("careerReadiness.quizPrevious")} + data-testid={DATA_TEST_ID.QUIZ_PREV} + > + {t("careerReadiness.quizPrevious")} + + + {isLastStep ? ( + + {isSubmitting ? t("common.buttons.submit") + "…" : t("common.buttons.submit")} + + ) : ( + } + data-testid={DATA_TEST_ID.QUIZ_NEXT} + > + {t("careerReadiness.quizNext")} + + )} + + + {submissionResult && ( + + {submissionResult.correctAnswersSummary && ( + + {submissionResult.correctAnswersSummary} + + )} + {submissionResult.passed ? ( + {t("careerReadiness.quizPassedInline")} + ) : ( + + + {t("careerReadiness.quizFailedInline")} + + {onRetry && ( + + {t("careerReadiness.quizRetry")} + + )} + + )} + + )} + + + ); +}; + +export default CareerReadinessQuiz; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage.stories.tsx new file mode 100644 index 00000000..5af965c9 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessQuizChatMessage from "src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage"; +import type { QuizQuestionResponse } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessQuizChatMessage", + component: CareerReadinessQuizChatMessage, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const mockQuestions: QuizQuestionResponse[] = [ + { + question: "What best describes your professional identity?", + options: [ + "A. Your job title alone", + "B. Your unique combination of values, skills, experiences, and aspirations", + "C. The company you work for", + "D. Your salary and benefits", + ], + }, +]; + +export const WithIntroMessage: Story = { + args: { + message_id: "quiz-msg-1", + introMessage: "Great work! You've covered the key topics. It's time for a short quiz to check your understanding.", + questions: mockQuestions, + onSubmit: async (answers) => { + console.log("Quiz submitted:", answers); + }, + }, +}; + +export const WithoutIntroMessage: Story = { + args: { + message_id: "quiz-msg-2", + questions: mockQuestions, + onSubmit: async (answers) => { + console.log("Quiz submitted:", answers); + }, + }, +}; + +export const WithFailedResultAndRetry: Story = { + args: { + message_id: "quiz-msg-3", + introMessage: "Great work! You've covered the key topics. It's time for a short quiz.", + questions: mockQuestions, + onSubmit: async (answers) => { + console.log("Quiz submitted (retry):", answers); + }, + moduleId: "professional-identity", + conversationId: "conv-1", + submissionResult: { + score: 0, + total: 1, + passed: false, + submittedAt: Date.now(), + }, + lastAnswers: { 1: "A" }, + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage.tsx new file mode 100644 index 00000000..35cde2e2 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessQuizChatMessage/CareerReadinessQuizChatMessage.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from "react"; +import { Box, Divider, styled, Typography, useTheme } from "@mui/material"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; +import CareerReadinessQuiz from "src/careerReadiness/components/CareerReadinessQuiz/CareerReadinessQuiz"; +import type { QuizQuestionResponse } from "src/careerReadiness/types"; + +const uniqueId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +export const DATA_TEST_ID = { + CAREER_READINESS_QUIZ_CHAT_MESSAGE_CONTAINER: `career-readiness-quiz-chat-message-container-${uniqueId}`, +}; + +export const CAREER_READINESS_QUIZ_CHAT_MESSAGE_TYPE = `career-readiness-quiz-chat-message-${uniqueId}`; + +export interface QuizSubmissionResult { + score: number; + total: number; + passed: boolean; + submittedAt: number; + correctAnswersSummary?: string; +} + +export interface CareerReadinessQuizChatMessageProps { + message_id: string; + introMessage?: string; + questions: QuizQuestionResponse[]; + onSubmit: (answers: Record) => Promise; + moduleId?: string; + conversationId?: string; + submissionResult?: QuizSubmissionResult; + lastAnswers?: Record; +} + +const MessageContainer = styled(Box)<{ origin: ConversationMessageSender }>(({ theme, origin }) => ({ + display: "flex", + flexDirection: "column", + alignItems: origin === ConversationMessageSender.USER ? "flex-end" : "flex-start", + marginBottom: theme.spacing(theme.tabiyaSpacing.sm), + width: "100%", +})); + +const AgentBubble = styled(Box)(({ theme }) => ({ + width: "100%", + minWidth: "100%", + maxWidth: "100%", + padding: theme.fixedSpacing(theme.tabiyaSpacing.sm), + borderRadius: "12px 12px 12px 0px", + backgroundColor: theme.palette.grey[100], + color: theme.palette.text.primary, + display: "flex", + flexDirection: "column", + alignItems: "flex-start", +})); + +const CareerReadinessQuizChatMessage: React.FC = ({ + message_id, + introMessage, + questions, + onSubmit, + moduleId, + conversationId, + submissionResult, + lastAnswers, +}) => { + const theme = useTheme(); + const [hideResultForRetry, setHideResultForRetry] = useState(false); + + useEffect(() => { + setHideResultForRetry(false); + }, [submissionResult?.submittedAt]); + + if (!questions.length) { + return null; + } + + return ( + + + {introMessage && ( + <> + + {introMessage} + + + + )} + + + { + if (moduleId && conversationId) { + PersistentStorageService.clearCareerReadinessQuizResult(moduleId, conversationId); + } + setHideResultForRetry(true); + }} + /> + + + + ); +}; + +export default CareerReadinessQuizChatMessage; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage.tsx new file mode 100644 index 00000000..bd51fc73 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Box, keyframes, Typography } from "@mui/material"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import { MessageContainer } from "src/chat/chatMessage/userChatMessage/UserChatMessage"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { nanoid } from "nanoid"; +import type { IChatMessage } from "src/chat/Chat.types"; + +const TYPING_KEY = "chat.chatMessage.typingChatMessage.typing"; + +const uniqueId = "4bca669b-6e88-4d21-8a0e-4d5e5ad3c4de"; + +export const CAREER_READINESS_TYPING_MESSAGE_TYPE = `career-readiness-typing-${uniqueId}`; + +const dotAnimation = keyframes` + 0%, 100% { + transform: translateY(+0.5px); + opacity: 0.5; + } + 50% { + transform: translateY(-1px); + opacity: 1; + } +`; + +export interface CareerReadinessTypingMessageProps { + _?: never; +} + +const CareerReadinessTypingMessage: React.FC = () => { + const { t } = useTranslation(); + const displayText = t(TYPING_KEY); + + return ( + + + + {displayText} + + {[0, 1, 2].map((i) => ( + + . + + ))} + + + + + ); +}; + +export function generateCareerReadinessTypingMessage(): IChatMessage { + return { + type: CAREER_READINESS_TYPING_MESSAGE_TYPE, + message_id: nanoid(), + sender: ConversationMessageSender.COMPASS, + payload: {}, + component: (props: CareerReadinessTypingMessageProps) => , + }; +} + +export default CareerReadinessTypingMessage; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessUserMessage/CareerReadinessUserMessage.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessUserMessage/CareerReadinessUserMessage.tsx new file mode 100644 index 00000000..4912207a --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessUserMessage/CareerReadinessUserMessage.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Box, styled } from "@mui/material"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; + +const uniqueId = "58df8006-bf2a-4d64-89d0-4442f262b702"; + +export const DATA_TEST_ID = { + CAREER_READINESS_USER_MESSAGE_CONTAINER: `career-readiness-user-message-container-${uniqueId}`, +}; + +export const CAREER_READINESS_USER_MESSAGE_TYPE = `career-readiness-user-message-${uniqueId}`; + +export interface CareerReadinessUserMessageProps { + message: string; +} + +const MessageContainer = styled(Box)<{ origin: ConversationMessageSender }>(({ theme, origin }) => ({ + display: "flex", + flexDirection: "column", + alignItems: origin === ConversationMessageSender.USER ? "flex-end" : "flex-start", + marginBottom: theme.spacing(theme.tabiyaSpacing.sm), + width: "100%", +})); + +const CareerReadinessUserMessage: React.FC = ({ message }) => { + return ( + + + + + + ); +}; + +export default CareerReadinessUserMessage; diff --git a/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.stories.tsx b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.stories.tsx new file mode 100644 index 00000000..c7dd9af2 --- /dev/null +++ b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessList from "src/careerReadiness/pages/CareerReadinessList/CareerReadinessList"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import type { ModuleSummary } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessList", + component: CareerReadinessList, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const mockModules: ModuleSummary[] = [ + { + id: "professional-identity", + title: "Professional Identity & Skills Mapping", + description: + "Understand what professional identity means, learn the three types of skills, and how to articulate them to employers.", + icon: "identity", + status: "COMPLETED", + sort_order: 1, + input_placeholder: "Ask about professional identity and skills…", + }, + { + id: "cv-development", + title: "CV Development", + description: "Build a strong CV that highlights your skills and experience effectively.", + icon: "cv", + status: "UNLOCKED", + sort_order: 2, + input_placeholder: "Ask about CV writing…", + }, + { + id: "cover-letter", + title: "Cover Letter & Motivation Statement", + description: "Write compelling cover letters and motivation statements tailored to each job application.", + icon: "letter", + status: "NOT_STARTED", + sort_order: 3, + input_placeholder: "Ask about cover letters…", + }, + { + id: "interview-preparation", + title: "Interview Preparation", + description: "Practice common interview questions, learn the STAR method, and build confidence for job interviews.", + icon: "interview", + status: "NOT_STARTED", + sort_order: 4, + input_placeholder: "Ask about interviews…", + }, + { + id: "workplace-readiness", + title: "Workplace Readiness", + description: + "Develop essential workplace skills including communication, problem-solving, teamwork, and professional etiquette.", + icon: "workplace", + status: "NOT_STARTED", + sort_order: 5, + input_placeholder: "Ask about workplace readiness…", + }, +]; + +export const Default: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = async () => ({ modules: mockModules }); + return ; + }, + ], +}; + +export const Loading: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = () => new Promise(() => {}); + return ; + }, + ], +}; + +export const Empty: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = async () => ({ modules: [] }); + return ; + }, + ], +}; + +export const LoadError: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = async () => { + throw new Error("Unable to load modules. Please try again."); + }; + return ; + }, + ], +}; diff --git a/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.tsx b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.tsx new file mode 100644 index 00000000..fffe1923 --- /dev/null +++ b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Box, Grid, Typography, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; +import { routerPaths } from "src/app/routerPaths"; +import { mapModuleStatusToDisplay } from "src/careerReadiness/types"; +import CareerReadinessModuleCard from "src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard"; +import CareerReadinessModuleCardSkeleton from "src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton"; +import CareerReadinessProgressBanner from "src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner"; +import Footer from "src/home/components/Footer/Footer"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import type { ModuleSummary } from "src/careerReadiness/types"; +import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; + +const uniqueId = "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a"; + +export const DATA_TEST_ID = { + CAREER_READINESS_LIST_CONTAINER: `career-readiness-list-container-${uniqueId}`, + CAREER_READINESS_LIST_CONTENT: `career-readiness-list-content-${uniqueId}`, + CAREER_READINESS_LIST_GRID: `career-readiness-list-grid-${uniqueId}`, + CAREER_READINESS_LIST_EMPTY: `career-readiness-list-empty-${uniqueId}`, + CAREER_READINESS_PROGRESS_BANNER: `career-readiness-progress-banner-wrapper-${uniqueId}`, + CAREER_READINESS_INTRODUCTION: `career-readiness-introduction-${uniqueId}`, + CAREER_READINESS_LIST_LOADING: `career-readiness-list-loading-${uniqueId}`, +}; + +const SKELETON_COUNT = 6; + +const CareerReadinessList: React.FC = () => { + const theme = useTheme(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + + const loadModules = useCallback(() => { + setLoading(true); + CareerReadinessService.getInstance() + .listModules() + .then((res) => { + const sorted = [...res.modules].sort((a, b) => a.sort_order - b.sort_order); + setModules(sorted); + }) + .catch((e) => { + enqueueSnackbar(e?.message ?? t("careerReadiness.listError"), { variant: "error" }); + }) + .finally(() => { + setLoading(false); + }); + }, [t, enqueueSnackbar]); + + useEffect(() => { + loadModules(); + }, [loadModules]); + + return ( + + navigate(routerPaths.ROOT)} + /> + + + {!loading && modules.length > 0 && ( + + + + )} + + + + {t("careerReadiness.introduction")} + + + + {loading && ( + + {Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + + + ))} + + )} + + {!loading && modules.length === 0 && ( + + {t("careerReadiness.emptyList")} + + )} + + {!loading && modules.length > 0 && ( + + {modules.map((module) => ( + + + + ))} + + )} + + +