diff --git a/backend/app/agent/collect_experiences_agent/_conversation_llm.py b/backend/app/agent/collect_experiences_agent/_conversation_llm.py index c1e9a89d4..4fe49e3b8 100644 --- a/backend/app/agent/collect_experiences_agent/_conversation_llm.py +++ b/backend/app/agent/collect_experiences_agent/_conversation_llm.py @@ -38,6 +38,35 @@ def _find_incomplete_experiences(collected_data: list[CollectedData]) -> list[tu return incomplete_experiences +def fill_incomplete_fields_as_declined( + collected_data: list[CollectedData], + work_type: WorkType | None, +) -> None: + """ + Set None to "" for completeness fields (start_date, end_date, company, location) + on experiences of the given work type. Treats missing data as user declined to provide. + Mutates the experiences in place. + """ + if work_type is None: + return + logger = logging.getLogger(__name__) + key = work_type.name + for exp in collected_data: + if exp.work_type and exp.work_type.strip() == key: + if exp.start_date is None: + logger.warning("Filling incomplete start_date as declined for %s experience: %s", key, exp.experience_title) + exp.start_date = "" + if exp.end_date is None: + logger.warning("Filling incomplete end_date as declined for %s experience: %s", key, exp.experience_title) + exp.end_date = "" + if exp.company is None: + logger.warning("Filling incomplete company as declined for %s experience: %s", key, exp.experience_title) + exp.company = "" + if exp.location is None: + logger.warning("Filling incomplete location as declined for %s experience: %s", key, exp.experience_title) + exp.location = "" + + def _get_incomplete_experiences_instructions(collected_data: list[CollectedData]) -> str: """ Generate instructions for handling incomplete experiences. diff --git a/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py b/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py index c45fed2cf..a850bddec 100644 --- a/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py +++ b/backend/app/agent/collect_experiences_agent/_transition_decision_tool.py @@ -13,14 +13,14 @@ from app.agent.prompt_template import sanitize_input from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter from app.conversation_memory.conversation_memory_types import ConversationContext -from app.agent.experience.work_type import WorkType from common_libs.llm.generative_models import GeminiGenerativeLLM from common_libs.llm.models_utils import LLMConfig, ZERO_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG, \ get_config_variation from common_libs.llm.schema_builder import with_response_schema from common_libs.retry import Retry -from ._conversation_llm import _find_incomplete_experiences, _get_experience_type, _ask_experience_type_question +from ._conversation_llm import _get_experience_type, _ask_experience_type_question from ._types import CollectedData +from ..experience import WorkType _TAGS_TO_FILTER = [ "system instructions", @@ -99,18 +99,15 @@ async def execute(self, explored_types: list[WorkType], conversation_context: ConversationContext, user_input: AgentInput) -> tuple[TransitionDecision, Optional[TransitionReasoning], list[LLMStats]]: - - # Rule-based check 1: Incomplete experiences - incomplete_experiences = _find_incomplete_experiences(collected_data) - if incomplete_experiences: + incomplete_required = _find_incomplete_required_for_work_type(collected_data, exploring_type) + if incomplete_required: self._logger.info( - "Incomplete experiences found - returning CONTINUE. " + "Incomplete required fields (title/work_type) - returning CONTINUE. " "Incomplete experiences: %s", - [(idx, exp.experience_title, missing) for idx, exp, missing in incomplete_experiences] + [(idx, exp.experience_title, missing) for idx, exp, missing in incomplete_required] ) return TransitionDecision.CONTINUE, None, [] - - + cleaned_experience_dicts = [] for collected_item in collected_data: collected_item_dict = collected_item.model_dump(exclude={"defined_at_turn_number"}) @@ -288,7 +285,7 @@ async def _internal_execute(self, #Constraints - Use semantic understanding, not keyword matching -- If incomplete experiences exist, continue_current_type must be true +- When the user clearly indicates no more experiences of this type (e.g. "no", "nope"), return continue_current_type=false - If unexplored_types is not empty, done_with_collection must be false #Collected Experience Data @@ -333,3 +330,28 @@ async def _internal_execute(self, You must complete the entire JSON object including the closing brace }}. """ + + +def _find_incomplete_required_for_work_type( + collected_data: list[CollectedData], + exploring_type: WorkType | None, +) -> list[tuple[int, CollectedData, list[str]]]: + """ + Find experiences of the given work type that lack required fields (experience_title, work_type). + Used for early-exit: if any have missing required fields, we must CONTINUE to gather them. + Optional fields (start_date, end_date, company, location) are NOT checked here. + """ + if exploring_type is None: + return [] + key = exploring_type.name + result = [] + for i, exp in enumerate (collected_data): + if exp.work_type and exp.work_type.strip() == key: + missing = [] + if not (exp.experience_title and exp.experience_title.strip()): + missing.append("experience_title") + if not (exp.work_type and exp.work_type.strip()): + missing.append("work_type") + if missing: + result.append((i, exp, missing)) + return result 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 305a35ed2..bcbc4fc7d 100644 --- a/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py +++ b/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py @@ -6,7 +6,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 + _get_experience_type, fill_incomplete_fields_as_declined from app.agent.collect_experiences_agent._dataextraction_llm import _DataExtractionLLM from app.agent.collect_experiences_agent._transition_decision_tool import TransitionDecisionTool, TransitionDecision from app.agent.collect_experiences_agent._types import CollectedData @@ -184,21 +184,35 @@ async def execute(self, user_input: AgentInput, reasoning_text = transition_reasoning.reasoning if transition_reasoning else "No reasoning provided" - if transition_decision == TransitionDecision.END_WORKTYPE and self._state.unexplored_types: - explored_type = self._state.unexplored_types.pop(0) - self._state.explored_types.append(explored_type) - self.logger.info( - "Transition decision: END_WORKTYPE - Explored work type: %s" - "\n - remaining types: %s" - "\n - discovered experiences so far: %s" - "\n - reasoning: %s", - explored_type, - self._state.unexplored_types, - self._state.collected_data, - reasoning_text - ) - - next_exploring_type = self._state.unexplored_types[0] if len(self._state.unexplored_types) > 0 else None + if transition_decision == TransitionDecision.END_WORKTYPE: + did_update = False + # if decision is to end the exploration of the current work type, we update null fields to "" + if exploring_type is not None and exploring_type in self._state.unexplored_types: + fill_incomplete_fields_as_declined( + self._state.collected_data, exploring_type + ) + self._state.unexplored_types.remove(exploring_type) + self._state.explored_types.append(exploring_type) + did_update = True + self.logger.info( + "Transition decision: END_WORKTYPE - Explored work type: %s" + "\n - remaining types: %s" + "\n - discovered experiences so far: %s" + "\n - reasoning: %s", + exploring_type, + self._state.unexplored_types, + self._state.collected_data, + reasoning_text + ) + # exit if no unexplored types left + if not did_update and 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 transition_message: str if next_exploring_type is not None: transition_message = (