Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5c53391
chore(ci): update environment name in deployment workflow
rav3n11 Feb 20, 2026
01615ad
feat(i18n): add Nyanja (Zambia) locale support and related translations
rav3n11 Feb 20, 2026
a15e917
feat(i18n): --prettier format SupportedLocales array
rav3n11 Feb 20, 2026
a0060af
Merge branch 'tabiya-tech:main' into main
rav3n11 Mar 4, 2026
e3fce4f
feat(career-readiness): add API contract stubs for career readiness m…
nraffa Feb 27, 2026
485309c
feat(data): implement plain personal data module with service, reposi…
rav3n11 Feb 27, 2026
166950a
feat(career-readiness): implement career readiness module with servic…
nraffa Mar 2, 2026
7a86b31
feat(frontend): add home page with modules, progress, and continue badge
Fidesnoella Mar 2, 2026
7af6a99
feat(fe): knowledge hub content management
irumvanselme Mar 2, 2026
e23a096
feat(backend): simplify counseling phase by removing recommender advi…
rav3n11 Mar 2, 2026
46b56c8
feat(backend): remove new session feature to simplify the module arch…
rav3n11 Mar 3, 2026
33b7689
feat(backend): remove paid work from job types (paid employment and s…
rav3n11 Mar 3, 2026
1d47af7
feat(backend): first experience collection message mentioned paid job…
rav3n11 Mar 4, 2026
666cad2
feat(backend): question at the end of the preference elicitation wrap…
rav3n11 Mar 4, 2026
34e75ac
feat(backend): final skill elicitation message should transition smoo…
rav3n11 Mar 4, 2026
da066ef
feat(backend): update thinking messages for improved clarity in multi…
rav3n11 Mar 4, 2026
b6f31c3
feat(backend): stop appending transition text
rav3n11 Mar 4, 2026
20aac4c
feat(backend): refactor BackButton component and fix imports after re…
rav3n11 Mar 4, 2026
bb023fc
feat(backend): change sloth to bushbaby
rav3n11 Mar 4, 2026
647fe44
feat(preferences): integrate plain personal data service into user pr…
irumvanselme Mar 5, 2026
912c995
feat(ci): update artifact upload conditions to include zambia-working…
irumvanselme Mar 5, 2026
5981b29
feat: update environment name in deployment configuration to dev-njil…
irumvanselme Mar 4, 2026
69262d7
feat(career-readiness): implement full instruction and quiz pipeline …
nraffa Mar 5, 2026
eb3e6e8
refactor(career-readiness): reduce test suite from 125 to 90 tests
nraffa Mar 5, 2026
33d81c6
feat(career-readiness): expose quiz as structured data via dedicated …
nraffa Mar 6, 2026
b267823
Merge pull request #11 from tabiya-tech/feat/career-readiness-clean
Fidesnoella Mar 9, 2026
541312e
feat(frontend|backend): Add career exploration module powered by llm …
rav3n11 Mar 5, 2026
a479508
feat(backend): make sectors configurable
rav3n11 Mar 9, 2026
391e423
feat(career-readiness): add entrepreneurship module and update module…
nraffa Mar 9, 2026
9eff818
Merge pull request #18 from tabiya-tech/feat/module-6-entrepreneurship
nraffa Mar 9, 2026
6a3476c
feat(frontend): add career readiness modules
Fidesnoella Mar 5, 2026
e1e6c4a
feat(fronted): add career readiness quiz for each module and track pr…
Fidesnoella Mar 9, 2026
1f0dfd3
feat: add swagger UI parameter to disable document expansion so that …
irumvanselme Mar 7, 2026
d952f04
feat: close taxonomy database connection in server shutdown process
irumvanselme Mar 9, 2026
b20d521
feat(frontend): profile page [iteration 1]
irumvanselme Mar 7, 2026
19bc864
feat(backend): Make country name configurable in Career Explorer
rav3n11 Mar 9, 2026
ea92065
feat(backend): enhance Career Explorer with non-priority sector explo…
rav3n11 Mar 9, 2026
bbb4031
feat(backend): update career modules to match expected ones
rav3n11 Mar 10, 2026
54443d7
feat(bushbaby): add eye white elements and adjust SVG dimensions
rav3n11 Mar 10, 2026
075f025
feat(ama): add Ask Me Anything platform guide agent
wilfredeveloper Mar 6, 2026
4dbb404
feat(ama): refactor platform modules into constants
rav3n11 Mar 10, 2026
66a0781
feat(ama): add multilingual support to ama agent
rav3n11 Mar 10, 2026
c91c491
feat(ama): remove conversation_id, since only 1 conversation at a tim…
rav3n11 Mar 10, 2026
d863852
feat(ama): add eval tests for ama agent
rav3n11 Mar 10, 2026
8da6ba3
poc(ama): mascot ama chat
rav3n11 Mar 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 5 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ BACKEND_ENABLE_METRICS=False
USERDATA_MONGODB_URI=mongodb+srv://<USERNAME>:<PASSWORD>@<CLUSTER>/?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://<USERNAME>:<PASSWORD>@<CLUSTER>/?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
Expand Down
12 changes: 1 addition & 11 deletions backend/app/agent/agent_director/_llm_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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,
Expand Down
50 changes: 22 additions & 28 deletions backend/app/agent/agent_director/llm_agent_director.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,33 +120,23 @@ 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
if sub_phase == CounselingSubPhase.EXPLORE_EXPERIENCES:
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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:")

Expand All @@ -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)

Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions backend/app/agent/agent_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
208 changes: 208 additions & 0 deletions backend/app/agent/ask_me_anything_agent.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions backend/app/agent/career_explorer_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .agent import CareerExplorerAgent
from .sector_search_service import SectorChunkEntity, SectorSearchService

__all__ = ["CareerExplorerAgent", "SectorChunkEntity", "SectorSearchService"]
Loading
Loading