Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ BACKEND_CV_STORAGE_BUCKET=<GCS_BUCKET_NAME>
BACKEND_CV_MAX_UPLOADS_PER_USER=<INTEGER>
BACKEND_CV_RATE_LIMIT_PER_MINUTE=<INTEGER>

# Preference elicitation feature.
# When True, enables the preference elicitation sub-phase (vignettes + BWS card)
# after experience collection and registers the /job-preferences/* REST routes.
GLOBAL_ENABLE_PREFERENCE_ELICITATION=<True/False>


# Language Configurations
BACKEND_LANGUAGE_CONFIG='{
Expand Down
1 change: 1 addition & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ keys
test_output/
.idea/
logs/
session_logs/
feedback-reports/
exports/
30 changes: 30 additions & 0 deletions backend/app/agent/agent_director/abstract_agent_director.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,28 @@ class ConversationPhase(Enum):
ENDED = 3


class CounselingSubPhase(Enum):
"""
Deterministic sub-phases within the COUNSELING phase.

When the preference elicitation feature is enabled, COUNSELING progresses
from EXPLORE_EXPERIENCES to PREFERENCE_ELICITATION. When the feature is
disabled, the sub-phase stays at EXPLORE_EXPERIENCES throughout COUNSELING.

The agent director uses this enum to deterministically route between
sub-phase agents rather than asking the LLM router to do it.
"""
EXPLORE_EXPERIENCES = 0
PREFERENCE_ELICITATION = 1


class AgentDirectorState(BaseModel):
"""
The state of the agent director
"""
session_id: int
current_phase: ConversationPhase = Field(default=ConversationPhase.INTRO)
counseling_sub_phase: CounselingSubPhase = Field(default=CounselingSubPhase.EXPLORE_EXPERIENCES)
conversation_conducted_at: Optional[datetime] = None

class Config:
Expand Down Expand Up @@ -53,6 +69,16 @@ def deserialize_current_phase(cls, value: str | ConversationPhase) -> Conversati
return ConversationPhase[value]
return value

@field_serializer("counseling_sub_phase")
def serialize_counseling_sub_phase(self, counseling_sub_phase: CounselingSubPhase, _info):
return counseling_sub_phase.name

@field_validator("counseling_sub_phase", mode='before')
def deserialize_counseling_sub_phase(cls, value: str | CounselingSubPhase) -> CounselingSubPhase:
if isinstance(value, str):
return CounselingSubPhase[value]
return value

