| import os |
| import asyncio |
| import re |
| from datetime import datetime, timedelta |
| from langchain_groq import ChatGroq |
| from langchain_openai import ChatOpenAI |
| from langchain_core.messages import SystemMessage, HumanMessage, AIMessage |
| from langchain_community.utilities import GoogleSerperAPIWrapper |
| from src.config import SystemConfig |
| from src.memory import MemoryJournal |
|
|
| |
| try: |
| from google_services import get_gmail, get_calendar, get_daily_briefing, GOOGLE_AVAILABLE |
| except ImportError: |
| GOOGLE_AVAILABLE = False |
| def get_daily_briefing(): |
| return None |
|
|
|
|
| def parse_and_create_event(text): |
| """Parse event details from user text and create calendar event(s). |
| Returns (success, message) tuple. Handles multiple events.""" |
| if not GOOGLE_AVAILABLE: |
| return False, "Calendar not connected." |
| |
| cal = get_calendar() |
| if not cal or not cal.service: |
| return False, "Calendar service unavailable." |
| |
| text_lower = text.lower() |
| today = datetime.now() |
| |
| |
| date_match = None |
| if 'tomorrow' in text_lower: |
| date_match = today + timedelta(days=1) |
| elif re.search(r'(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})', text_lower): |
| m = re.search(r'(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})', text_lower) |
| month_name = m.group(1) |
| day = int(m.group(2)) |
| months = {'january':1,'february':2,'march':3,'april':4,'may':5,'june':6,'july':7,'august':8,'september':9,'october':10,'november':11,'december':12} |
| month = months.get(month_name, today.month) |
| year = today.year if month >= today.month else today.year + 1 |
| date_match = datetime(year, month, day) |
| |
| if not date_match: |
| return False, "I couldn't determine the date. Please specify when (e.g., 'tomorrow' or 'February 3rd')." |
| |
| |
| |
| time_pattern = r'(\d{1,2})(?::(\d{2}))?\s*(a\.?m\.?|p\.?m\.?)?' |
| all_times = re.findall(time_pattern, text_lower) |
| |
| |
| times_with_ampm = [(t[0], t[1], t[2]) for t in all_times if t[2]] |
| |
| if len(times_with_ampm) < 2: |
| return False, "I need both a start and end time with AM/PM (e.g., '9am to 11am')." |
| |
| def parse_time(t): |
| hour = int(t[0]) |
| minute = int(t[1]) if t[1] else 0 |
| ampm = t[2].lower().replace('.', '') if t[2] else 'am' |
| is_pm = 'p' in ampm |
| if is_pm and hour != 12: |
| hour += 12 |
| elif not is_pm and hour == 12: |
| hour = 0 |
| return hour, minute |
| |
| |
| has_multiple = any(word in text_lower for word in ['first', 'second', 'two', '2 appointments', 'both']) |
| |
| created_events = [] |
| |
| if has_multiple and len(times_with_ampm) >= 4: |
| |
| |
| |
| |
| |
| titles = [] |
| if 'haircut' in text_lower: |
| titles.append('Haircut and Color' if 'color' in text_lower else 'Haircut') |
| if 'pedicure' in text_lower: |
| titles.append('Pedicure') |
| if 'manicure' in text_lower: |
| titles.append('Manicure') |
| |
| |
| while len(titles) < 2: |
| titles.append('Appointment') |
| |
| for i in range(2): |
| start_h, start_m = parse_time(times_with_ampm[i*2]) |
| end_h, end_m = parse_time(times_with_ampm[i*2 + 1]) |
| |
| start_time = date_match.replace(hour=start_h, minute=start_m, second=0, microsecond=0) |
| end_time = date_match.replace(hour=end_h, minute=end_m, second=0, microsecond=0) |
| |
| result = cal.create_event( |
| summary=titles[i], |
| start_time=start_time, |
| end_time=end_time |
| ) |
| if result: |
| created_events.append(f"'{titles[i]}' from {start_time.strftime('%I:%M %p').lstrip('0')} to {end_time.strftime('%I:%M %p').lstrip('0')}") |
| else: |
| |
| start_h, start_m = parse_time(times_with_ampm[0]) |
| end_h, end_m = parse_time(times_with_ampm[1]) |
| |
| start_time = date_match.replace(hour=start_h, minute=start_m, second=0, microsecond=0) |
| end_time = date_match.replace(hour=end_h, minute=end_m, second=0, microsecond=0) |
| |
| |
| title = None |
| if 'haircut' in text_lower: |
| title = 'Haircut and Color' if 'color' in text_lower else 'Haircut' |
| elif 'pedicure' in text_lower: |
| title = 'Pedicure' |
| elif 'dentist' in text_lower: |
| title = 'Dentist Appointment' |
| elif 'doctor' in text_lower: |
| title = 'Doctor Appointment' |
| elif 'meeting' in text_lower: |
| title = 'Meeting' |
| else: |
| |
| m = re.search(r'(?:for|is)\s+(?:a\s+)?(.+?)(?:\s+appointment|\s+on|\s+at|\s+from|$)', text_lower) |
| if m: |
| title = m.group(1).strip().title() |
| else: |
| title = 'Appointment' |
| |
| result = cal.create_event( |
| summary=title, |
| start_time=start_time, |
| end_time=end_time |
| ) |
| if result: |
| created_events.append(f"'{title}' from {start_time.strftime('%I:%M %p').lstrip('0')} to {end_time.strftime('%I:%M %p').lstrip('0')}") |
| |
| if created_events: |
| date_str = date_match.strftime('%B %d') |
| if len(created_events) == 1: |
| return True, f"I've added {created_events[0]} to your calendar on {date_str}." |
| else: |
| return True, f"I've added {len(created_events)} events to your calendar on {date_str}: {' and '.join(created_events)}." |
| else: |
| return False, "I wasn't able to add the event. Please try again." |
|
|
|
|
| def parse_and_create_birthday(text): |
| """Parse birthday/anniversary info and create recurring all-day event. |
| Returns (success, message) tuple.""" |
| if not GOOGLE_AVAILABLE: |
| return False, "Calendar not connected." |
| |
| cal = get_calendar() |
| if not cal or not cal.service: |
| return False, "Calendar service unavailable." |
| |
| text_lower = text.lower() |
| today = datetime.now() |
| |
| |
| name = None |
| name_patterns = [ |
| r"(?:my\s+)?(\w+(?:'s)?)\s+birthday", |
| r"birthday\s+(?:for\s+)?(?:my\s+)?(\w+)", |
| r"(\w+)'s\s+birthday", |
| ] |
| for pattern in name_patterns: |
| m = re.search(pattern, text_lower) |
| if m: |
| name = m.group(1).replace("'s", "").strip().title() |
| break |
| |
| if not name: |
| name = "Birthday" |
| |
| |
| date_match = None |
| month_pattern = r'(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})' |
| m = re.search(month_pattern, text_lower) |
| if m: |
| month_name = m.group(1) |
| day = int(m.group(2)) |
| months = {'january':1,'february':2,'march':3,'april':4,'may':5,'june':6,'july':7,'august':8,'september':9,'october':10,'november':11,'december':12} |
| month = months.get(month_name, today.month) |
| |
| year = today.year |
| test_date = datetime(year, month, day) |
| if test_date < today: |
| year += 1 |
| date_match = datetime(year, month, day) |
| |
| if not date_match: |
| return False, "I need the date for the birthday (e.g., 'January 30th')." |
| |
| |
| recurrence = None |
| if any(word in text_lower for word in ['annual', 'annually', 'every year', 'yearly', 'recurring', 'repeat']): |
| recurrence = 'yearly' |
| |
| |
| title = f"{name}'s Birthday" if name != "Birthday" else "Birthday" |
| |
| result = cal.create_all_day_event( |
| summary=title, |
| date=date_match, |
| recurrence=recurrence |
| ) |
| |
| if result: |
| recur_text = " (recurring annually)" if recurrence else "" |
| return True, f"I've added '{title}' to your calendar on {date_match.strftime('%B %d')}{recur_text}." |
| else: |
| return False, "I wasn't able to add the birthday. Please try again." |
|
|
|
|
| def parse_and_update_event(text): |
| """Parse update request and modify existing event. |
| Returns (success, message) tuple.""" |
| if not GOOGLE_AVAILABLE: |
| return False, "Calendar not connected." |
| |
| cal = get_calendar() |
| if not cal or not cal.service: |
| return False, "Calendar service unavailable." |
| |
| text_lower = text.lower() |
| |
| |
| |
| event_keywords = [] |
| specific_terms = ['haircut', 'color', 'pedicure', 'manicure', 'dentist', 'doctor', |
| 'lunch', 'dinner', 'breakfast', 'class', 'lesson'] |
| for term in specific_terms: |
| if term in text_lower: |
| event_keywords.append(term) |
| |
| |
| events = [] |
| if event_keywords: |
| for keyword in event_keywords: |
| found = cal.find_events_by_name(keyword, days_ahead=60) |
| for e in found: |
| if e not in events: |
| events.append(e) |
| |
| |
| if not events: |
| m = re.search(r'(?:the|my)\s+(\w+(?:\s+and\s+\w+)?)\s+(?:appointment|event|meeting)', text_lower) |
| if m: |
| search_term = m.group(1) |
| events = cal.find_events_by_name(search_term, days_ahead=60) |
| |
| if not events: |
| return False, "I couldn't find a matching event in the next 60 days. Please specify the event name." |
| |
| |
| event = events[0] |
| event_id = event['id'] |
| |
| |
| updates = {} |
| |
| |
| location_patterns = [ |
| r'location\s+(?:is|to|:)?\s*(.+?)(?:\.|$)', |
| r'(?:add|set|change|update)\s+(?:the\s+)?location\s+(?:to\s+)?(.+?)(?:\.|$)', |
| r'it\'?s?\s+(?:at|located at)\s+(.+?)(?:\.|$)', |
| r'at\s+(.+?)(?:\s+and\s+|\.|$)', |
| ] |
| for pattern in location_patterns: |
| m = re.search(pattern, text_lower) |
| if m: |
| location = m.group(1).strip() |
| |
| location = ' '.join(word.capitalize() for word in location.split()) |
| updates['location'] = location |
| break |
| |
| if not updates: |
| return False, "I couldn't determine what to update. You can say things like 'add the location' or 'change the time'." |
| |
| result = cal.update_event(event_id, **updates) |
| |
| if result: |
| update_desc = [] |
| if 'location' in updates: |
| update_desc.append(f"location to '{updates['location']}'") |
| return True, f"I've updated '{event['summary']}' - set the {', '.join(update_desc)}." |
| else: |
| return False, "I wasn't able to update the event. Please try again." |
|
|
|
|
| class KitchenBrain: |
| def __init__(self): |
| self.cfg = SystemConfig() |
| |
| |
| self.primary_llm = ChatGroq( |
| model="llama-3.3-70b-versatile", |
| api_key=self.cfg.groq_key, |
| streaming=True |
| ) |
| |
| |
| if self.cfg.openai_api_key: |
| self.backup_llm = ChatOpenAI( |
| model="gpt-4o-mini", |
| api_key=self.cfg.openai_api_key, |
| streaming=True |
| ) |
| else: |
| self.backup_llm = None |
|
|
| self.memory = MemoryJournal() |
| |
| if self.cfg.serper_key: |
| self.search = GoogleSerperAPIWrapper(serper_api_key=self.cfg.serper_key) |
| else: |
| self.search = None |
|
|
| async def route_and_process(self, user_input): |
| self.memory.save_interaction("user", user_input, "👤") |
| text = user_input.lower() |
| |
| |
| |
| |
| |
| brie_triggers = [ |
| 'recipe', |
| 'ingredients for', |
| 'instructions for', |
| 'how do i cook', |
| 'how do i make', |
| 'how to cook', |
| 'how to make', |
| 'shopping list' |
| ] |
| |
| |
| is_requesting_chef = any(trigger in text for trigger in brie_triggers) |
|
|
| persona = "Olivia" |
| handoff_msg = "" |
|
|
| if is_requesting_chef: |
| persona = "Brie" |
| handoff_msg = self.get_handoff_message(user_input) |
| generator = self.stream_brie(user_input) |
| else: |
| |
| |
| generator = self.stream_olivia(user_input) |
|
|
| return persona, handoff_msg, generator |
|
|
| def get_handoff_message(self, text): |
| return "That sounds delicious. I'll ask Brie to handle the culinary details." |
|
|
| async def _safe_stream(self, messages): |
| try: |
| async for chunk in self.primary_llm.astream(messages): |
| yield chunk.content |
| return |
| except Exception as e: |
| print(f"⚠️ Primary Brain Failed: {e}") |
| |
| if self.backup_llm: |
| try: |
| print("🔄 Switching to Backup Brain (OpenAI)...") |
| async for chunk in self.backup_llm.astream(messages): |
| yield chunk.content |
| return |
| except Exception as e: |
| print(f"⚠️ Backup Brain Failed: {e}") |
| |
| yield "I'm having trouble connecting to my networks right now. Please try again in a moment." |
|
|
| async def stream_olivia(self, text): |
| now = datetime.now().strftime("%A, %B %d, %Y at %I:%M %p") |
| past_context = self.memory.get_context_string(limit=10) |
| |
| search_data = "" |
| triggers = ['weather', 'news', 'score', 'price', 'who is', 'what is', 'when is', 'location', 'find', 'near me'] |
| skip = ['sad', 'happy', 'tired', 'love', 'hate', 'joke'] |
| |
| if self.search and any(t in text.lower() for t in triggers) and not any(s in text.lower() for s in skip): |
| try: |
| query = f"{text} in {self.cfg.location} ({now})" |
| res = self.search.run(query) |
| search_data = f"\n[REAL-TIME INFO]: {res}" |
| except: pass |
| |
| |
| google_context = "" |
| calendar_action_result = "" |
| email_triggers = ['email', 'gmail', 'inbox', 'messages', 'unread', 'mail'] |
| calendar_triggers = ['calendar', 'schedule', 'agenda', 'appointment', 'meeting', 'today', 'tomorrow', 'plans', 'briefing', 'morning briefing', 'daily briefing'] |
| calendar_add_triggers = ['add', 'schedule', 'put', 'create', 'set up', 'book'] |
| |
| if GOOGLE_AVAILABLE: |
| |
| text_lower = text.lower() |
| has_time = bool(re.search(r'\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)', text_lower)) |
| wants_to_add = any(t in text_lower for t in calendar_add_triggers) and has_time |
| |
| |
| is_birthday = 'birthday' in text_lower or 'anniversary' in text_lower |
| has_date = bool(re.search(r'(january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2}', text_lower)) |
| wants_birthday = is_birthday and has_date and any(t in text_lower for t in calendar_add_triggers) |
| |
| |
| update_triggers = ['update', 'edit', 'change', 'add the location', 'set the location', 'modify', 'add location'] |
| wants_to_update = any(t in text_lower for t in update_triggers) |
| |
| if wants_birthday: |
| |
| success, message = parse_and_create_birthday(text) |
| if success: |
| calendar_action_result = f"\n[CALENDAR ACTION COMPLETED]: {message}" |
| else: |
| calendar_action_result = f"\n[CALENDAR ACTION NEEDED]: {message}" |
| elif wants_to_update: |
| |
| success, message = parse_and_update_event(text) |
| if success: |
| calendar_action_result = f"\n[CALENDAR ACTION COMPLETED]: {message}" |
| else: |
| calendar_action_result = f"\n[CALENDAR ACTION NEEDED]: {message}" |
| elif wants_to_add: |
| success, message = parse_and_create_event(text) |
| if success: |
| calendar_action_result = f"\n[CALENDAR ACTION COMPLETED]: {message}" |
| else: |
| calendar_action_result = f"\n[CALENDAR ACTION NEEDED]: {message}" |
| |
| if any(t in text.lower() for t in email_triggers): |
| try: |
| gmail = get_gmail() |
| if gmail and gmail.service: |
| google_context += f"\n[EMAIL STATUS]: {gmail.get_email_summary()}" |
| except Exception as e: |
| print(f"⚠️ Gmail error: {e}") |
| |
| if any(t in text.lower() for t in calendar_triggers): |
| try: |
| cal = get_calendar() |
| if cal and cal.service: |
| |
| if 'tomorrow' in text.lower(): |
| google_context += f"\n[CALENDAR]: {cal.get_tomorrow_summary()}" |
| else: |
| google_context += f"\n[CALENDAR]: {cal.get_schedule_summary()}" |
| except Exception as e: |
| print(f"⚠️ Calendar error: {e}") |
| |
| |
| if any(phrase in text.lower() for phrase in ['briefing', 'morning update', 'daily update', "what's on"]): |
| try: |
| briefing = get_daily_briefing() |
| if briefing: |
| google_context = f"\n[DAILY BRIEFING]: {briefing}" |
| except Exception as e: |
| print(f"⚠️ Briefing error: {e}") |
|
|
| |
| sys_prompt = f"""You are Olivia, a sophisticated Household Companion. |
| Time: {now}. Location: {self.cfg.location}. |
| User Name: {self.cfg.user_name}. |
| |
| MEMORY: {past_context} |
| CONTEXT: {search_data}{google_context}{calendar_action_result} |
| |
| GUIDANCE: |
| - You are the Manager. You handle chat, scheduling, and life updates. |
| - If the user talks about food (e.g., "I'm making dinner"), be supportive and conversational. |
| - DO NOT generate full recipes yourself. |
| - If the user explicitly asks for a recipe, you can suggest asking Brie. |
| - When you have calendar or email info, share it naturally and helpfully. |
| - Be warm, professional, and concise. |
| |
| IMPORTANT - Calendar/Email Capabilities: |
| - You CAN read calendar events and emails. |
| - You CAN add calendar events with times (appointments, meetings). |
| - You CAN add all-day events like birthdays and anniversaries. |
| - You CAN make events recurring (annually for birthdays, weekly for meetings, etc.). |
| - You CAN update existing events (add location, change details). |
| - If you see [CALENDAR ACTION COMPLETED] in CONTEXT, the action was successful! Confirm warmly but DO NOT say "[CALENDAR ACTION COMPLETED]" - that's an internal system message. |
| - If you see [CALENDAR ACTION NEEDED], ask the user for the missing information mentioned. |
| - You CANNOT send emails yet. |
| - Never include system tags like [CALENDAR ACTION COMPLETED] or [CALENDAR] in your spoken response. |
| - Always check the calendar info provided in CONTEXT before responding about schedule. |
| - Never include system tags like [CALENDAR ACTION COMPLETED] or [CALENDAR] in your spoken response.""" |
| |
| msgs = [SystemMessage(content=sys_prompt), HumanMessage(content=text)] |
| |
| async for chunk in self._safe_stream(msgs): |
| yield chunk |
|
|
| async def stream_brie(self, text): |
| prompt = """You are Brie, an elite private chef and cooking companion. You are warm, encouraging, and love helping people cook! |
| |
| STRICT OUTPUT FORMAT - Follow this exactly: |
| |
| **[Recipe Name]** |
| |
| **Ingredients:** |
| - [ingredient 1] |
| - [ingredient 2] |
| - [ingredient 3] |
| (list all ingredients as bullet points) |
| |
| **Instructions:** |
| 1. [First step] |
| 2. [Second step] |
| 3. [Third step] |
| (number all steps clearly) |
| |
| **Chef's Note:** |
| [One helpful tip or variation suggestion] |
| |
| IMPORTANT RULES: |
| - Always use bullet points (-) for ingredients |
| - Always use numbers (1. 2. 3.) for instructions |
| - Keep instructions clear and concise |
| - Be encouraging and friendly in your Chef's Note |
| - Do NOT add extra sections or commentary outside this format""" |
| |
| msgs = [SystemMessage(content=prompt), HumanMessage(content=text)] |
| |
| async for chunk in self._safe_stream(msgs): |
| yield chunk |
|
|