""" Conversation state management: user profile extraction and tracking. Adopted from the chatbot-decoupling branch: this logic previously lived inside the ExecutiveAgentChain class; extracting it keeps the chain a thin orchestrator. Latency-neutral — pure regex/keyword extraction, no LLM calls. """ import json import os import re from datetime import datetime from langchain_core.messages import AIMessage from src.config import config from src.utils.logging import get_logger logger = get_logger('conversation_state') class ConversationStateManager: """Extracts and tracks user profile data from conversation turns. Holds a reference to the owning chain and operates on its `_conversation_state` dict and `_conversation_history` list. """ def __init__(self, chain) -> None: self._chain = chain # ------------------------------ public API ------------------------------ def update(self, user_query: str, agent_response: str) -> None: """Update conversation state by extracting information from the turn.""" if not config.convstate.TRACK_USER_PROFILE: return state = self._chain._conversation_state # Extract profile information only from the user's own text. Assistant # programme descriptions must not become inferred user interests. profile_text = user_query if not state.get('experience_years'): exp_years = self._extract_experience_years(profile_text) if exp_years: state['experience_years'] = exp_years logger.info(f"Extracted experience years: {exp_years}") if not state.get('leadership_years'): lead_years = self._extract_leadership_years(profile_text) if lead_years: state['leadership_years'] = lead_years logger.info(f"Extracted leadership years: {lead_years}") if not state.get('field'): field = self._extract_field(profile_text) if field: state['field'] = field logger.info(f"Extracted field: {field}") if not state.get('interest'): interest = self._extract_interest(profile_text) if interest: state['interest'] = interest logger.info(f"Extracted interest: {interest}") if not state.get('user_name'): name = self._extract_name(profile_text) if name: state['user_name'] = name logger.info(f"Extracted name: {name}") # Detect handover request from the user only; assistant soft offers # should not count. if self._detect_handover_request(user_query): state['handover_requested'] = True logger.info("Handover request detected") # Check for programme mentions. Match the most specific names first so # "emba X" is not misclassified as the generic EMBA HSG. user_programmes = self._chain._extract_programmes_from_text(user_query) for program in user_programmes: if program not in state['program_interest']: state['program_interest'].append(program) if len(user_programmes) == 1: state['suggested_program'] = user_programmes[0] logger.info(f"Suggested program updated from user selection: {user_programmes[0]}") suggested = self._determine_suggested_program() if suggested and not state.get('suggested_program'): state['suggested_program'] = suggested logger.info(f"Suggested program: {suggested}") def log_user_profile(self) -> None: """Log user profile to a JSON file.""" if not config.convstate.TRACK_USER_PROFILE: return state = self._chain._conversation_state try: log_dir = os.path.join('logs', 'user_profiles') os.makedirs(log_dir, exist_ok=True) profile_data = { 'session_id': state['session_id'], 'user_id': state['user_id'], 'name': state.get('user_name'), 'timestamp': datetime.now().isoformat(), 'experience_years': state.get('experience_years'), 'leadership_years': state.get('leadership_years'), 'field': state.get('field'), 'interest': state.get('interest'), 'suggested_program': state.get('suggested_program'), 'handover': state.get('handover_requested'), 'user_language': state.get('user_language'), 'program_interest': state.get('program_interest', []), } timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') log_file = os.path.join(log_dir, f'profile_{state["user_id"]}_{timestamp}.json') with open(log_file, 'w', encoding='utf-8') as f: json.dump(profile_data, f, indent=2, ensure_ascii=False) logger.info(f"User profile logged to {log_file}") except Exception as e: logger.error(f"Failed to log user profile: {e}") # ----------------------------- derivations ------------------------------ def _determine_suggested_program(self) -> str | None: """Determine recommended programme based on the user profile.""" state = self._chain._conversation_state # If programme interest was explicitly mentioned if state['program_interest']: return self._chain._normalise_programme_id(state['program_interest'][0]) if state.get('interest') and any( kw in state.get('interest', '').lower() for kw in ['digital', 'digitalisierung', 'innovation', 'technology', 'technologie'] ): return 'emba_x' # Do not infer programme fit from years of experience in code. Current # eligibility thresholds live in the scraped programme source. return None def previous_response_offered_booking(self) -> bool: """Return True if the latest assistant turn offered booking as a next step.""" booking_offer_terms = [ "appointment slots", "book an appointment", "book a consultation", "appointment booking", "show you available appointments", "show appointment options", "terminbuchung", "termin buchen", "termine anzeigen", "verfügbare termine", "beratungstermin", ] for message in reversed(self._chain._conversation_history): if not isinstance(message, AIMessage): continue content = getattr(message, "content", "") or getattr(message, "text", "") if isinstance(content, list): content = " ".join(str(part) for part in content) content_lower = str(content).lower() return any(term in content_lower for term in booking_offer_terms) return False # ------------------------- pure text extraction ------------------------- @staticmethod def _extract_experience_years(conversation: str) -> int | None: """Extract years of professional experience from conversation text.""" patterns = [ r'(\d+)\s*years?\s*(?:of\s*)?(?:experience|work)', r'(\d+)\s*years?\s*in\s*(?:the\s*)?(?:field|industry)', r'working\s*for\s*(\d+)\s*years?', r'(\d+)\s*Jahre\s*(?:Erfahrung|Berufserfahrung)', # German ] for pattern in patterns: match = re.search(pattern, conversation, re.IGNORECASE) if match: return int(match.group(1)) return None @staticmethod def _extract_leadership_years(conversation: str) -> int | None: """Extract years of leadership experience from conversation text.""" patterns = [ r'(\d+)\s*years?\s*(?:of\s*)?(?:leadership|management|managing)', r'(?:lead|led|manage|managed)\s*(?:for\s*)?(\d+)\s*years?', r'(\d+)\s*Jahre\s*(?:Führungserfahrung|Führung)', # German ] for pattern in patterns: match = re.search(pattern, conversation, re.IGNORECASE) if match: return int(match.group(1)) return None @staticmethod def _extract_field(conversation: str) -> str | None: """Extract professional field/industry from conversation text.""" fields = [ 'finance', 'banking', 'technology', 'tech', 'IT', 'healthcare', 'consulting', 'manufacturing', 'retail', 'marketing', 'sales', 'engineering', 'pharma', 'telecommunications', 'energy', 'Finanzwesen', 'Technologie', 'Gesundheitswesen', 'Beratung' # German ] conversation_lower = conversation.lower() for field in fields: if field.lower() in conversation_lower: return field.capitalize() return None @staticmethod def _extract_interest(conversation: str) -> str | None: """Extract content interests from conversation text.""" interests = [ 'strategy', 'innovation', 'leadership', 'digital transformation', 'finance', 'operations', 'marketing', 'entrepreneurship', 'social impact', 'technology', 'management', 'Strategie', 'Innovation', 'Führung', 'Digitalisierung' # German ] conversation_lower = conversation.lower() found_interests = [interest for interest in interests if interest.lower() in conversation_lower] return ', '.join(found_interests) if found_interests else None @staticmethod def _extract_name(conversation: str) -> str | None: """Extract user's name from conversation text.""" patterns = [ r"(?:my name is|i'm|i am|call me)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)", r"(?:this is|it's)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)", r"(?:ich heiße|mein Name ist|ich bin)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)", # German ] for pattern in patterns: match = re.search(pattern, conversation, re.IGNORECASE) if match: name = match.group(1).strip() excluded = ['interested', 'looking', 'working', 'searching', 'asking'] if name.lower() not in excluded: return name return None @staticmethod def _detect_handover_request(conversation: str) -> bool: """Detect if the user requested an appointment, callback, or contact.""" handover_keywords = [ 'appointment', 'call me', 'contact me', 'schedule', 'meeting', 'callback', 'reach out', 'follow up', 'get in touch', 'speak with', 'talk to', 'consultation', 'discuss with', 'meet with', 'Termin', 'Rückruf', 'kontaktieren', 'Gespräch', 'anrufen', # German 'zurückrufen', 'Beratung', 'treffen' ] conversation_lower = conversation.lower() return any(keyword.lower() in conversation_lower for keyword in handover_keywords)