# Deserialize the conversation_conducted_at datetime and ensure it's interpreted as UTC
@field_validator("conversation_conducted_at", mode='before')
def deserialize_conversation_conducted_at(cls, value: Optional[datetime]) -> Optional[datetime]:
Expand All @@ -62,6 +88,10 @@ def deserialize_conversation_conducted_at(cls, value: Optional[datetime]) -> Opt
def from_document(_doc: Mapping[str, Any]) -> "AgentDirectorState":
return AgentDirectorState(session_id=_doc["session_id"],
current_phase=_doc["current_phase"],
# counseling_sub_phase was introduced with the preference elicitation feature,
# so older docs may not have it — default to the initial sub-phase.
counseling_sub_phase=_doc.get("counseling_sub_phase",
CounselingSubPhase.EXPLORE_EXPERIENCES),
# The conversation_conducted_at field was introduced later, so it may not exist in all documents
# For the documents that don't have this field, we'll default to None,
# The implication being that the client will have to handle this case.
Expand Down
87 changes: 72 additions & 15 deletions backend/app/agent/agent_director/llm_agent_director.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from pathlib import Path

from app.agent.agent import Agent
from app.agent.agent_director._llm_router import LLMRouter
from app.agent.agent_director.abstract_agent_director import AbstractAgentDirector, ConversationPhase
from app.agent.agent_director.abstract_agent_director import AbstractAgentDirector, ConversationPhase, CounselingSubPhase
from app.agent.agent_types import AgentInput, AgentOutput, AgentType
from app.agent.explore_experiences_agent_director import ExploreExperiencesAgentDirector
from app.agent.farewell_agent import FarewellAgent
from app.agent.linking_and_ranking_pipeline import ExperiencePipelineConfig
from app.agent.preference_elicitation_agent.agent import PreferenceElicitationAgent
from app.agent.welcome_agent import WelcomeAgent
from app.conversation_memory.conversation_memory_manager import ConversationMemoryManager
from app.conversation_memory.conversation_memory_types import ConversationContext
from app.countries import Country
from app.vector_search.vector_search_dependencies import SearchServices
from app.i18n.translation_service import t

Expand All @@ -21,19 +25,31 @@ class LLMAgentDirector(AbstractAgentDirector):
def __init__(self, *,
conversation_manager: ConversationMemoryManager,
search_services: SearchServices,
experience_pipeline_config: ExperiencePipelineConfig
experience_pipeline_config: ExperiencePipelineConfig,
enable_preference_elicitation: bool = False,
default_country_of_user: Country = Country.UNSPECIFIED,
):
super().__init__(conversation_manager)
self._enable_preference_elicitation = enable_preference_elicitation
# initialize the agents
self._agents: dict[AgentType, Agent] = {
AgentType.WELCOME_AGENT: WelcomeAgent(),
AgentType.EXPLORE_EXPERIENCES_AGENT: ExploreExperiencesAgentDirector(
conversation_manager=conversation_manager,
search_services=search_services,
experience_pipeline_config=experience_pipeline_config
experience_pipeline_config=experience_pipeline_config,
enable_preference_elicitation=enable_preference_elicitation,
),
AgentType.FAREWELL_AGENT: FarewellAgent()
}
if enable_preference_elicitation:
offline_output_dir = str(Path(__file__).parent.parent.parent.parent / "offline_output")
self._agents[AgentType.PREFERENCE_ELICITATION_AGENT] = PreferenceElicitationAgent(
use_personalized_vignettes=False,
use_offline_with_personalization=True,
offline_output_dir=offline_output_dir,
country_of_user=default_country_of_user,
)
self._llm_router = LLMRouter(self._logger)

def get_welcome_agent(self) -> WelcomeAgent:
Expand All @@ -50,6 +66,13 @@ def get_explore_experiences_agent(self) -> ExploreExperiencesAgentDirector:
raise ValueError("The agent is not an instance of ExploreExperiencesAgentDirector")
return agent

def get_preference_elicitation_agent(self) -> PreferenceElicitationAgent:
# cast the agent to the PreferenceElicitationAgent
agent = self._agents.get(AgentType.PREFERENCE_ELICITATION_AGENT)
if not isinstance(agent, PreferenceElicitationAgent):
raise ValueError("Preference elicitation agent is not enabled")
return agent

async def get_suitable_agent_type(self, *,
user_input: AgentInput,
phase: ConversationPhase,
Expand All @@ -62,8 +85,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.
# In the counseling phase, when preference elicitation is enabled the sub-phase
# determines the agent deterministically. When it is disabled, fall back to the
# existing LLM router which routes within (WELCOME, EXPLORE_EXPERIENCES, FAREWELL).
if phase == ConversationPhase.COUNSELING:
if self._enable_preference_elicitation and self._state is not None:
sub_phase = self._state.counseling_sub_phase
if sub_phase == CounselingSubPhase.PREFERENCE_ELICITATION:
return AgentType.PREFERENCE_ELICITATION_AGENT
# CounselingSubPhase.EXPLORE_EXPERIENCES → route deterministically too.
return AgentType.EXPLORE_EXPERIENCES_AGENT
return await self._llm_router.execute(
user_input=user_input,
phase=phase,
Expand All @@ -73,37 +104,48 @@ async def get_suitable_agent_type(self, *,
# Otherwise, send the Farewell agent to the LLM, no penalty and no error.
return AgentType.FAREWELL_AGENT

def _get_new_phase(self, agent_output: AgentOutput) -> ConversationPhase:
def _compute_next_state(self, agent_output: AgentOutput) -> tuple[ConversationPhase, CounselingSubPhase]:
"""
Get the new conversation phase based on the agent output and the current phase.
Compute the next (conversation_phase, counseling_sub_phase) for the given agent output.
Pure function — does not mutate self._state. The caller (execute) applies the result.
"""
if self._state is None:
raise RuntimeError("AgentDirectorState must be set before computing the new phase")
current_phase = self._state.current_phase
current_sub_phase = self._state.counseling_sub_phase

# ConversationPhase.ENDED is the final phase
if current_phase == ConversationPhase.ENDED:
return ConversationPhase.ENDED
return ConversationPhase.ENDED, current_sub_phase

# In the intro phase, only the Welcome agent can end the phase
if (current_phase == ConversationPhase.INTRO
and agent_output.agent_type == AgentType.WELCOME_AGENT
and agent_output.finished):
return ConversationPhase.COUNSELING
return ConversationPhase.COUNSELING, current_sub_phase

# In the consulting phase, only the Explore Experiences agent can end the phase
# (when preference elicitation is enabled it advances the sub-phase first instead of ending)
if (current_phase == ConversationPhase.COUNSELING
and agent_output.agent_type == AgentType.EXPLORE_EXPERIENCES_AGENT
and agent_output.finished):
return ConversationPhase.CHECKOUT
if self._enable_preference_elicitation:
return ConversationPhase.COUNSELING, CounselingSubPhase.PREFERENCE_ELICITATION
return ConversationPhase.CHECKOUT, current_sub_phase

# In the consulting phase, the Preference Elicitation agent ends the phase
if (current_phase == ConversationPhase.COUNSELING
and agent_output.agent_type == AgentType.PREFERENCE_ELICITATION_AGENT
and agent_output.finished):
return ConversationPhase.CHECKOUT, current_sub_phase

# In the checkout phase, only the Farewell agent can end the phase
if (current_phase == ConversationPhase.CHECKOUT
and agent_output.agent_type == AgentType.FAREWELL_AGENT
and agent_output.finished):
return ConversationPhase.ENDED
return ConversationPhase.ENDED, current_sub_phase

return current_phase
return current_phase, current_sub_phase

async def execute(self, user_input: AgentInput) -> AgentOutput:
"""
Expand Down Expand Up @@ -150,16 +192,31 @@ async def execute(self, user_input: AgentInput) -> AgentOutput:

# Determine if a phase transition is about to happen so we can decide
# whether to save this agent's response to history.
new_phase = self._get_new_phase(agent_output)
new_phase, new_sub_phase = self._compute_next_state(agent_output)
_will_transition = self._state.current_phase != new_phase

if not agent_for_task.is_responsible_for_conversation_history():
# Don't save the outgoing agent's final response when transitioning —
# the next agent will produce the response the user actually sees.
if not _will_transition:
# Skip saving only the WelcomeAgent's final transition message — the next
# agent produces its own opener that supersedes it. Other transitions
# (PreferenceElicitationAgent WRAPUP summary, FarewellAgent closing) emit
# user-facing content that must be preserved.
skip_on_transition = (
_will_transition
and agent_output.agent_type == AgentType.WELCOME_AGENT
)
if not skip_on_transition:
await self._conversation_manager.update_history(clean_input, agent_output)
context = await self._conversation_manager.get_conversation_context()

# Advance the counseling sub-phase if it changed (no director loop re-entry —
# the next user turn picks up the new sub-agent via deterministic routing).
if self._state.counseling_sub_phase != new_sub_phase:
self._logger.info(
"Advancing counseling sub-phase: %s --to-> %s",
self._state.counseling_sub_phase, new_sub_phase,
)
self._state.counseling_sub_phase = new_sub_phase

# Update the conversation phase
self._logger.debug("Transitioned phase from %s --to-> %s", self._state.current_phase, new_phase)

Expand Down
12 changes: 12 additions & 0 deletions backend/app/agent/agent_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ class AgentType(Enum):
COLLECT_EXPERIENCES_AGENT = "CollectExperiencesAgent"
INFER_OCCUPATIONS_AGENT = "InferOccupationsAgent"
EXPLORE_SKILLS_AGENT = "ExploreSkillsAgent"
PREFERENCE_ELICITATION_AGENT = "PreferenceElicitationAgent"
FAREWELL_AGENT = "FarewellAgent"
QNA_AGENT = "QnaAgent"


class LLMQuickReplyOption(BaseModel):
"""Quick-reply option for the LLM to suggest in its response."""
label: str = Field(description="Short button text displayed to the user and sent as their reply when clicked (max ~40 chars)")

class Config:
extra = "forbid"


class AgentInput(BaseModel):
"""
The input to an agent
Expand Down Expand Up @@ -101,6 +110,9 @@ class AgentOutput(BaseModel):
sent_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
"""The sent_at of the message"""

metadata: Optional[dict] = None
"""Optional metadata for structured UI rendering (e.g., BWS tasks, vignette options, interactive components)"""

class Config:
extra = "forbid"

Expand Down
23 changes: 19 additions & 4 deletions backend/app/agent/explore_experiences_agent_director.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,15 @@ async def _dive_into_experiences(self, *,
current_experience = state.experiences_state.get(state.current_experience_uuid, None)

if not current_experience:
# When preference elicitation is enabled, transition into it with a
# bridge message instead of the "no more experiences" wrap-up text.
transition_key = (
"exploreExperiences.transitionToPreferences"
if self._enable_preference_elicitation
else "exploreExperiences.noMoreExperiences"
)
message = AgentOutput(
message_for_user=t("messages", "exploreExperiences.noMoreExperiences"),
message_for_user=t("messages", transition_key),
finished=True,
agent_type=self._agent_type,
agent_response_time_in_sec=0,
Expand Down Expand Up @@ -284,9 +291,15 @@ async def _dive_into_experiences(self, *,
# then we are done
_next_experience = _pick_next_experience_to_process(state.experiences_state)
if not _next_experience:
# No more experiences to process, we are done
# No more experiences to process — either say goodbye to the explore phase
# or hand off to preference elicitation depending on the feature flag.
transition_key = (
"exploreExperiences.transitionToPreferences"
if self._enable_preference_elicitation
else "exploreExperiences.finishedAll"
)
return AgentOutput(
message_for_user=t("messages", "exploreExperiences.finishedAll"),
message_for_user=t("messages", transition_key),
finished=True,
agent_type=self._agent_type,
agent_response_time_in_sec=0,
Expand Down Expand Up @@ -360,7 +373,8 @@ def set_state(self, state: ExploreExperiencesAgentDirectorState):
def __init__(self, *,
conversation_manager: ConversationMemoryManager,
search_services: SearchServices,
experience_pipeline_config: ExperiencePipelineConfig
experience_pipeline_config: ExperiencePipelineConfig,
enable_preference_elicitation: bool = False,
):
super().__init__(agent_type=AgentType.EXPLORE_EXPERIENCES_AGENT,
is_responsible_for_conversation_history=True)
Expand All @@ -370,6 +384,7 @@ def __init__(self, *,
self._collect_experiences_agent = CollectExperiencesAgent()
self._exploring_skills_agent = SkillsExplorerAgent()
self._experience_pipeline_config = experience_pipeline_config
self._enable_preference_elicitation = enable_preference_elicitation

def get_collect_experiences_agent(self) -> CollectExperiencesAgent:
return self._collect_experiences_agent
Expand Down
46 changes: 46 additions & 0 deletions backend/app/agent/preference_elicitation_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Preference Elicitation Agent Package.

This package contains the implementation of the preference elicitation agent
that gathers user job preferences through conversational vignettes.
"""

from app.agent.preference_elicitation_agent.agent import PreferenceElicitationAgent
from app.agent.preference_elicitation_agent.state import PreferenceElicitationAgentState
from app.agent.preference_elicitation_agent.types import (
PreferenceVector,
Vignette,
VignetteOption,
VignetteResponse,
FinancialPreferences,
WorkEnvironmentPreferences,
JobSecurityPreferences,
CareerAdvancementPreferences,
WorkLifeBalancePreferences,
TaskPreferences,
SocialImpactPreferences
)
from app.agent.preference_elicitation_agent.vignette_engine import VignetteEngine
from app.agent.preference_elicitation_agent.preference_extractor import (
PreferenceExtractor,
PreferenceExtractionResult
)

__all__ = [
"PreferenceElicitationAgent",
"PreferenceElicitationAgentState",
"PreferenceVector",
"Vignette",
"VignetteOption",
"VignetteResponse",
"FinancialPreferences",
"WorkEnvironmentPreferences",
"JobSecurityPreferences",
"CareerAdvancementPreferences",
"WorkLifeBalancePreferences",
"TaskPreferences",
"SocialImpactPreferences",
"VignetteEngine",
"PreferenceExtractor",
"PreferenceExtractionResult",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Adaptive selection components for D-optimal vignette choice."""

from .d_optimal_selector import DOptimalSelector
from .uncertainty_analyzer import UncertaintyAnalyzer
from .adaptive_difficulty import AdaptiveDifficulty

__all__ = [
"DOptimalSelector",
"UncertaintyAnalyzer",
"AdaptiveDifficulty",
]
Loading
Loading