From bd7d3d17b8e3b8e9f90b28b436d24414c3015838 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Tue, 26 Aug 2025 16:44:23 -0700 Subject: [PATCH 01/10] first version of cartesia line form fill integration --- .../cartesia/.cartesia/config.toml | 1 + examples/integrations/cartesia/.env.example | 11 + examples/integrations/cartesia/.gitignore | 39 ++ examples/integrations/cartesia/README.md | 111 +++++ examples/integrations/cartesia/__init__.py | 0 examples/integrations/cartesia/cartesia.toml | 8 + examples/integrations/cartesia/config.py | 26 + examples/integrations/cartesia/config.toml | 1 + .../cartesia/form_filling_node.py | 449 ++++++++++++++++++ examples/integrations/cartesia/main.py | 70 +++ .../integrations/cartesia/requirements.txt | 8 + .../cartesia/stagehand_form_filler.py | 418 ++++++++++++++++ 12 files changed, 1142 insertions(+) create mode 100644 examples/integrations/cartesia/.cartesia/config.toml create mode 100644 examples/integrations/cartesia/.env.example create mode 100644 examples/integrations/cartesia/.gitignore create mode 100644 examples/integrations/cartesia/README.md create mode 100644 examples/integrations/cartesia/__init__.py create mode 100644 examples/integrations/cartesia/cartesia.toml create mode 100644 examples/integrations/cartesia/config.py create mode 100644 examples/integrations/cartesia/config.toml create mode 100644 examples/integrations/cartesia/form_filling_node.py create mode 100644 examples/integrations/cartesia/main.py create mode 100644 examples/integrations/cartesia/requirements.txt create mode 100644 examples/integrations/cartesia/stagehand_form_filler.py diff --git a/examples/integrations/cartesia/.cartesia/config.toml b/examples/integrations/cartesia/.cartesia/config.toml new file mode 100644 index 0000000..4251232 --- /dev/null +++ b/examples/integrations/cartesia/.cartesia/config.toml @@ -0,0 +1 @@ +agent-id = 'agent_NKsQKSxugbsoA3ByZrJVQY' diff --git a/examples/integrations/cartesia/.env.example b/examples/integrations/cartesia/.env.example new file mode 100644 index 0000000..9777dfb --- /dev/null +++ b/examples/integrations/cartesia/.env.example @@ -0,0 +1,11 @@ +# Gemini API Key for language model +GEMINI_API_KEY=your_gemini_api_key_here + +# Optional: Browserbase API credentials for cloud browser automation +# If not set, will use local browser +BROWSERBASE_API_KEY=your_browserbase_api_key_here +BROWSERBASE_PROJECT_ID=your_browserbase_project_id_here + +# Optional: Model configuration +MODEL_NAME=google/gemini-2.0-flash-exp +MODEL_API_KEY=your_model_api_key_here \ No newline at end of file diff --git a/examples/integrations/cartesia/.gitignore b/examples/integrations/cartesia/.gitignore new file mode 100644 index 0000000..e87426f --- /dev/null +++ b/examples/integrations/cartesia/.gitignore @@ -0,0 +1,39 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*.pyd +.Python + +# Virtual environments +.env +.venv/ +venv/ +env/ + +virtualenv/ + +# Conda environments +conda-env/ +envs/ +.conda/ +conda-meta/ + +# uv environments (in addition to uv.lock at top) +uv.lock +.python-version + +# Python package managers +poetry.lock +Pipfile.lock +pip-log.txt + +# pyenv +.pyenv/ + +# Distribution / packaging +*.egg-info/ +dist/ +build/ + +# Editor / OS files +.DS_Store diff --git a/examples/integrations/cartesia/README.md b/examples/integrations/cartesia/README.md new file mode 100644 index 0000000..b5dbd48 --- /dev/null +++ b/examples/integrations/cartesia/README.md @@ -0,0 +1,111 @@ +# Voice Agent with Real-time Web Form Filling + +This project demonstrates an advanced voice agent that conducts phone questionnaires while automatically filling out web forms in real-time using Stagehand browser automation. + +## Features + +- **Voice Conversations**: Natural voice interactions using Cartesia Line +- **Real-time Form Filling**: Automatically fills web forms as answers are collected +- **Browser Automation**: Uses Stagehand AI to interact with any web form +- **Intelligent Mapping**: AI-powered mapping of voice answers to form fields +- **Async Processing**: Non-blocking form filling maintains conversation flow +- **Auto-submission**: Submits forms automatically when complete + +## Architecture + +``` +Voice Call (Cartesia) → Form Filling Node → Records Answer + ↓ + Stagehand Browser API + ↓ + Fills Web Form Field + ↓ + Continues Conversation + ↓ + Submits Form on Completion +``` + +## Setup + +1. Install dependencies: +```bash +pip install -r requirements.txt +``` + +2. Set up environment variables: +```bash +cp .env.example .env +# Add your GEMINI_API_KEY +``` + +3. Run the agent: +```bash +python main.py +``` + +## Components + +### StagehandFormFiller +- Manages browser automation +- Opens and controls web forms +- Maps conversation data to form fields +- Handles form submission + +### FormFillingNode +- Voice-optimized reasoning node +- Integrates Stagehand browser automation +- Manages async form filling during conversation +- Provides status updates + +### FormFieldMapping +- Maps YAML questions to web form fields +- Transforms voice answers to form-compatible formats +- Handles different field types (text, select, checkbox, etc.) + +## Configuration + +The system can be configured through: + +- `form.yaml`: Define questionnaire structure +- `FORM_URL`: Target web form to fill +- `headless`: Run browser in background (True) or visible (False) +- `enable_browser`: Toggle browser automation on/off + +## Example Flow + +1. User calls the voice agent +2. Agent asks: "What type of voice agent are you building?" +3. User responds: "A customer service agent" +4. System: + - Records the answer + - Opens browser to form (if not already open) + - Fills "Customer Service" in the role selection field + - Takes screenshot for debugging +5. Agent asks next question +6. Process continues until all questions answered +7. Form is automatically submitted + +## Advanced Features + +- **Background Processing**: Form filling happens asynchronously +- **Error Recovery**: Continues conversation even if form filling fails +- **Progress Tracking**: Monitor form completion status +- **Screenshot Debugging**: Captures screenshots after each field +- **Flexible Mapping**: AI interprets answers for different field types + +## Testing + +Test with different scenarios: +- Complete questionnaire flow +- Interruptions and corrections +- Various answer formats +- Multi-page forms +- Form validation errors + +## Production Considerations + +- Set `headless=True` for production +- Configure proper error logging +- Add retry logic for form submission +- Implement form validation checks +- Consider rate limiting for API calls \ No newline at end of file diff --git a/examples/integrations/cartesia/__init__.py b/examples/integrations/cartesia/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/integrations/cartesia/cartesia.toml b/examples/integrations/cartesia/cartesia.toml new file mode 100644 index 0000000..afe77c2 --- /dev/null +++ b/examples/integrations/cartesia/cartesia.toml @@ -0,0 +1,8 @@ +[app] +name = "form-filling" + +[build] +cmd = "echo 'No build cmd specified'" + +[run] +cmd = "echo 'No run cmd specified'" diff --git a/examples/integrations/cartesia/config.py b/examples/integrations/cartesia/config.py new file mode 100644 index 0000000..a82700c --- /dev/null +++ b/examples/integrations/cartesia/config.py @@ -0,0 +1,26 @@ +import os + +DEFAULT_MODEL_ID = os.getenv("MODEL_ID", "gemini-2.5-flash") + +DEFAULT_TEMPERATURE = 0.7 +SYSTEM_PROMPT = """ +### You and your role +You are a friendly assistant conducting a questionnaire. +Be professional but conversational. Confirm answers when appropriate. +If a user's answer is unclear, ask for clarification. +For sensitive information, be especially tactful and professional. + +IMPORTANT: When you receive a clear answer from the user, use the record_answer tool to record their response. + +### Your tone +When having a conversation, you should: +- Always polite and respectful, even when users are challenging +- Concise and brief but never curt. Keep your responses to 1-2 sentences and less than 35 words +- When asking a question, be sure to ask in a short and concise manner +- Only ask one question at a time + +If the user is rude, or curses, respond with exceptional politeness and genuine curiosity. +You should always be polite. + +Remember, you're on the phone, so do not use emojis or abbreviations. Spell out units and dates. +""" diff --git a/examples/integrations/cartesia/config.toml b/examples/integrations/cartesia/config.toml new file mode 100644 index 0000000..4251232 --- /dev/null +++ b/examples/integrations/cartesia/config.toml @@ -0,0 +1 @@ +agent-id = 'agent_NKsQKSxugbsoA3ByZrJVQY' diff --git a/examples/integrations/cartesia/form_filling_node.py b/examples/integrations/cartesia/form_filling_node.py new file mode 100644 index 0000000..b5e2621 --- /dev/null +++ b/examples/integrations/cartesia/form_filling_node.py @@ -0,0 +1,449 @@ +""" +FormFillingNode - Voice agent that fills web forms in real-time using Stagehand +""" + +import asyncio +from typing import AsyncGenerator, Union, Optional, Dict, Any +from dataclasses import dataclass + +from config import DEFAULT_MODEL_ID, DEFAULT_TEMPERATURE +from stagehand_form_filler import StagehandFormFiller +from google.genai import types as gemini_types +from loguru import logger +from pydantic import BaseModel, Field + +from line.events import AgentResponse, EndCall, ToolResult +from line.nodes.conversation_context import ConversationContext +from line.nodes.reasoning import ReasoningNode +from line.tools.system_tools import EndCallArgs, end_call +from line.utils.gemini_utils import convert_messages_to_gemini + + +class RecordFormFieldArgs(BaseModel): + """Arguments for recording a form field""" + field_name: str = Field(description="The form field being filled") + value: str = Field(description="The value to enter in the field") + + +class RecordFormFieldTool: + """Tool for recording form field values""" + + @staticmethod + def name() -> str: + return "record_form_field" + + @staticmethod + def description() -> str: + return "Record a value for a form field that needs to be filled" + + @staticmethod + def parameters() -> dict: + return RecordFormFieldArgs.model_json_schema() + + @staticmethod + def to_gemini_tool(): + """Convert to Gemini tool format""" + return gemini_types.Tool( + function_declarations=[ + gemini_types.FunctionDeclaration( + name=RecordFormFieldTool.name(), + description=RecordFormFieldTool.description(), + parameters=RecordFormFieldTool.parameters(), + ) + ] + ) + + +@dataclass +class FormQuestion: + """Represents a question to ask the user""" + field_name: str + question: str + field_type: str = "text" + required: bool = True + + +class FormFillingNode(ReasoningNode): + """ + Voice agent that fills actual web forms while conducting conversations. + - Uses Stagehand to read and fill web forms dynamically + - Maintains conversation flow while automating browser actions + - Intelligently extracts form structure and asks relevant questions + """ + + def __init__( + self, + system_prompt: str, + gemini_client, + form_url: str, + model_id: str = DEFAULT_MODEL_ID, + temperature: float = DEFAULT_TEMPERATURE, + max_context_length: int = 15, + max_output_tokens: int = 1000, + headless: bool = False, + ): + """ + Initialize the Form Filling node with Stagehand integration + + Args: + system_prompt: System prompt for the LLM + gemini_client: Google Gemini client instance + form_url: URL of the web form to fill + model_id: Gemini model ID + temperature: Temperature for generation + max_context_length: Maximum conversation context length + headless: Run browser in headless mode + """ + super().__init__(system_prompt=system_prompt, max_context_length=max_context_length) + + self.client = gemini_client + self.model_id = model_id + self.temperature = temperature + + # Browser automation + self.form_url = form_url + self.headless = headless + self.stagehand_filler: Optional[StagehandFormFiller] = None + + # Form state + self.form_fields: Dict[str, Any] = {} + self.collected_data: Dict[str, str] = {} + self.questions: list[FormQuestion] = [] + self.current_question_index = 0 + + # Browser initialization + self.browser_init_task = None + + # Enhanced prompt for form filling + enhanced_prompt = system_prompt + """ + + You are conducting a voice conversation to help fill out a web form. + As you collect information, it's being entered into an actual online form in real-time. + Ask natural questions to gather the required information. + Use the record_form_field tool to save each piece of information. + Keep the conversation friendly and natural. + """ + + # Generation config + self.generation_config = gemini_types.GenerateContentConfig( + system_instruction=enhanced_prompt, + temperature=self.temperature, + tools=[RecordFormFieldTool.to_gemini_tool()], + max_output_tokens=max_output_tokens, + thinking_config=gemini_types.ThinkingConfig(thinking_budget=0), + ) + + logger.info(f"🚀 FormFillingNode initialized for form: {form_url}") + + async def _initialize_browser(self): + """Initialize browser and extract form fields""" + try: + logger.info("🌐 Initializing browser and analyzing form") + self.stagehand_filler = StagehandFormFiller( + form_url=self.form_url, + headless=self.headless + ) + await self.stagehand_filler.initialize() + + # Extract form fields using Stagehand (optional for dynamic forms) + # For now, we'll use predefined questions for the known form + if self.stagehand_filler.page: + try: + form_analysis = await self.stagehand_filler.page.extract({ + "form_fields": "list of all form fields with their labels and types", + "required_fields": "list of required field names", + "form_title": "the title or heading of the form", + }) + logger.info(f"📋 Form analysis: {form_analysis}") + self.form_fields = form_analysis + except Exception as e: + logger.warning(f"Could not extract form fields: {e}") + + # Always create questions - don't depend on form extraction + self.questions = self._create_questions_from_analysis({}) + + logger.info(f"✅ Browser ready with {len(self.questions)} questions") + + except Exception as e: + logger.error(f"❌ Failed to initialize browser: {e}") + raise + + def _create_questions_from_analysis(self, form_analysis: Dict[str, Any]) -> list[FormQuestion]: + """Create questions based on form analysis""" + + # Define questions for the form fields we know about + # This matches the form at https://forms.fillout.com/t/34ccsqafUFus + form_questions = [ + FormQuestion( + field_name="full_name", + question="What is your full name?", + field_type="text", + required=True + ), + FormQuestion( + field_name="email", + question="What is your email address?", + field_type="email", + required=True + ), + FormQuestion( + field_name="phone", + question="What is your phone number?", + field_type="phone", + required=False + ), + FormQuestion( + field_name="address", + question="What is your current address? Please include street address, city, state, and zip code.", + field_type="address", + required=True + ), + FormQuestion( + field_name="work_eligibility", + question="Are you legally eligible to work in this country?", + field_type="radio", + required=True + ), + FormQuestion( + field_name="availability_type", + question="What's your availability - temporary, part-time, or full-time?", + field_type="radio", + required=True + ), + FormQuestion( + field_name="role_selection", + question="Which role are you applying for? We have openings for Sales Manager, IT Support, Recruiting, Software Engineer, or Marketing Specialist.", + field_type="checkbox", + required=True + ), + FormQuestion( + field_name="previous_experience", + question="Have you worked in a similar role before?", + field_type="radio", + required=True + ), + FormQuestion( + field_name="skills_experience", + question="What relevant skills and experience do you have that make you a strong candidate for this position?", + field_type="textarea", + required=True + ), + FormQuestion( + field_name="additional_info", + question="Is there anything else you'd like to tell us about yourself?", + field_type="textarea", + required=False + ), + ] + + return form_questions + + async def _fill_form_field(self, field_name: str, value: str): + """Fill a form field in the browser in real-time""" + if not self.stagehand_filler: + logger.warning("⚠️ Browser not initialized yet") + return + + try: + logger.info(f"🖊️ Filling field '{field_name}' with: {value} in real-time") + + # Use StagehandFormFiller's fill_field method which handles the mapping + success = await self.stagehand_filler.fill_field(field_name, value) + + if success: + logger.info(f"✅ Successfully filled field: {field_name} in the browser") + else: + logger.warning(f"⚠️ Failed to fill field: {field_name}") + + except Exception as e: + logger.error(f"Error filling field {field_name}: {e}") + + async def _submit_form(self): + """Submit the completed form""" + if not self.stagehand_filler: + return False + + try: + logger.info("📤 Submitting web form with collected data") + logger.info(f"📊 Data collected: {self.collected_data}") + + # Ensure StagehandFormFiller has all collected data + # (it should already have it from fill_field calls, but ensure consistency) + self.stagehand_filler.collected_data.update(self.collected_data) + + # Use StagehandFormFiller's submit_form method which now uses collected_data + success = await self.stagehand_filler.submit_form() + + if success: + logger.info("✅ Form submitted successfully!") + return True + else: + logger.warning("⚠️ Form submission may have failed") + return False + + except Exception as e: + logger.error(f"Error submitting form: {e}") + return False + + def get_current_question(self) -> Optional[FormQuestion]: + """Get the current question to ask""" + if self.current_question_index < len(self.questions): + return self.questions[self.current_question_index] + return None + + async def process_context( + self, context: ConversationContext + ) -> AsyncGenerator[Union[AgentResponse, EndCall], None]: + """ + Process conversation context with real-time form filling + + Yields: + AgentResponse: Text responses to the user + EndCall: Call termination when form is complete + """ + # Initialize browser on first call + if not self.browser_init_task: + self.browser_init_task = asyncio.create_task(self._initialize_browser()) + # Wait for initialization to complete + await self.browser_init_task + logger.info(f"📝 Initialization complete. Questions loaded: {len(self.questions)}") + + # Get current question after initialization + current_question = self.get_current_question() + logger.info(f"📊 Current question: {current_question.field_name if current_question else 'None'}") + logger.info(f"📊 Question index: {self.current_question_index}/{len(self.questions)}") + logger.info(f"📊 Events count: {len(context.events)}") + + # Check latest event to determine what to do + latest_event = context.events[-1] if context.events else None + is_agent_response = isinstance(latest_event, AgentResponse) if latest_event else False + + # Handle initial greeting - speak first when conversation starts + if not context.events: + logger.info(f"📝 Starting conversation - Agent speaks first") + initial_greeting = ( + "Hello! I'm here to help you fill out an application form today. " + "I'll ask you a series of questions and fill in the form as we go. " + "Ready to get started?" + ) + yield AgentResponse(content=initial_greeting) + return + + # If last event was our greeting, and user responded, ask first question + if len(context.events) == 2 and is_agent_response == False and self.current_question_index == 0: + user_message = context.get_latest_user_transcript_message() + if user_message and current_question: + logger.info(f"📝 User ready to start: '{user_message}'") + logger.info(f"📝 Asking first question: {current_question.field_name}") + yield AgentResponse(content=f"Great! Let's begin. {current_question.question}") + return + + # Check if all questions have been answered + # Only submit if we've actually collected data + if not current_question and self.current_question_index > 0 and len(self.collected_data) > 0: + # All questions answered - submit the form + logger.info(f"📋 All {self.current_question_index} questions answered") + logger.info(f"📊 Collected data for {len(self.collected_data)} fields") + + submission_success = await self._submit_form() + + if submission_success: + goodbye = "Perfect! I've submitted your application. Thank you!" + else: + goodbye = "Thank you for providing all the information. Your responses have been recorded." + + # Clean up + if self.stagehand_filler: + await self.stagehand_filler.cleanup() + + # End call + args = EndCallArgs(goodbye_message=goodbye) + async for item in end_call(args): + yield item + return + + # Guard against no questions or empty state + if not current_question and self.current_question_index == 0: + logger.warning("⚠️ No questions available or not properly initialized") + return + + # Process user response + messages = convert_messages_to_gemini(context.events, text_events_only=True) + + # Add context about current question + question_context = f""" + + Current form field: {current_question.field_name} + Question: {current_question.question} + + Listen to the user's response and use the record_form_field tool to save it. + Then acknowledge their answer naturally. + """ + + # Enhanced config + enhanced_config = gemini_types.GenerateContentConfig( + system_instruction=self.generation_config.system_instruction + question_context, + temperature=self.temperature, + tools=[RecordFormFieldTool.to_gemini_tool()], + max_output_tokens=self.generation_config.max_output_tokens, + thinking_config=gemini_types.ThinkingConfig(thinking_budget=0), + ) + + # Get user's latest message + user_message = context.get_latest_user_transcript_message() + if user_message: + logger.info(f'🎤 User response: "{user_message}"') + + # Stream Gemini response + full_response = "" + stream = await self.client.aio.models.generate_content_stream( + model=self.model_id, + contents=messages, + config=enhanced_config, + ) + + field_recorded = False + + async for msg in stream: + if msg.text: + full_response += msg.text + yield AgentResponse(content=msg.text) + + if msg.function_calls: + for function_call in msg.function_calls: + if function_call.name == RecordFormFieldTool.name(): + field_name = function_call.args.get("field_name", current_question.field_name) + value = function_call.args.get("value", "") + + logger.info(f"📝 Recording: {field_name} = {value}") + + # Store data first + self.collected_data[field_name] = value + + # Fill the form field immediately in real-time + await self._fill_form_field(field_name, value) + + # Log the collected data + logger.info(f"📊 Collected: {field_name}={value}") + + # Move to next question + self.current_question_index += 1 + field_recorded = True + + # Clear context + self.clear_context() + + # Get next question + next_question = self.get_current_question() + if next_question: + yield AgentResponse(content=f"Great! {next_question.question}") + + # Yield tool result + yield ToolResult( + tool_name="record_form_field", + tool_args={"field_name": field_name, "value": value}, + result=f"Recorded: {field_name}={value}" + ) + + if full_response: + logger.info(f'🤖 Agent response: "{full_response}"') \ No newline at end of file diff --git a/examples/integrations/cartesia/main.py b/examples/integrations/cartesia/main.py new file mode 100644 index 0000000..04d7978 --- /dev/null +++ b/examples/integrations/cartesia/main.py @@ -0,0 +1,70 @@ +""" +Cartesia Line Voice Agent with Real-time Web Form Filling using Stagehand +""" + +import os + +from config import SYSTEM_PROMPT +from form_filling_node import FormFillingNode +from google import genai + +from line import Bridge, CallRequest, VoiceAgentApp, VoiceAgentSystem +from line.events import UserStartedSpeaking, UserStoppedSpeaking, UserTranscriptionReceived + +# Target form URL - the actual web form to fill +FORM_URL = "https://forms.fillout.com/t/34ccsqafUFus" + +# Initialize Gemini client +gemini_client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) + + +async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): + """ + Handle incoming voice calls with real-time web form filling + + This agent will: + 1. Conduct a voice conversation to gather information + 2. Open and fill an actual web form in the background + 3. Submit the form when the conversation is complete + """ + + # Create form filling node with browser automation + form_node = FormFillingNode( + system_prompt=SYSTEM_PROMPT, + gemini_client=gemini_client, + form_url=FORM_URL, + headless=False, # Show browser for demo (set True for production) + ) + + # Set up bridge for event handling + form_bridge = Bridge(form_node) + system.with_speaking_node(form_node, bridge=form_bridge) + + # Connect transcription events + form_bridge.on(UserTranscriptionReceived).map(form_node.add_event) + + # Handle interruptions and streaming + ( + form_bridge.on(UserStoppedSpeaking) + .interrupt_on(UserStartedSpeaking, handler=form_node.on_interrupt_generate) + .stream(form_node.generate) + .broadcast() + ) + + # Start the system + await system.start() + + # Send initial greeting (will be handled by form_node) + await system.wait_for_shutdown() + + +# Create the voice agent application +app = VoiceAgentApp(handle_new_call) + +if __name__ == "__main__": + print("🚀 Starting Voice Agent with Web Form Automation") + print(f"📝 Will fill form at: {FORM_URL}") + print("📞 Ready to receive calls...") + print("\nNote: The browser will open when the first call is received.") + print("Set headless=True in production to run in background.\n") + app.run() \ No newline at end of file diff --git a/examples/integrations/cartesia/requirements.txt b/examples/integrations/cartesia/requirements.txt new file mode 100644 index 0000000..e7c06e2 --- /dev/null +++ b/examples/integrations/cartesia/requirements.txt @@ -0,0 +1,8 @@ +cartesia-line +aiohttp>=3.12.0 +google-genai>=1.26.0; python_version>='3.9' +loguru>=0.7.0 +python-dotenv>=1.0.0 +PyYAML>=6.0.0 +stagehand>=0.1.0 +pydantic>=2.0.0 diff --git a/examples/integrations/cartesia/stagehand_form_filler.py b/examples/integrations/cartesia/stagehand_form_filler.py new file mode 100644 index 0000000..f8c9025 --- /dev/null +++ b/examples/integrations/cartesia/stagehand_form_filler.py @@ -0,0 +1,418 @@ +""" +StagehandFormFiller - Browser automation for filling web forms during voice conversations +""" + +import asyncio +import os +from typing import Dict, Optional, Any +from dataclasses import dataclass +from enum import Enum + +from loguru import logger +from stagehand import Stagehand, StagehandConfig +from pydantic import BaseModel + + +class FieldType(Enum): + TEXT = "text" + EMAIL = "email" + PHONE = "phone" + SELECT = "select" + RADIO = "radio" + CHECKBOX = "checkbox" + TEXTAREA = "textarea" + ADDRESS = "address" + + +@dataclass +class FormField: + """Represents a form field with its metadata""" + field_id: str + field_type: FieldType + label: str + selector: Optional[str] = None + required: bool = False + options: Optional[list] = None + + +class FormFieldMapping: + """Maps conversation questions to actual form fields""" + + def __init__(self): + # Page 1 - Basic Information + self.basic_info_mappings = { + "full_name": FormField( + field_id="full_name", + field_type=FieldType.TEXT, + label="What is your full name?", + required=True, + ), + "email": FormField( + field_id="email", + field_type=FieldType.EMAIL, + label="What is your email address?", + required=True, + ), + "phone": FormField( + field_id="phone", + field_type=FieldType.PHONE, + label="What is your phone number?", + required=False, + ), + "address": FormField( + field_id="address", + field_type=FieldType.ADDRESS, + label="What is your current address?", + required=True, + ), + "city": FormField( + field_id="city", + field_type=FieldType.TEXT, + label="City", + required=True, + ), + "state": FormField( + field_id="state", + field_type=FieldType.TEXT, + label="State / Province", + required=True, + ), + "zip": FormField( + field_id="zip", + field_type=FieldType.TEXT, + label="ZIP / Postal code", + required=True, + ), + } + + # Page 2 - Availability + self.availability_mappings = { + "work_eligibility": FormField( + field_id="work_eligibility", + field_type=FieldType.RADIO, + label="Are you legally eligible to work in this country?", + options=["Yes", "No"], + required=True, + ), + "availability_type": FormField( + field_id="availability", + field_type=FieldType.RADIO, + label="What's your availability?", + options=["Temporary", "Part-time", "Full-time"], + required=True, + ), + } + + # Page 3 - Additional Information + self.additional_info_mappings = { + "additional_info": FormField( + field_id="additional_info", + field_type=FieldType.TEXTAREA, + label="Anything else you'd like to let us know about you?", + required=False, + ), + } + + # Page 4 - Role Information + self.role_mappings = { + "role_selection": FormField( + field_id="role_selection", + field_type=FieldType.CHECKBOX, + label="Which of these roles are you applying for?", + options=["Sales manager", "IT Support", "Recruiting", "Software engineer", "Marketing specialist"], + required=True, + ), + "previous_experience": FormField( + field_id="previous_experience", + field_type=FieldType.RADIO, + label="Have you worked in a role similar to this one in the past?", + options=["Yes", "No"], + required=True, + ), + "skills_experience": FormField( + field_id="skills_experience", + field_type=FieldType.TEXTAREA, + label="What relevant skills and experience do you have that make you a strong candidate for this position?", + required=True, + ), + } + + # Combined mappings for easy lookup + self.field_mappings = { + **self.basic_info_mappings, + **self.availability_mappings, + **self.additional_info_mappings, + **self.role_mappings, + } + + def get_form_field(self, question_id: str) -> Optional[FormField]: + """Get the form field mapping for a question ID""" + return self.field_mappings.get(question_id) + + def transform_answer(self, question_id: str, answer: str) -> str: + """Transform voice answer to form-compatible format""" + field = self.get_form_field(question_id) + if not field: + return answer + + # Handle specific transformations + if question_id == "role_selection": + # Map voice responses to exact form options + role_mapping = { + "sales": "Sales manager", + "sales manager": "Sales manager", + "it": "IT Support", + "it support": "IT Support", + "tech support": "IT Support", + "technical support": "IT Support", + "recruiting": "Recruiting", + "recruiter": "Recruiting", + "software": "Software engineer", + "software engineer": "Software engineer", + "developer": "Software engineer", + "programming": "Software engineer", + "marketing": "Marketing specialist", + "marketing specialist": "Marketing specialist", + } + answer_lower = answer.lower().strip() + return role_mapping.get(answer_lower, answer) + + elif question_id == "availability_type": + # Map availability responses + availability_mapping = { + "temp": "Temporary", + "temporary": "Temporary", + "part": "Part-time", + "part-time": "Part-time", + "part time": "Part-time", + "full": "Full-time", + "full-time": "Full-time", + "full time": "Full-time", + } + answer_lower = answer.lower().strip() + return availability_mapping.get(answer_lower, answer) + + elif question_id in ["work_eligibility", "previous_experience"]: + # Convert boolean-like responses to Yes/No + if answer.lower().strip() in ["true", "yes", "yeah", "yep", "sure", "of course", "definitely"]: + return "Yes" + elif answer.lower().strip() in ["false", "no", "nope", "not really", "nah"]: + return "No" + else: + return answer + + elif question_id in ["full_name", "email", "phone", "address", "city", "state", "zip"]: + # Clean up basic text fields + return answer.strip() + + elif question_id in ["additional_info", "skills_experience"]: + # Keep text areas as-is but clean whitespace + return answer.strip() + + return answer + + +class StagehandFormFiller: + """Manages browser automation for filling forms using Stagehand""" + + def __init__(self, form_url: str, headless: bool = False): + self.form_url = form_url + self.headless = headless + self.stagehand: Optional[Stagehand] = None + self.page = None + self.is_initialized = False + self.field_mapper = FormFieldMapping() + self.collected_data: Dict[str, str] = {} + + async def initialize(self): + """Initialize Stagehand and open the form""" + if self.is_initialized: + return + + try: + logger.info("🚀 Initializing Stagehand browser automation") + + # Configure Stagehand + config = StagehandConfig( + env="LOCAL", # Use local browser + model_name="google/gemini-2.0-flash-exp", # Fast model for form filling + model_api_key=os.getenv("GEMINI_API_KEY"), + ) + + self.stagehand = Stagehand(config) + await self.stagehand.init() + + self.page = self.stagehand.page + + # Navigate to form + logger.info(f"📝 Opening form: {self.form_url}") + await self.page.goto(self.form_url) + + # Wait for form to load + await asyncio.sleep(2) + + self.is_initialized = True + logger.info("✅ Browser automation initialized successfully") + + except Exception as e: + logger.error(f"❌ Failed to initialize Stagehand: {e}") + raise + + async def fill_field(self, question_id: str, answer: str) -> bool: + """Fill a specific form field based on the question ID and answer""" + if not self.is_initialized: + await self.initialize() + + try: + # Get field mapping + field = self.field_mapper.get_form_field(question_id) + if not field: + logger.warning(f"⚠️ No field mapping found for question: {question_id}") + return False + + # Transform answer for the form + transformed_answer = self.field_mapper.transform_answer(question_id, answer) + self.collected_data[question_id] = transformed_answer + + logger.info(f"🖊️ Filling field '{field.label}' with: {transformed_answer}") + + # Use Stagehand's natural language API to fill the field + if field.field_type in [FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE]: + await self.page.act(f"Fill in the '{field.label}' field with: {transformed_answer}") + + elif field.field_type == FieldType.ADDRESS: + await self.page.act(f"Fill in the address field with: {transformed_answer}") + + elif field.field_type == FieldType.TEXTAREA: + await self.page.act(f"Type in the '{field.label}' text area: {transformed_answer}") + + elif field.field_type in [FieldType.SELECT, FieldType.RADIO]: + await self.page.act(f"Select '{transformed_answer}' for the '{field.label}' field") + + elif field.field_type == FieldType.CHECKBOX: + # For role selection, check the specific role checkbox + if question_id == "role_selection": + await self.page.act(f"Check the '{transformed_answer}' checkbox") + else: + # For other checkboxes, check/uncheck based on answer + if transformed_answer.lower() in ["yes", "true"]: + await self.page.act(f"Check the '{field.label}' checkbox") + else: + await self.page.act(f"Uncheck the '{field.label}' checkbox") + + return True + + except Exception as e: + logger.error(f"❌ Error filling field {question_id}: {e}") + return False + + async def fill_collected_data(self): + """Fill in all collected data from the conversation""" + logger.info("👤 Filling collected information from conversation") + + # Fill all collected data + for field_name, value in self.collected_data.items(): + field = self.field_mapper.get_form_field(field_name) + if field and value: + logger.info(f"📝 Filling {field_name}: {value}") + + # Parse address components if it's an address field + if field_name == "address" and "," in value: + # Try to parse address components + parts = [p.strip() for p in value.split(",")] + if len(parts) >= 4: + # Assume format: street, city, state, zip + await self.page.act(f"Fill in the 'Address' field with: {parts[0]}") + await self.page.act(f"Fill in the 'City' field with: {parts[1]}") + await self.page.act(f"Fill in the 'State / Province' field with: {parts[2]}") + await self.page.act(f"Fill in the 'ZIP / Postal code' field with: {parts[3]}") + else: + await self.page.act(f"Fill in the '{field.label}' field with: {value}") + else: + await self.page.act(f"Fill in the '{field.label}' field with: {value}") + + await asyncio.sleep(0.5) # Small delay between fields + + async def navigate_to_next_page(self): + """Navigate to the next page of the form if multi-page""" + try: + await self.page.act("Click the Next or Continue button") + await asyncio.sleep(2) # Wait for page transition + return True + except Exception as e: + logger.debug(f"No next button found or single-page form: {e}") + return False + + async def submit_form(self) -> bool: + """Submit the completed form""" + try: + logger.info("📤 Attempting to submit the form") + logger.info(f"📊 Form has {len(self.collected_data)} fields already filled in real-time") + + # Data has already been filled in real-time during conversation + # Just navigate and submit + + # Navigate through pages if needed + await self.navigate_to_next_page() + + # Submit the form + await self.page.act("Click the Submit button") + + # Wait for submission confirmation + await asyncio.sleep(3) + + # Check for success message + try: + success_check = await self.page.extract({ + "success_indicator": "boolean indicating if form was submitted successfully" + }) + + if success_check and hasattr(success_check, 'success_indicator'): + logger.info("✅ Form submitted successfully!") + return True + elif success_check: + logger.info("✅ Form submission completed") + return True + except Exception as e: + logger.warning(f"⚠️ Could not verify submission: {e}") + + logger.warning("⚠️ Form submission uncertain, checking page state") + return False + + except Exception as e: + logger.error(f"❌ Error submitting form: {e}") + return False + + async def get_form_progress(self) -> Dict[str, Any]: + """Get current progress of form filling""" + if not self.is_initialized: + return {"status": "not_started", "fields_filled": 0} + + try: + # Use Stagehand to extract form progress + progress = await self.page.extract({ + "filled_fields": "number of fields that have been filled", + "total_fields": "total number of fields in the form", + "current_page": "current page number if multi-page form", + "total_pages": "total pages if multi-page form", + }) + + return { + "status": "in_progress", + "fields_filled": len(self.collected_data), + "form_state": progress, + "collected_data": self.collected_data, + } + + except Exception as e: + logger.error(f"Error getting form progress: {e}") + return {"status": "error", "message": str(e)} + + async def cleanup(self): + """Clean up browser resources""" + if self.stagehand and self.page: + try: + await self.page.close() + logger.info("🧹 Browser closed") + except Exception as e: + logger.error(f"Error closing browser: {e}") \ No newline at end of file From 6c94ea4d072f7d6a85352558f51713c5b3fd0f60 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Tue, 26 Aug 2025 16:47:04 -0700 Subject: [PATCH 02/10] remove agent toml --- examples/integrations/cartesia/.cartesia/config.toml | 1 - examples/integrations/cartesia/.gitignore | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 examples/integrations/cartesia/.cartesia/config.toml diff --git a/examples/integrations/cartesia/.cartesia/config.toml b/examples/integrations/cartesia/.cartesia/config.toml deleted file mode 100644 index 4251232..0000000 --- a/examples/integrations/cartesia/.cartesia/config.toml +++ /dev/null @@ -1 +0,0 @@ -agent-id = 'agent_NKsQKSxugbsoA3ByZrJVQY' diff --git a/examples/integrations/cartesia/.gitignore b/examples/integrations/cartesia/.gitignore index e87426f..7d9a412 100644 --- a/examples/integrations/cartesia/.gitignore +++ b/examples/integrations/cartesia/.gitignore @@ -37,3 +37,5 @@ build/ # Editor / OS files .DS_Store + +.cartesia/ \ No newline at end of file From 20a5e64c9e7897e5ddedfedf4c2e1736f6ad8e9a Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Fri, 29 Aug 2025 12:53:59 -0700 Subject: [PATCH 03/10] make browser fill actions async --- examples/integrations/cartesia/README.md | 8 +- .../cartesia/form_filling_node.py | 40 ++++++---- examples/integrations/cartesia/main.py | 6 +- .../cartesia/stagehand_form_filler.py | 75 ++++++++++++------- 4 files changed, 79 insertions(+), 50 deletions(-) diff --git a/examples/integrations/cartesia/README.md b/examples/integrations/cartesia/README.md index b5dbd48..443bb11 100644 --- a/examples/integrations/cartesia/README.md +++ b/examples/integrations/cartesia/README.md @@ -8,7 +8,7 @@ This project demonstrates an advanced voice agent that conducts phone questionna - **Real-time Form Filling**: Automatically fills web forms as answers are collected - **Browser Automation**: Uses Stagehand AI to interact with any web form - **Intelligent Mapping**: AI-powered mapping of voice answers to form fields -- **Async Processing**: Non-blocking form filling maintains conversation flow +- **Async Processing**: Non-blocking form filling maintains conversation flow - form fields are filled in background tasks without delaying voice responses - **Auto-submission**: Submits forms automatically when complete ## Architecture @@ -68,7 +68,7 @@ The system can be configured through: - `form.yaml`: Define questionnaire structure - `FORM_URL`: Target web form to fill -- `headless`: Run browser in background (True) or visible (False) +- `headless`: Run browser in background (True) or visible (False) - currently set to True for production use - `enable_browser`: Toggle browser automation on/off ## Example Flow @@ -87,7 +87,7 @@ The system can be configured through: ## Advanced Features -- **Background Processing**: Form filling happens asynchronously +- **Background Processing**: Form filling happens asynchronously using background tasks - conversation remains smooth and responsive - **Error Recovery**: Continues conversation even if form filling fails - **Progress Tracking**: Monitor form completion status - **Screenshot Debugging**: Captures screenshots after each field @@ -104,7 +104,7 @@ Test with different scenarios: ## Production Considerations -- Set `headless=True` for production +- Set `headless=True` for production (currently configured this way) - Configure proper error logging - Add retry logic for form submission - Implement form validation checks diff --git a/examples/integrations/cartesia/form_filling_node.py b/examples/integrations/cartesia/form_filling_node.py index b5e2621..3ed2ff8 100644 --- a/examples/integrations/cartesia/form_filling_node.py +++ b/examples/integrations/cartesia/form_filling_node.py @@ -238,25 +238,33 @@ def _create_questions_from_analysis(self, form_analysis: Dict[str, Any]) -> list return form_questions + async def _fill_form_field_async(self, field_name: str, value: str): + """Fill a form field asynchronously in background (non-blocking)""" + try: + await self._fill_form_field(field_name, value) + except Exception as e: + logger.error(f"❌ Background form filling error for {field_name}: {e}") + async def _fill_form_field(self, field_name: str, value: str): """Fill a form field in the browser in real-time""" if not self.stagehand_filler: logger.warning("⚠️ Browser not initialized yet") return - + try: - logger.info(f"🖊️ Filling field '{field_name}' with: {value} in real-time") - + logger.info(f"🖊️ Filling field '{field_name}' with: {value} in background") + # Use StagehandFormFiller's fill_field method which handles the mapping success = await self.stagehand_filler.fill_field(field_name, value) - + if success: logger.info(f"✅ Successfully filled field: {field_name} in the browser") else: logger.warning(f"⚠️ Failed to fill field: {field_name}") - + except Exception as e: logger.error(f"Error filling field {field_name}: {e}") + raise # Re-raise so background task can catch it async def _submit_form(self): """Submit the completed form""" @@ -416,29 +424,29 @@ async def process_context( value = function_call.args.get("value", "") logger.info(f"📝 Recording: {field_name} = {value}") - + # Store data first self.collected_data[field_name] = value - - # Fill the form field immediately in real-time - await self._fill_form_field(field_name, value) - + + # Fill the form field asynchronously in background (non-blocking) + asyncio.create_task(self._fill_form_field_async(field_name, value)) + # Log the collected data logger.info(f"📊 Collected: {field_name}={value}") - - # Move to next question + + # Move to next question immediately (don't wait for form filling) self.current_question_index += 1 field_recorded = True - + # Clear context self.clear_context() - + # Get next question next_question = self.get_current_question() if next_question: yield AgentResponse(content=f"Great! {next_question.question}") - - # Yield tool result + + # Yield tool result immediately yield ToolResult( tool_name="record_form_field", tool_args={"field_name": field_name, "value": value}, diff --git a/examples/integrations/cartesia/main.py b/examples/integrations/cartesia/main.py index 04d7978..d9cecaa 100644 --- a/examples/integrations/cartesia/main.py +++ b/examples/integrations/cartesia/main.py @@ -33,7 +33,7 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): system_prompt=SYSTEM_PROMPT, gemini_client=gemini_client, form_url=FORM_URL, - headless=False, # Show browser for demo (set True for production) + headless=True, # Run browser in background for production ) # Set up bridge for event handling @@ -65,6 +65,6 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): print("🚀 Starting Voice Agent with Web Form Automation") print(f"📝 Will fill form at: {FORM_URL}") print("📞 Ready to receive calls...") - print("\nNote: The browser will open when the first call is received.") - print("Set headless=True in production to run in background.\n") + print("\nNote: The browser will run in background (headless mode).") + print("Form filling happens invisibly while processing voice calls.\n") app.run() \ No newline at end of file diff --git a/examples/integrations/cartesia/stagehand_form_filler.py b/examples/integrations/cartesia/stagehand_form_filler.py index f8c9025..6d73125 100644 --- a/examples/integrations/cartesia/stagehand_form_filler.py +++ b/examples/integrations/cartesia/stagehand_form_filler.py @@ -234,7 +234,7 @@ async def initialize(self): # Configure Stagehand config = StagehandConfig( - env="LOCAL", # Use local browser + env="BROWSERBASE", # Use local browser model_name="google/gemini-2.0-flash-exp", # Fast model for form filling model_api_key=os.getenv("GEMINI_API_KEY"), ) @@ -259,9 +259,11 @@ async def initialize(self): raise async def fill_field(self, question_id: str, answer: str) -> bool: - """Fill a specific form field based on the question ID and answer""" + """Fill a specific form field based on the question ID and answer (non-blocking)""" if not self.is_initialized: - await self.initialize() + # Initialize asynchronously without blocking + init_task = asyncio.create_task(self.initialize()) + await init_task try: # Get field mapping @@ -274,31 +276,38 @@ async def fill_field(self, question_id: str, answer: str) -> bool: transformed_answer = self.field_mapper.transform_answer(question_id, answer) self.collected_data[question_id] = transformed_answer - logger.info(f"🖊️ Filling field '{field.label}' with: {transformed_answer}") + logger.info(f"🖊️ Async filling field '{field.label}' with: {transformed_answer}") + + # Create async task for the actual field filling + fill_action = None # Use Stagehand's natural language API to fill the field if field.field_type in [FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE]: - await self.page.act(f"Fill in the '{field.label}' field with: {transformed_answer}") + fill_action = self.page.act(f"Fill in the '{field.label}' field with: {transformed_answer}") elif field.field_type == FieldType.ADDRESS: - await self.page.act(f"Fill in the address field with: {transformed_answer}") + fill_action = self.page.act(f"Fill in the address field with: {transformed_answer}") elif field.field_type == FieldType.TEXTAREA: - await self.page.act(f"Type in the '{field.label}' text area: {transformed_answer}") + fill_action = self.page.act(f"Type in the '{field.label}' text area: {transformed_answer}") elif field.field_type in [FieldType.SELECT, FieldType.RADIO]: - await self.page.act(f"Select '{transformed_answer}' for the '{field.label}' field") + fill_action = self.page.act(f"Select '{transformed_answer}' for the '{field.label}' field") elif field.field_type == FieldType.CHECKBOX: # For role selection, check the specific role checkbox if question_id == "role_selection": - await self.page.act(f"Check the '{transformed_answer}' checkbox") + fill_action = self.page.act(f"Check the '{transformed_answer}' checkbox") else: # For other checkboxes, check/uncheck based on answer if transformed_answer.lower() in ["yes", "true"]: - await self.page.act(f"Check the '{field.label}' checkbox") + fill_action = self.page.act(f"Check the '{field.label}' checkbox") else: - await self.page.act(f"Uncheck the '{field.label}' checkbox") + fill_action = self.page.act(f"Uncheck the '{field.label}' checkbox") + + # Execute the fill action asynchronously + if fill_action: + await fill_action return True @@ -334,17 +343,23 @@ async def fill_collected_data(self): await asyncio.sleep(0.5) # Small delay between fields async def navigate_to_next_page(self): - """Navigate to the next page of the form if multi-page""" + """Navigate to the next page of the form if multi-page (non-blocking)""" try: - await self.page.act("Click the Next or Continue button") - await asyncio.sleep(2) # Wait for page transition + # Create async task for navigation + nav_task = asyncio.create_task( + self.page.act("Click the Next or Continue button") + ) + await nav_task + + # Small async delay for page transition + await asyncio.sleep(1.5) return True except Exception as e: logger.debug(f"No next button found or single-page form: {e}") return False async def submit_form(self) -> bool: - """Submit the completed form""" + """Submit the completed form (fully async)""" try: logger.info("📤 Attempting to submit the form") logger.info(f"📊 Form has {len(self.collected_data)} fields already filled in real-time") @@ -352,20 +367,26 @@ async def submit_form(self) -> bool: # Data has already been filled in real-time during conversation # Just navigate and submit - # Navigate through pages if needed - await self.navigate_to_next_page() + # Navigate through pages if needed (async) + nav_result = await self.navigate_to_next_page() - # Submit the form - await self.page.act("Click the Submit button") + # Submit the form asynchronously + submit_task = asyncio.create_task( + self.page.act("Click the Submit button") + ) + await submit_task - # Wait for submission confirmation - await asyncio.sleep(3) + # Wait for submission confirmation (non-blocking) + await asyncio.sleep(2.5) - # Check for success message + # Check for success message asynchronously try: - success_check = await self.page.extract({ - "success_indicator": "boolean indicating if form was submitted successfully" - }) + extract_task = asyncio.create_task( + self.page.extract({ + "success_indicator": "boolean indicating if form was submitted successfully" + }) + ) + success_check = await extract_task if success_check and hasattr(success_check, 'success_indicator'): logger.info("✅ Form submitted successfully!") @@ -376,8 +397,8 @@ async def submit_form(self) -> bool: except Exception as e: logger.warning(f"⚠️ Could not verify submission: {e}") - logger.warning("⚠️ Form submission uncertain, checking page state") - return False + logger.info("📝 Form submission process completed") + return True # Assume success if no errors except Exception as e: logger.error(f"❌ Error submitting form: {e}") From 9119ffc6645e52ebc57aaeb2cf9027ba8af75263 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Thu, 4 Sep 2025 14:10:31 -0700 Subject: [PATCH 04/10] in-prog async browser creation and form fill --- examples/integrations/cartesia/__init__.py | 0 .../cartesia/form_filling_node.py | 57 ++++++++++--------- examples/integrations/cartesia/main.py | 2 +- 3 files changed, 30 insertions(+), 29 deletions(-) delete mode 100644 examples/integrations/cartesia/__init__.py diff --git a/examples/integrations/cartesia/__init__.py b/examples/integrations/cartesia/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/integrations/cartesia/form_filling_node.py b/examples/integrations/cartesia/form_filling_node.py index 3ed2ff8..4359f97 100644 --- a/examples/integrations/cartesia/form_filling_node.py +++ b/examples/integrations/cartesia/form_filling_node.py @@ -108,11 +108,13 @@ def __init__( # Form state self.form_fields: Dict[str, Any] = {} self.collected_data: Dict[str, str] = {} - self.questions: list[FormQuestion] = [] + # Pre-initialize questions so conversation can start immediately + self.questions: list[FormQuestion] = self._create_questions_from_analysis({}) self.current_question_index = 0 # Browser initialization self.browser_init_task = None + self.browser_initializing = False # Enhanced prompt for form filling enhanced_prompt = system_prompt + """ @@ -137,6 +139,12 @@ def __init__( async def _initialize_browser(self): """Initialize browser and extract form fields""" + # Prevent multiple initializations + if self.browser_initializing or self.stagehand_filler: + logger.info("🔒 Browser already initializing or initialized, skipping") + return + + self.browser_initializing = True try: logger.info("🌐 Initializing browser and analyzing form") self.stagehand_filler = StagehandFormFiller( @@ -159,14 +167,15 @@ async def _initialize_browser(self): except Exception as e: logger.warning(f"Could not extract form fields: {e}") - # Always create questions - don't depend on form extraction - self.questions = self._create_questions_from_analysis({}) - - logger.info(f"✅ Browser ready with {len(self.questions)} questions") + # Questions already initialized in __init__, no need to recreate + logger.info(f"✅ Browser ready, form can now be filled") except Exception as e: logger.error(f"❌ Failed to initialize browser: {e}") + self.browser_initializing = False raise + finally: + self.browser_initializing = False def _create_questions_from_analysis(self, form_analysis: Dict[str, Any]) -> list[FormQuestion]: """Create questions based on form analysis""" @@ -241,19 +250,12 @@ def _create_questions_from_analysis(self, form_analysis: Dict[str, Any]) -> list async def _fill_form_field_async(self, field_name: str, value: str): """Fill a form field asynchronously in background (non-blocking)""" try: - await self._fill_form_field(field_name, value) - except Exception as e: - logger.error(f"❌ Background form filling error for {field_name}: {e}") - - async def _fill_form_field(self, field_name: str, value: str): - """Fill a form field in the browser in real-time""" - if not self.stagehand_filler: - logger.warning("⚠️ Browser not initialized yet") - return - - try: + # Wait for browser initialization if needed + if self.browser_init_task: + logger.info(f"⏳ Waiting for browser to initialize before filling {field_name}") + await self.browser_init_task + logger.info(f"🖊️ Filling field '{field_name}' with: {value} in background") - # Use StagehandFormFiller's fill_field method which handles the mapping success = await self.stagehand_filler.fill_field(field_name, value) @@ -264,10 +266,15 @@ async def _fill_form_field(self, field_name: str, value: str): except Exception as e: logger.error(f"Error filling field {field_name}: {e}") - raise # Re-raise so background task can catch it - + raise # Re-raise so background task can catch it + async def _submit_form(self): """Submit the completed form""" + # Wait for browser initialization if needed + if self.browser_init_task and not self.stagehand_filler: + logger.info("⏳ Waiting for browser to initialize before submitting form") + await self.browser_init_task + if not self.stagehand_filler: return False @@ -309,12 +316,10 @@ async def process_context( AgentResponse: Text responses to the user EndCall: Call termination when form is complete """ - # Initialize browser on first call - if not self.browser_init_task: + # Initialize browser on first call (non-blocking) + if not self.browser_init_task and not self.stagehand_filler: self.browser_init_task = asyncio.create_task(self._initialize_browser()) - # Wait for initialization to complete - await self.browser_init_task - logger.info(f"📝 Initialization complete. Questions loaded: {len(self.questions)}") + logger.info("🚀 Browser initialization started in background") # Get current question after initialization current_question = self.get_current_question() @@ -388,7 +393,6 @@ async def process_context( Then acknowledge their answer naturally. """ - # Enhanced config enhanced_config = gemini_types.GenerateContentConfig( system_instruction=self.generation_config.system_instruction + question_context, temperature=self.temperature, @@ -427,13 +431,10 @@ async def process_context( # Store data first self.collected_data[field_name] = value - # Fill the form field asynchronously in background (non-blocking) asyncio.create_task(self._fill_form_field_async(field_name, value)) - # Log the collected data logger.info(f"📊 Collected: {field_name}={value}") - # Move to next question immediately (don't wait for form filling) self.current_question_index += 1 field_recorded = True diff --git a/examples/integrations/cartesia/main.py b/examples/integrations/cartesia/main.py index d9cecaa..1e228f0 100644 --- a/examples/integrations/cartesia/main.py +++ b/examples/integrations/cartesia/main.py @@ -33,7 +33,7 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): system_prompt=SYSTEM_PROMPT, gemini_client=gemini_client, form_url=FORM_URL, - headless=True, # Run browser in background for production + headless=False ) # Set up bridge for event handling From 1d45f94246f2f93cc3d1c97b225f40493abbf96c Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Tue, 14 Oct 2025 13:36:08 -0700 Subject: [PATCH 05/10] adding cartesia deploy to docs + toml file --- examples/integrations/cartesia/README.md | 6 ++++++ examples/integrations/cartesia/cartesia.toml | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/integrations/cartesia/README.md b/examples/integrations/cartesia/README.md index 443bb11..fda907b 100644 --- a/examples/integrations/cartesia/README.md +++ b/examples/integrations/cartesia/README.md @@ -93,6 +93,12 @@ The system can be configured through: - **Screenshot Debugging**: Captures screenshots after each field - **Flexible Mapping**: AI interprets answers for different field types +## Deployment + +Note: you need to set env variables in the Cartesia Platform to properly deploy. + +[Here's how to deploy an agent from the Cartesia Docs.](https://docs.cartesia.ai/line/start-building/talk-to-your-first-agent) + ## Testing Test with different scenarios: diff --git a/examples/integrations/cartesia/cartesia.toml b/examples/integrations/cartesia/cartesia.toml index afe77c2..979916c 100644 --- a/examples/integrations/cartesia/cartesia.toml +++ b/examples/integrations/cartesia/cartesia.toml @@ -2,7 +2,7 @@ name = "form-filling" [build] -cmd = "echo 'No build cmd specified'" +cmd = "pip install -r requirements.txt" [run] -cmd = "echo 'No run cmd specified'" +cmd = "python main.py" From 72b5c4fb43c2ee8b5b725ed1c231783f578d2ad6 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Thu, 16 Oct 2025 09:15:11 -0700 Subject: [PATCH 06/10] update integration code to be simplified --- .../cartesia/form_filling_node.py | 50 ++-- examples/integrations/cartesia/main.py | 7 +- .../cartesia/stagehand_form_filler.py | 247 ++---------------- 3 files changed, 46 insertions(+), 258 deletions(-) diff --git a/examples/integrations/cartesia/form_filling_node.py b/examples/integrations/cartesia/form_filling_node.py index 4359f97..63ba171 100644 --- a/examples/integrations/cartesia/form_filling_node.py +++ b/examples/integrations/cartesia/form_filling_node.py @@ -106,10 +106,9 @@ def __init__( self.stagehand_filler: Optional[StagehandFormFiller] = None # Form state - self.form_fields: Dict[str, Any] = {} self.collected_data: Dict[str, str] = {} # Pre-initialize questions so conversation can start immediately - self.questions: list[FormQuestion] = self._create_questions_from_analysis({}) + self.questions: list[FormQuestion] = self._create_questions() self.current_question_index = 0 # Browser initialization @@ -136,6 +135,23 @@ def __init__( ) logger.info(f"🚀 FormFillingNode initialized for form: {form_url}") + + # Track if form was submitted + self.form_submitted = False + + async def cleanup_and_submit(self): + """Ensure form is submitted and cleanup when call ends""" + # Submit form if we have any data and haven't submitted yet + if not self.form_submitted and self.collected_data and self.stagehand_filler: + logger.info("🔄 Call ending - auto-submitting form with collected data") + try: + await self._submit_form() + except Exception as e: + logger.error(f"Error during cleanup submission: {e}") + + # Clean up browser + if self.stagehand_filler: + await self.stagehand_filler.cleanup() async def _initialize_browser(self): """Initialize browser and extract form fields""" @@ -153,21 +169,6 @@ async def _initialize_browser(self): ) await self.stagehand_filler.initialize() - # Extract form fields using Stagehand (optional for dynamic forms) - # For now, we'll use predefined questions for the known form - if self.stagehand_filler.page: - try: - form_analysis = await self.stagehand_filler.page.extract({ - "form_fields": "list of all form fields with their labels and types", - "required_fields": "list of required field names", - "form_title": "the title or heading of the form", - }) - logger.info(f"📋 Form analysis: {form_analysis}") - self.form_fields = form_analysis - except Exception as e: - logger.warning(f"Could not extract form fields: {e}") - - # Questions already initialized in __init__, no need to recreate logger.info(f"✅ Browser ready, form can now be filled") except Exception as e: @@ -177,9 +178,8 @@ async def _initialize_browser(self): finally: self.browser_initializing = False - def _create_questions_from_analysis(self, form_analysis: Dict[str, Any]) -> list[FormQuestion]: - """Create questions based on form analysis""" - + def _create_questions(self) -> list[FormQuestion]: + """Create questions for the form""" # Define questions for the form fields we know about # This matches the form at https://forms.fillout.com/t/34ccsqafUFus form_questions = [ @@ -201,12 +201,6 @@ def _create_questions_from_analysis(self, form_analysis: Dict[str, Any]) -> list field_type="phone", required=False ), - FormQuestion( - field_name="address", - question="What is your current address? Please include street address, city, state, and zip code.", - field_type="address", - required=True - ), FormQuestion( field_name="work_eligibility", question="Are you legally eligible to work in this country?", @@ -359,6 +353,7 @@ async def process_context( logger.info(f"📊 Collected data for {len(self.collected_data)} fields") submission_success = await self._submit_form() + self.form_submitted = True if submission_success: goodbye = "Perfect! I've submitted your application. Thank you!" @@ -366,8 +361,7 @@ async def process_context( goodbye = "Thank you for providing all the information. Your responses have been recorded." # Clean up - if self.stagehand_filler: - await self.stagehand_filler.cleanup() + await self.cleanup_and_submit() # End call args = EndCallArgs(goodbye_message=goodbye) diff --git a/examples/integrations/cartesia/main.py b/examples/integrations/cartesia/main.py index 1e228f0..45dae78 100644 --- a/examples/integrations/cartesia/main.py +++ b/examples/integrations/cartesia/main.py @@ -12,7 +12,7 @@ from line.events import UserStartedSpeaking, UserStoppedSpeaking, UserTranscriptionReceived # Target form URL - the actual web form to fill -FORM_URL = "https://forms.fillout.com/t/34ccsqafUFus" +FORM_URL = "https://forms.fillout.com/t/rff6XZTSApus" # Initialize Gemini client gemini_client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) @@ -54,8 +54,11 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): # Start the system await system.start() - # Send initial greeting (will be handled by form_node) + # Wait for call to end await system.wait_for_shutdown() + + # Ensure form is submitted when call ends + await form_node.cleanup_and_submit() # Create the voice agent application diff --git a/examples/integrations/cartesia/stagehand_form_filler.py b/examples/integrations/cartesia/stagehand_form_filler.py index 6d73125..d730356 100644 --- a/examples/integrations/cartesia/stagehand_form_filler.py +++ b/examples/integrations/cartesia/stagehand_form_filler.py @@ -10,7 +10,6 @@ from loguru import logger from stagehand import Stagehand, StagehandConfig -from pydantic import BaseModel class FieldType(Enum): @@ -21,7 +20,6 @@ class FieldType(Enum): RADIO = "radio" CHECKBOX = "checkbox" TEXTAREA = "textarea" - ADDRESS = "address" @dataclass @@ -30,7 +28,6 @@ class FormField: field_id: str field_type: FieldType label: str - selector: Optional[str] = None required: bool = False options: Optional[list] = None @@ -39,8 +36,7 @@ class FormFieldMapping: """Maps conversation questions to actual form fields""" def __init__(self): - # Page 1 - Basic Information - self.basic_info_mappings = { + self.field_mappings = { "full_name": FormField( field_id="full_name", field_type=FieldType.TEXT, @@ -59,34 +55,6 @@ def __init__(self): label="What is your phone number?", required=False, ), - "address": FormField( - field_id="address", - field_type=FieldType.ADDRESS, - label="What is your current address?", - required=True, - ), - "city": FormField( - field_id="city", - field_type=FieldType.TEXT, - label="City", - required=True, - ), - "state": FormField( - field_id="state", - field_type=FieldType.TEXT, - label="State / Province", - required=True, - ), - "zip": FormField( - field_id="zip", - field_type=FieldType.TEXT, - label="ZIP / Postal code", - required=True, - ), - } - - # Page 2 - Availability - self.availability_mappings = { "work_eligibility": FormField( field_id="work_eligibility", field_type=FieldType.RADIO, @@ -101,20 +69,12 @@ def __init__(self): options=["Temporary", "Part-time", "Full-time"], required=True, ), - } - - # Page 3 - Additional Information - self.additional_info_mappings = { "additional_info": FormField( field_id="additional_info", field_type=FieldType.TEXTAREA, label="Anything else you'd like to let us know about you?", required=False, ), - } - - # Page 4 - Role Information - self.role_mappings = { "role_selection": FormField( field_id="role_selection", field_type=FieldType.CHECKBOX, @@ -136,80 +96,10 @@ def __init__(self): required=True, ), } - - # Combined mappings for easy lookup - self.field_mappings = { - **self.basic_info_mappings, - **self.availability_mappings, - **self.additional_info_mappings, - **self.role_mappings, - } def get_form_field(self, question_id: str) -> Optional[FormField]: """Get the form field mapping for a question ID""" return self.field_mappings.get(question_id) - - def transform_answer(self, question_id: str, answer: str) -> str: - """Transform voice answer to form-compatible format""" - field = self.get_form_field(question_id) - if not field: - return answer - - # Handle specific transformations - if question_id == "role_selection": - # Map voice responses to exact form options - role_mapping = { - "sales": "Sales manager", - "sales manager": "Sales manager", - "it": "IT Support", - "it support": "IT Support", - "tech support": "IT Support", - "technical support": "IT Support", - "recruiting": "Recruiting", - "recruiter": "Recruiting", - "software": "Software engineer", - "software engineer": "Software engineer", - "developer": "Software engineer", - "programming": "Software engineer", - "marketing": "Marketing specialist", - "marketing specialist": "Marketing specialist", - } - answer_lower = answer.lower().strip() - return role_mapping.get(answer_lower, answer) - - elif question_id == "availability_type": - # Map availability responses - availability_mapping = { - "temp": "Temporary", - "temporary": "Temporary", - "part": "Part-time", - "part-time": "Part-time", - "part time": "Part-time", - "full": "Full-time", - "full-time": "Full-time", - "full time": "Full-time", - } - answer_lower = answer.lower().strip() - return availability_mapping.get(answer_lower, answer) - - elif question_id in ["work_eligibility", "previous_experience"]: - # Convert boolean-like responses to Yes/No - if answer.lower().strip() in ["true", "yes", "yeah", "yep", "sure", "of course", "definitely"]: - return "Yes" - elif answer.lower().strip() in ["false", "no", "nope", "not really", "nah"]: - return "No" - else: - return answer - - elif question_id in ["full_name", "email", "phone", "address", "city", "state", "zip"]: - # Clean up basic text fields - return answer.strip() - - elif question_id in ["additional_info", "skills_experience"]: - # Keep text areas as-is but clean whitespace - return answer.strip() - - return answer class StagehandFormFiller: @@ -234,7 +124,7 @@ async def initialize(self): # Configure Stagehand config = StagehandConfig( - env="BROWSERBASE", # Use local browser + env="BROWSERBASE", model_name="google/gemini-2.0-flash-exp", # Fast model for form filling model_api_key=os.getenv("GEMINI_API_KEY"), ) @@ -272,35 +162,32 @@ async def fill_field(self, question_id: str, answer: str) -> bool: logger.warning(f"⚠️ No field mapping found for question: {question_id}") return False - # Transform answer for the form - transformed_answer = self.field_mapper.transform_answer(question_id, answer) - self.collected_data[question_id] = transformed_answer + # Store and use the answer directly + answer = answer.strip() + self.collected_data[question_id] = answer - logger.info(f"🖊️ Async filling field '{field.label}' with: {transformed_answer}") + logger.info(f"🖊️ Async filling field '{field.label}' with: {answer}") # Create async task for the actual field filling fill_action = None # Use Stagehand's natural language API to fill the field if field.field_type in [FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE]: - fill_action = self.page.act(f"Fill in the '{field.label}' field with: {transformed_answer}") - - elif field.field_type == FieldType.ADDRESS: - fill_action = self.page.act(f"Fill in the address field with: {transformed_answer}") + fill_action = self.page.act(f"Fill in the '{field.label}' field with: {answer}") elif field.field_type == FieldType.TEXTAREA: - fill_action = self.page.act(f"Type in the '{field.label}' text area: {transformed_answer}") + fill_action = self.page.act(f"Type in the '{field.label}' text area: {answer}") elif field.field_type in [FieldType.SELECT, FieldType.RADIO]: - fill_action = self.page.act(f"Select '{transformed_answer}' for the '{field.label}' field") + fill_action = self.page.act(f"Select '{answer}' for the '{field.label}' field") elif field.field_type == FieldType.CHECKBOX: # For role selection, check the specific role checkbox if question_id == "role_selection": - fill_action = self.page.act(f"Check the '{transformed_answer}' checkbox") + fill_action = self.page.act(f"Check the '{answer}' checkbox") else: # For other checkboxes, check/uncheck based on answer - if transformed_answer.lower() in ["yes", "true"]: + if answer.lower() in ["yes", "true"]: fill_action = self.page.act(f"Check the '{field.label}' checkbox") else: fill_action = self.page.act(f"Uncheck the '{field.label}' checkbox") @@ -315,120 +202,24 @@ async def fill_field(self, question_id: str, answer: str) -> bool: logger.error(f"❌ Error filling field {question_id}: {e}") return False - async def fill_collected_data(self): - """Fill in all collected data from the conversation""" - logger.info("👤 Filling collected information from conversation") - - # Fill all collected data - for field_name, value in self.collected_data.items(): - field = self.field_mapper.get_form_field(field_name) - if field and value: - logger.info(f"📝 Filling {field_name}: {value}") - - # Parse address components if it's an address field - if field_name == "address" and "," in value: - # Try to parse address components - parts = [p.strip() for p in value.split(",")] - if len(parts) >= 4: - # Assume format: street, city, state, zip - await self.page.act(f"Fill in the 'Address' field with: {parts[0]}") - await self.page.act(f"Fill in the 'City' field with: {parts[1]}") - await self.page.act(f"Fill in the 'State / Province' field with: {parts[2]}") - await self.page.act(f"Fill in the 'ZIP / Postal code' field with: {parts[3]}") - else: - await self.page.act(f"Fill in the '{field.label}' field with: {value}") - else: - await self.page.act(f"Fill in the '{field.label}' field with: {value}") - - await asyncio.sleep(0.5) # Small delay between fields - - async def navigate_to_next_page(self): - """Navigate to the next page of the form if multi-page (non-blocking)""" - try: - # Create async task for navigation - nav_task = asyncio.create_task( - self.page.act("Click the Next or Continue button") - ) - await nav_task - - # Small async delay for page transition - await asyncio.sleep(1.5) - return True - except Exception as e: - logger.debug(f"No next button found or single-page form: {e}") - return False - async def submit_form(self) -> bool: - """Submit the completed form (fully async)""" + """Submit the completed form""" try: - logger.info("📤 Attempting to submit the form") - logger.info(f"📊 Form has {len(self.collected_data)} fields already filled in real-time") + logger.info("📤 Submitting the form") + logger.info(f"📊 Form has {len(self.collected_data)} fields filled") - # Data has already been filled in real-time during conversation - # Just navigate and submit + await self.page.act("Find and click the Submit button to submit the form") - # Navigate through pages if needed (async) - nav_result = await self.navigate_to_next_page() + # Wait for submission to process + await asyncio.sleep(1) - # Submit the form asynchronously - submit_task = asyncio.create_task( - self.page.act("Click the Submit button") - ) - await submit_task - - # Wait for submission confirmation (non-blocking) - await asyncio.sleep(2.5) - - # Check for success message asynchronously - try: - extract_task = asyncio.create_task( - self.page.extract({ - "success_indicator": "boolean indicating if form was submitted successfully" - }) - ) - success_check = await extract_task - - if success_check and hasattr(success_check, 'success_indicator'): - logger.info("✅ Form submitted successfully!") - return True - elif success_check: - logger.info("✅ Form submission completed") - return True - except Exception as e: - logger.warning(f"⚠️ Could not verify submission: {e}") - - logger.info("📝 Form submission process completed") - return True # Assume success if no errors + logger.info("✅ Form submitted successfully!") + return True except Exception as e: logger.error(f"❌ Error submitting form: {e}") return False - async def get_form_progress(self) -> Dict[str, Any]: - """Get current progress of form filling""" - if not self.is_initialized: - return {"status": "not_started", "fields_filled": 0} - - try: - # Use Stagehand to extract form progress - progress = await self.page.extract({ - "filled_fields": "number of fields that have been filled", - "total_fields": "total number of fields in the form", - "current_page": "current page number if multi-page form", - "total_pages": "total pages if multi-page form", - }) - - return { - "status": "in_progress", - "fields_filled": len(self.collected_data), - "form_state": progress, - "collected_data": self.collected_data, - } - - except Exception as e: - logger.error(f"Error getting form progress: {e}") - return {"status": "error", "message": str(e)} - async def cleanup(self): """Clean up browser resources""" if self.stagehand and self.page: From a39cb10405815b1d4e4b5df6cc5b9f0d279a9139 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Thu, 16 Oct 2025 13:49:05 -0700 Subject: [PATCH 07/10] update readme for line + config --- examples/integrations/cartesia/README.md | 78 ++++++++++++++-------- examples/integrations/cartesia/config.toml | 2 +- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/examples/integrations/cartesia/README.md b/examples/integrations/cartesia/README.md index fda907b..2071ec8 100644 --- a/examples/integrations/cartesia/README.md +++ b/examples/integrations/cartesia/README.md @@ -2,6 +2,8 @@ This project demonstrates an advanced voice agent that conducts phone questionnaires while automatically filling out web forms in real-time using Stagehand browser automation. +![Workflow](workflow_diagram.png) + ## Features - **Voice Conversations**: Natural voice interactions using Cartesia Line @@ -25,6 +27,27 @@ Voice Call (Cartesia) → Form Filling Node → Records Answer Submits Form on Completion ``` +## Getting Started + +First things first, here is what you will need: +- A [Cartesia](https://play.cartesia.ai/agents) account and API key +- A [Gemini API Key](https://aistudio.google.com/apikey) +- A [Browserbase API Key](https://www.browserbase.com/) (optional, for cloud browser automation) + +Make sure to add the API keys in your `.env` file or to the API keys section in your Cartesia account. + +- Required packages: + ```bash + cartesia-line + stagehand>=0.1.0 + google-genai>=1.26.0 + python-dotenv>=1.0.0 + PyYAML>=6.0.0 + loguru>=0.7.0 + aiohttp>=3.12.0 + pydantic>=2.0.0 + ``` + ## Setup 1. Install dependencies: @@ -32,10 +55,10 @@ Voice Call (Cartesia) → Form Filling Node → Records Answer pip install -r requirements.txt ``` -2. Set up environment variables: +2. Set up environment variables - create a `.env` file: ```bash -cp .env.example .env -# Add your GEMINI_API_KEY +GEMINI_API_KEY=your_gemini_api_key_here +BROWSERBASE_API_KEY=your_browserbase_api_key_here # Optional ``` 3. Run the agent: @@ -43,33 +66,34 @@ cp .env.example .env python main.py ``` -## Components +## Project Structure + +### `main.py` +Entry point for the voice agent. Handles call initialization with `VoiceAgentApp` class and orchestrates the conversation flow with form filling integration. -### StagehandFormFiller -- Manages browser automation -- Opens and controls web forms -- Maps conversation data to form fields -- Handles form submission +### `form_filling_node.py` +ReasoningNode subclass customized for voice-optimized form filling. Integrates Stagehand browser automation and manages async form filling during conversation without blocking the voice flow. Provides status updates and error handling. -### FormFillingNode -- Voice-optimized reasoning node -- Integrates Stagehand browser automation -- Manages async form filling during conversation -- Provides status updates +### `stagehand_form_filler.py` +Browser automation manager that handles all web interactions. Opens and controls web forms, maps conversation data to form fields using AI, transforms voice answers to form-compatible formats, and handles form submission. Supports different field types (text, select, checkbox, etc.). -### FormFieldMapping -- Maps YAML questions to web form fields -- Transforms voice answers to form-compatible formats -- Handles different field types (text, select, checkbox, etc.) +### `config.py` +System configuration file including system prompts, model IDs, hyperparameters, and boolean flags for features like enabling/disabling browser automation and headless mode. + +### `config.toml` +Additional configuration for questionnaire structure and application-specific settings. ## Configuration -The system can be configured through: +The system can be configured through multiple files: -- `form.yaml`: Define questionnaire structure -- `FORM_URL`: Target web form to fill -- `headless`: Run browser in background (True) or visible (False) - currently set to True for production use -- `enable_browser`: Toggle browser automation on/off +- **`config.py`**: System prompts, model IDs (Gemini model selection), hyperparameters, and boolean flags for features +- **`config.toml`** / **YAML files**: Questionnaire structure and questions flow +- **`cartesia.toml`**: Deployment configuration for Cartesia platform (installs dependencies and runs the script) +- **Environment variables**: + - `FORM_URL`: Target web form to fill + - `headless`: Run browser in background (True) or visible (False) - currently set to True for production use + - `enable_browser`: Toggle browser automation on/off ## Example Flow @@ -93,11 +117,13 @@ The system can be configured through: - **Screenshot Debugging**: Captures screenshots after each field - **Flexible Mapping**: AI interprets answers for different field types -## Deployment +## Deploying the Agent + +The `cartesia.toml` file defines how your agent will be installed and run when deployed on the Cartesia platform. This file tells the platform to install dependencies from `requirements.txt` and execute `main.py`. -Note: you need to set env variables in the Cartesia Platform to properly deploy. +You can clone this repository and add it to your [agents dashboard](https://play.cartesia.ai/agents) along with your API Keys (set them in the Cartesia Platform's API keys section). -[Here's how to deploy an agent from the Cartesia Docs.](https://docs.cartesia.ai/line/start-building/talk-to-your-first-agent) +For detailed deployment instructions, see [how to deploy an agent from the Cartesia Docs](https://docs.cartesia.ai/line/start-building/talk-to-your-first-agent). ## Testing diff --git a/examples/integrations/cartesia/config.toml b/examples/integrations/cartesia/config.toml index 4251232..2355be8 100644 --- a/examples/integrations/cartesia/config.toml +++ b/examples/integrations/cartesia/config.toml @@ -1 +1 @@ -agent-id = 'agent_NKsQKSxugbsoA3ByZrJVQY' +agent-id = 'your-agent-id' From 100719a21869d4bf1d0aa6f6bcc0e0eab09b85b9 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Thu, 16 Oct 2025 14:28:52 -0700 Subject: [PATCH 08/10] update to meet google code quality standards --- examples/integrations/cartesia/config.py | 19 +- .../cartesia/form_filling_node.py | 321 ++++++++++++------ examples/integrations/cartesia/main.py | 40 ++- .../cartesia/stagehand_form_filler.py | 126 +++++-- 4 files changed, 361 insertions(+), 145 deletions(-) diff --git a/examples/integrations/cartesia/config.py b/examples/integrations/cartesia/config.py index a82700c..e8b9bc2 100644 --- a/examples/integrations/cartesia/config.py +++ b/examples/integrations/cartesia/config.py @@ -1,3 +1,9 @@ +"""Configuration settings for the voice agent. + +This module contains system prompts, model configurations, and +hyperparameters for the Cartesia voice agent with form filling. +""" + import os DEFAULT_MODEL_ID = os.getenv("MODEL_ID", "gemini-2.5-flash") @@ -10,17 +16,20 @@ If a user's answer is unclear, ask for clarification. For sensitive information, be especially tactful and professional. -IMPORTANT: When you receive a clear answer from the user, use the record_answer tool to record their response. +IMPORTANT: When you receive a clear answer from the user, use the +record_answer tool to record their response. ### Your tone When having a conversation, you should: - Always polite and respectful, even when users are challenging -- Concise and brief but never curt. Keep your responses to 1-2 sentences and less than 35 words +- Concise and brief but never curt. Keep your responses to 1-2 + sentences and less than 35 words - When asking a question, be sure to ask in a short and concise manner - Only ask one question at a time -If the user is rude, or curses, respond with exceptional politeness and genuine curiosity. -You should always be polite. +If the user is rude, or curses, respond with exceptional politeness +and genuine curiosity. You should always be polite. -Remember, you're on the phone, so do not use emojis or abbreviations. Spell out units and dates. +Remember, you're on the phone, so do not use emojis or abbreviations. +Spell out units and dates. """ diff --git a/examples/integrations/cartesia/form_filling_node.py b/examples/integrations/cartesia/form_filling_node.py index 63ba171..ca9d859 100644 --- a/examples/integrations/cartesia/form_filling_node.py +++ b/examples/integrations/cartesia/form_filling_node.py @@ -1,10 +1,14 @@ -""" -FormFillingNode - Voice agent that fills web forms in real-time using Stagehand +"""Voice agent that fills web forms in real-time using Stagehand. + +This module implements a ReasoningNode subclass that conducts voice +conversations while automatically filling web forms in the background. +The agent uses Stagehand for browser automation and handles async form +filling during conversation without blocking the voice flow. """ import asyncio -from typing import AsyncGenerator, Union, Optional, Dict, Any from dataclasses import dataclass +from typing import Any, AsyncGenerator, Dict, Optional, Union from config import DEFAULT_MODEL_ID, DEFAULT_TEMPERATURE from stagehand_form_filler import StagehandFormFiller @@ -42,7 +46,11 @@ def parameters() -> dict: @staticmethod def to_gemini_tool(): - """Convert to Gemini tool format""" + """Convert to Gemini tool format. + + Returns: + A Gemini Tool object with function declarations. + """ return gemini_types.Tool( function_declarations=[ gemini_types.FunctionDeclaration( @@ -64,11 +72,11 @@ class FormQuestion: class FormFillingNode(ReasoningNode): - """ - Voice agent that fills actual web forms while conducting conversations. - - Uses Stagehand to read and fill web forms dynamically - - Maintains conversation flow while automating browser actions - - Intelligently extracts form structure and asks relevant questions + """Voice agent that fills web forms while conducting conversations. + + This class uses Stagehand to read and fill web forms dynamically, + maintains conversation flow while automating browser actions, and + intelligently extracts form structure and asks relevant questions. """ def __init__( @@ -82,19 +90,22 @@ def __init__( max_output_tokens: int = 1000, headless: bool = False, ): - """ - Initialize the Form Filling node with Stagehand integration + """Initialize the Form Filling node with Stagehand integration. Args: - system_prompt: System prompt for the LLM - gemini_client: Google Gemini client instance - form_url: URL of the web form to fill - model_id: Gemini model ID - temperature: Temperature for generation - max_context_length: Maximum conversation context length - headless: Run browser in headless mode + system_prompt: System prompt for the LLM. + gemini_client: Google Gemini client instance. + form_url: URL of the web form to fill. + model_id: Gemini model ID. + temperature: Temperature for generation. + max_context_length: Maximum conversation context length. + max_output_tokens: Maximum tokens for generation. + headless: Run browser in headless mode. """ - super().__init__(system_prompt=system_prompt, max_context_length=max_context_length) + super().__init__( + system_prompt=system_prompt, + max_context_length=max_context_length + ) self.client = gemini_client self.model_id = model_id @@ -118,11 +129,12 @@ def __init__( # Enhanced prompt for form filling enhanced_prompt = system_prompt + """ - You are conducting a voice conversation to help fill out a web form. - As you collect information, it's being entered into an actual online form in real-time. - Ask natural questions to gather the required information. - Use the record_form_field tool to save each piece of information. - Keep the conversation friendly and natural. + You are conducting a voice conversation to help fill out a web + form. As you collect information, it's being entered into an + actual online form in real-time. Ask natural questions to gather + the required information. Use the record_form_field tool to save + each piece of information. Keep the conversation friendly and + natural. """ # Generation config @@ -131,19 +143,30 @@ def __init__( temperature=self.temperature, tools=[RecordFormFieldTool.to_gemini_tool()], max_output_tokens=max_output_tokens, - thinking_config=gemini_types.ThinkingConfig(thinking_budget=0), + thinking_config=gemini_types.ThinkingConfig( + thinking_budget=0 + ), ) - logger.info(f"🚀 FormFillingNode initialized for form: {form_url}") + logger.info( + f"FormFillingNode initialized for form: {form_url}" + ) # Track if form was submitted self.form_submitted = False async def cleanup_and_submit(self): - """Ensure form is submitted and cleanup when call ends""" + """Ensure form is submitted and cleanup when call ends. + + Returns: + None. + """ # Submit form if we have any data and haven't submitted yet - if not self.form_submitted and self.collected_data and self.stagehand_filler: - logger.info("🔄 Call ending - auto-submitting form with collected data") + if (not self.form_submitted and self.collected_data and + self.stagehand_filler): + logger.info( + "Call ending - auto-submitting form with collected data" + ) try: await self._submit_form() except Exception as e: @@ -154,34 +177,44 @@ async def cleanup_and_submit(self): await self.stagehand_filler.cleanup() async def _initialize_browser(self): - """Initialize browser and extract form fields""" + """Initialize browser and extract form fields. + + Returns: + None. + """ # Prevent multiple initializations if self.browser_initializing or self.stagehand_filler: - logger.info("🔒 Browser already initializing or initialized, skipping") + logger.info( + "Browser already initializing or initialized, skipping" + ) return self.browser_initializing = True try: - logger.info("🌐 Initializing browser and analyzing form") + logger.info("Initializing browser and analyzing form") self.stagehand_filler = StagehandFormFiller( form_url=self.form_url, headless=self.headless ) await self.stagehand_filler.initialize() - logger.info(f"✅ Browser ready, form can now be filled") + logger.info("Browser ready, form can now be filled") except Exception as e: - logger.error(f"❌ Failed to initialize browser: {e}") + logger.error(f"Failed to initialize browser: {e}") self.browser_initializing = False raise finally: self.browser_initializing = False def _create_questions(self) -> list[FormQuestion]: - """Create questions for the form""" + """Create questions for the form. + + Returns: + A list of FormQuestion objects to ask the user. + """ # Define questions for the form fields we know about - # This matches the form at https://forms.fillout.com/t/34ccsqafUFus + # This matches form at https://forms.fillout.com/t/34ccsqafUFus form_questions = [ FormQuestion( field_name="full_name", @@ -209,13 +242,20 @@ def _create_questions(self) -> list[FormQuestion]: ), FormQuestion( field_name="availability_type", - question="What's your availability - temporary, part-time, or full-time?", + question=( + "What's your availability - temporary, part-time, " + "or full-time?" + ), field_type="radio", required=True ), FormQuestion( field_name="role_selection", - question="Which role are you applying for? We have openings for Sales Manager, IT Support, Recruiting, Software Engineer, or Marketing Specialist.", + question=( + "Which role are you applying for? We have openings " + "for Sales Manager, IT Support, Recruiting, " + "Software Engineer, or Marketing Specialist." + ), field_type="checkbox", required=True ), @@ -227,13 +267,19 @@ def _create_questions(self) -> list[FormQuestion]: ), FormQuestion( field_name="skills_experience", - question="What relevant skills and experience do you have that make you a strong candidate for this position?", + question=( + "What relevant skills and experience do you have " + "that make you a strong candidate for this position?" + ), field_type="textarea", required=True ), FormQuestion( field_name="additional_info", - question="Is there anything else you'd like to tell us about yourself?", + question=( + "Is there anything else you'd like to tell us " + "about yourself?" + ), field_type="textarea", required=False ), @@ -242,52 +288,77 @@ def _create_questions(self) -> list[FormQuestion]: return form_questions async def _fill_form_field_async(self, field_name: str, value: str): - """Fill a form field asynchronously in background (non-blocking)""" + """Fill a form field asynchronously in background (non-blocking). + + Args: + field_name: The name of the form field to fill. + value: The value to enter in the field. + """ try: # Wait for browser initialization if needed if self.browser_init_task: - logger.info(f"⏳ Waiting for browser to initialize before filling {field_name}") + logger.info( + f"Waiting for browser to initialize before filling " + f"{field_name}" + ) await self.browser_init_task - logger.info(f"🖊️ Filling field '{field_name}' with: {value} in background") - # Use StagehandFormFiller's fill_field method which handles the mapping - success = await self.stagehand_filler.fill_field(field_name, value) + logger.info( + f"Filling field '{field_name}' with: {value} in background" + ) + # Use StagehandFormFiller's fill_field method which + # handles the mapping + success = await self.stagehand_filler.fill_field( + field_name, value + ) if success: - logger.info(f"✅ Successfully filled field: {field_name} in the browser") + logger.info( + f"Successfully filled field: {field_name} in browser" + ) else: - logger.warning(f"⚠️ Failed to fill field: {field_name}") + logger.warning(f"Failed to fill field: {field_name}") except Exception as e: logger.error(f"Error filling field {field_name}: {e}") raise # Re-raise so background task can catch it async def _submit_form(self): - """Submit the completed form""" + """Submit the completed form. + + Returns: + True if submission succeeded, False otherwise. + """ # Wait for browser initialization if needed if self.browser_init_task and not self.stagehand_filler: - logger.info("⏳ Waiting for browser to initialize before submitting form") + logger.info( + "Waiting for browser to initialize before submitting form" + ) await self.browser_init_task if not self.stagehand_filler: return False try: - logger.info("📤 Submitting web form with collected data") - logger.info(f"📊 Data collected: {self.collected_data}") + logger.info("Submitting web form with collected data") + logger.info(f"Data collected: {self.collected_data}") # Ensure StagehandFormFiller has all collected data - # (it should already have it from fill_field calls, but ensure consistency) - self.stagehand_filler.collected_data.update(self.collected_data) + # (it should already have it from fill_field calls, + # but ensure consistency) + self.stagehand_filler.collected_data.update( + self.collected_data + ) - # Use StagehandFormFiller's submit_form method which now uses collected_data + # Use StagehandFormFiller's submit_form method which now + # uses collected_data success = await self.stagehand_filler.submit_form() if success: - logger.info("✅ Form submitted successfully!") + logger.info("Form submitted successfully!") return True else: - logger.warning("⚠️ Form submission may have failed") + logger.warning("Form submission may have failed") return False except Exception as e: @@ -295,7 +366,11 @@ async def _submit_form(self): return False def get_current_question(self) -> Optional[FormQuestion]: - """Get the current question to ask""" + """Get the current question to ask. + + Returns: + The current FormQuestion or None if all questions answered. + """ if self.current_question_index < len(self.questions): return self.questions[self.current_question_index] return None @@ -303,62 +378,93 @@ def get_current_question(self) -> Optional[FormQuestion]: async def process_context( self, context: ConversationContext ) -> AsyncGenerator[Union[AgentResponse, EndCall], None]: - """ - Process conversation context with real-time form filling + """Process conversation context with real-time form filling. + + Args: + context: The conversation context with events. Yields: - AgentResponse: Text responses to the user - EndCall: Call termination when form is complete + AgentResponse: Text responses to the user. + EndCall: Call termination when form is complete. """ # Initialize browser on first call (non-blocking) if not self.browser_init_task and not self.stagehand_filler: - self.browser_init_task = asyncio.create_task(self._initialize_browser()) - logger.info("🚀 Browser initialization started in background") + self.browser_init_task = asyncio.create_task( + self._initialize_browser() + ) + logger.info("Browser initialization started in background") # Get current question after initialization current_question = self.get_current_question() - logger.info(f"📊 Current question: {current_question.field_name if current_question else 'None'}") - logger.info(f"📊 Question index: {self.current_question_index}/{len(self.questions)}") - logger.info(f"📊 Events count: {len(context.events)}") + question_name = ( + current_question.field_name if current_question else 'None' + ) + logger.info(f"Current question: {question_name}") + logger.info( + f"Question index: {self.current_question_index}/" + f"{len(self.questions)}" + ) + logger.info(f"Events count: {len(context.events)}") # Check latest event to determine what to do latest_event = context.events[-1] if context.events else None - is_agent_response = isinstance(latest_event, AgentResponse) if latest_event else False + is_agent_response = ( + isinstance(latest_event, AgentResponse) if latest_event + else False + ) # Handle initial greeting - speak first when conversation starts if not context.events: - logger.info(f"📝 Starting conversation - Agent speaks first") + logger.info("Starting conversation - Agent speaks first") initial_greeting = ( - "Hello! I'm here to help you fill out an application form today. " - "I'll ask you a series of questions and fill in the form as we go. " - "Ready to get started?" + "Hello! I'm here to help you fill out an application " + "form today. I'll ask you a series of questions and " + "fill in the form as we go. Ready to get started?" ) yield AgentResponse(content=initial_greeting) return - # If last event was our greeting, and user responded, ask first question - if len(context.events) == 2 and is_agent_response == False and self.current_question_index == 0: + # If last event was our greeting, and user responded, ask + # first question + if (len(context.events) == 2 and not is_agent_response and + self.current_question_index == 0): user_message = context.get_latest_user_transcript_message() if user_message and current_question: - logger.info(f"📝 User ready to start: '{user_message}'") - logger.info(f"📝 Asking first question: {current_question.field_name}") - yield AgentResponse(content=f"Great! Let's begin. {current_question.question}") + logger.info(f"User ready to start: '{user_message}'") + logger.info( + f"Asking first question: " + f"{current_question.field_name}" + ) + yield AgentResponse( + content=f"Great! Let's begin. " + f"{current_question.question}" + ) return # Check if all questions have been answered # Only submit if we've actually collected data - if not current_question and self.current_question_index > 0 and len(self.collected_data) > 0: + if (not current_question and self.current_question_index > 0 and + len(self.collected_data) > 0): # All questions answered - submit the form - logger.info(f"📋 All {self.current_question_index} questions answered") - logger.info(f"📊 Collected data for {len(self.collected_data)} fields") + logger.info( + f"All {self.current_question_index} questions answered" + ) + logger.info( + f"Collected data for {len(self.collected_data)} fields" + ) submission_success = await self._submit_form() self.form_submitted = True if submission_success: - goodbye = "Perfect! I've submitted your application. Thank you!" + goodbye = ( + "Perfect! I've submitted your application. Thank you!" + ) else: - goodbye = "Thank you for providing all the information. Your responses have been recorded." + goodbye = ( + "Thank you for providing all the information. " + "Your responses have been recorded." + ) # Clean up await self.cleanup_and_submit() @@ -371,11 +477,16 @@ async def process_context( # Guard against no questions or empty state if not current_question and self.current_question_index == 0: - logger.warning("⚠️ No questions available or not properly initialized") + logger.warning( + "No questions available or not properly initialized" + ) return # Process user response - messages = convert_messages_to_gemini(context.events, text_events_only=True) + messages = convert_messages_to_gemini( + context.events, + text_events_only=True + ) # Add context about current question question_context = f""" @@ -383,22 +494,27 @@ async def process_context( Current form field: {current_question.field_name} Question: {current_question.question} - Listen to the user's response and use the record_form_field tool to save it. - Then acknowledge their answer naturally. + Listen to the user's response and use the record_form_field + tool to save it. Then acknowledge their answer naturally. """ enhanced_config = gemini_types.GenerateContentConfig( - system_instruction=self.generation_config.system_instruction + question_context, + system_instruction=( + self.generation_config.system_instruction + + question_context + ), temperature=self.temperature, tools=[RecordFormFieldTool.to_gemini_tool()], max_output_tokens=self.generation_config.max_output_tokens, - thinking_config=gemini_types.ThinkingConfig(thinking_budget=0), + thinking_config=gemini_types.ThinkingConfig( + thinking_budget=0 + ), ) # Get user's latest message user_message = context.get_latest_user_transcript_message() if user_message: - logger.info(f'🎤 User response: "{user_message}"') + logger.info(f'User response: "{user_message}"') # Stream Gemini response full_response = "" @@ -418,18 +534,24 @@ async def process_context( if msg.function_calls: for function_call in msg.function_calls: if function_call.name == RecordFormFieldTool.name(): - field_name = function_call.args.get("field_name", current_question.field_name) + field_name = function_call.args.get( + "field_name", current_question.field_name + ) value = function_call.args.get("value", "") - logger.info(f"📝 Recording: {field_name} = {value}") + logger.info(f"Recording: {field_name} = {value}") # Store data first self.collected_data[field_name] = value - # Fill the form field asynchronously in background (non-blocking) - asyncio.create_task(self._fill_form_field_async(field_name, value)) + # Fill the form field asynchronously in background + # (non-blocking) + asyncio.create_task( + self._fill_form_field_async(field_name, value) + ) # Log the collected data - logger.info(f"📊 Collected: {field_name}={value}") - # Move to next question immediately (don't wait for form filling) + logger.info(f"Collected: {field_name}={value}") + # Move to next question immediately + # (don't wait for form filling) self.current_question_index += 1 field_recorded = True @@ -439,14 +561,19 @@ async def process_context( # Get next question next_question = self.get_current_question() if next_question: - yield AgentResponse(content=f"Great! {next_question.question}") + yield AgentResponse( + content=f"Great! {next_question.question}" + ) # Yield tool result immediately yield ToolResult( tool_name="record_form_field", - tool_args={"field_name": field_name, "value": value}, + tool_args={ + "field_name": field_name, + "value": value + }, result=f"Recorded: {field_name}={value}" ) if full_response: - logger.info(f'🤖 Agent response: "{full_response}"') \ No newline at end of file + logger.info(f'Agent response: "{full_response}"') \ No newline at end of file diff --git a/examples/integrations/cartesia/main.py b/examples/integrations/cartesia/main.py index 45dae78..9f583d0 100644 --- a/examples/integrations/cartesia/main.py +++ b/examples/integrations/cartesia/main.py @@ -1,5 +1,8 @@ -""" -Cartesia Line Voice Agent with Real-time Web Form Filling using Stagehand +"""Cartesia Line Voice Agent with real-time web form filling. + +This module implements a voice agent that conducts phone questionnaires +while automatically filling out web forms in real-time using Stagehand +browser automation. """ import os @@ -9,7 +12,11 @@ from google import genai from line import Bridge, CallRequest, VoiceAgentApp, VoiceAgentSystem -from line.events import UserStartedSpeaking, UserStoppedSpeaking, UserTranscriptionReceived +from line.events import ( + UserStartedSpeaking, + UserStoppedSpeaking, + UserTranscriptionReceived +) # Target form URL - the actual web form to fill FORM_URL = "https://forms.fillout.com/t/rff6XZTSApus" @@ -18,14 +25,20 @@ gemini_client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) -async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): - """ - Handle incoming voice calls with real-time web form filling +async def handle_new_call( + system: VoiceAgentSystem, + call_request: CallRequest +) -> None: + """Handle incoming voice calls with real-time web form filling. This agent will: 1. Conduct a voice conversation to gather information 2. Open and fill an actual web form in the background 3. Submit the form when the conversation is complete + + Args: + system: The voice agent system instance. + call_request: The incoming call request. """ # Create form filling node with browser automation @@ -46,7 +59,10 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): # Handle interruptions and streaming ( form_bridge.on(UserStoppedSpeaking) - .interrupt_on(UserStartedSpeaking, handler=form_node.on_interrupt_generate) + .interrupt_on( + UserStartedSpeaking, + handler=form_node.on_interrupt_generate + ) .stream(form_node.generate) .broadcast() ) @@ -65,9 +81,11 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest): app = VoiceAgentApp(handle_new_call) if __name__ == "__main__": - print("🚀 Starting Voice Agent with Web Form Automation") - print(f"📝 Will fill form at: {FORM_URL}") - print("📞 Ready to receive calls...") + print("Starting Voice Agent with Web Form Automation") + print(f"Will fill form at: {FORM_URL}") + print("Ready to receive calls...") print("\nNote: The browser will run in background (headless mode).") - print("Form filling happens invisibly while processing voice calls.\n") + print( + "Form filling happens invisibly while processing voice calls.\n" + ) app.run() \ No newline at end of file diff --git a/examples/integrations/cartesia/stagehand_form_filler.py b/examples/integrations/cartesia/stagehand_form_filler.py index d730356..1d25d28 100644 --- a/examples/integrations/cartesia/stagehand_form_filler.py +++ b/examples/integrations/cartesia/stagehand_form_filler.py @@ -1,12 +1,15 @@ -""" -StagehandFormFiller - Browser automation for filling web forms during voice conversations +"""Browser automation for filling web forms during voice conversations. + +This module provides the StagehandFormFiller class which manages browser +automation for filling forms using Stagehand. It handles form field +mapping, field filling, and form submission. """ import asyncio import os -from typing import Dict, Optional, Any from dataclasses import dataclass from enum import Enum +from typing import Any, Dict, Optional from loguru import logger from stagehand import Stagehand, StagehandConfig @@ -79,26 +82,42 @@ def __init__(self): field_id="role_selection", field_type=FieldType.CHECKBOX, label="Which of these roles are you applying for?", - options=["Sales manager", "IT Support", "Recruiting", "Software engineer", "Marketing specialist"], + options=[ + "Sales manager", "IT Support", "Recruiting", + "Software engineer", "Marketing specialist" + ], required=True, ), "previous_experience": FormField( field_id="previous_experience", field_type=FieldType.RADIO, - label="Have you worked in a role similar to this one in the past?", + label=( + "Have you worked in a role similar to this one " + "in the past?" + ), options=["Yes", "No"], required=True, ), "skills_experience": FormField( field_id="skills_experience", field_type=FieldType.TEXTAREA, - label="What relevant skills and experience do you have that make you a strong candidate for this position?", + label=( + "What relevant skills and experience do you have " + "that make you a strong candidate for this position?" + ), required=True, ), } def get_form_field(self, question_id: str) -> Optional[FormField]: - """Get the form field mapping for a question ID""" + """Get the form field mapping for a question ID. + + Args: + question_id: The question identifier. + + Returns: + The FormField object or None if not found. + """ return self.field_mappings.get(question_id) @@ -115,17 +134,22 @@ def __init__(self, form_url: str, headless: bool = False): self.collected_data: Dict[str, str] = {} async def initialize(self): - """Initialize Stagehand and open the form""" + """Initialize Stagehand and open the form. + + Returns: + None. + """ if self.is_initialized: return try: - logger.info("🚀 Initializing Stagehand browser automation") + logger.info("Initializing Stagehand browser automation") # Configure Stagehand config = StagehandConfig( env="BROWSERBASE", - model_name="google/gemini-2.0-flash-exp", # Fast model for form filling + # Fast model for form filling + model_name="google/gemini-2.0-flash-exp", model_api_key=os.getenv("GEMINI_API_KEY"), ) @@ -135,21 +159,29 @@ async def initialize(self): self.page = self.stagehand.page # Navigate to form - logger.info(f"📝 Opening form: {self.form_url}") + logger.info(f"Opening form: {self.form_url}") await self.page.goto(self.form_url) # Wait for form to load await asyncio.sleep(2) self.is_initialized = True - logger.info("✅ Browser automation initialized successfully") + logger.info("Browser automation initialized successfully") except Exception as e: - logger.error(f"❌ Failed to initialize Stagehand: {e}") + logger.error(f"Failed to initialize Stagehand: {e}") raise async def fill_field(self, question_id: str, answer: str) -> bool: - """Fill a specific form field based on the question ID and answer (non-blocking)""" + """Fill a specific form field based on the question ID and answer. + + Args: + question_id: The question identifier. + answer: The answer value to fill. + + Returns: + True if field was filled successfully, False otherwise. + """ if not self.is_initialized: # Initialize asynchronously without blocking init_task = asyncio.create_task(self.initialize()) @@ -159,38 +191,56 @@ async def fill_field(self, question_id: str, answer: str) -> bool: # Get field mapping field = self.field_mapper.get_form_field(question_id) if not field: - logger.warning(f"⚠️ No field mapping found for question: {question_id}") + logger.warning( + f"No field mapping found for question: {question_id}" + ) return False # Store and use the answer directly answer = answer.strip() self.collected_data[question_id] = answer - logger.info(f"🖊️ Async filling field '{field.label}' with: {answer}") + logger.info( + f"Async filling field '{field.label}' with: {answer}" + ) # Create async task for the actual field filling fill_action = None # Use Stagehand's natural language API to fill the field - if field.field_type in [FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE]: - fill_action = self.page.act(f"Fill in the '{field.label}' field with: {answer}") + if field.field_type in [ + FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE + ]: + fill_action = self.page.act( + f"Fill in the '{field.label}' field with: {answer}" + ) elif field.field_type == FieldType.TEXTAREA: - fill_action = self.page.act(f"Type in the '{field.label}' text area: {answer}") + fill_action = self.page.act( + f"Type in the '{field.label}' text area: {answer}" + ) elif field.field_type in [FieldType.SELECT, FieldType.RADIO]: - fill_action = self.page.act(f"Select '{answer}' for the '{field.label}' field") + fill_action = self.page.act( + f"Select '{answer}' for the '{field.label}' field" + ) elif field.field_type == FieldType.CHECKBOX: # For role selection, check the specific role checkbox if question_id == "role_selection": - fill_action = self.page.act(f"Check the '{answer}' checkbox") + fill_action = self.page.act( + f"Check the '{answer}' checkbox" + ) else: # For other checkboxes, check/uncheck based on answer if answer.lower() in ["yes", "true"]: - fill_action = self.page.act(f"Check the '{field.label}' checkbox") + fill_action = self.page.act( + f"Check the '{field.label}' checkbox" + ) else: - fill_action = self.page.act(f"Uncheck the '{field.label}' checkbox") + fill_action = self.page.act( + f"Uncheck the '{field.label}' checkbox" + ) # Execute the fill action asynchronously if fill_action: @@ -199,32 +249,44 @@ async def fill_field(self, question_id: str, answer: str) -> bool: return True except Exception as e: - logger.error(f"❌ Error filling field {question_id}: {e}") + logger.error(f"Error filling field {question_id}: {e}") return False async def submit_form(self) -> bool: - """Submit the completed form""" + """Submit the completed form. + + Returns: + True if form was submitted successfully, False otherwise. + """ try: - logger.info("📤 Submitting the form") - logger.info(f"📊 Form has {len(self.collected_data)} fields filled") + logger.info("Submitting the form") + logger.info( + f"Form has {len(self.collected_data)} fields filled" + ) - await self.page.act("Find and click the Submit button to submit the form") + await self.page.act( + "Find and click the Submit button to submit the form" + ) # Wait for submission to process await asyncio.sleep(1) - logger.info("✅ Form submitted successfully!") + logger.info("Form submitted successfully!") return True except Exception as e: - logger.error(f"❌ Error submitting form: {e}") + logger.error(f"Error submitting form: {e}") return False async def cleanup(self): - """Clean up browser resources""" + """Clean up browser resources. + + Returns: + None. + """ if self.stagehand and self.page: try: await self.page.close() - logger.info("🧹 Browser closed") + logger.info("Browser closed") except Exception as e: logger.error(f"Error closing browser: {e}") \ No newline at end of file From 35191175c3bfd3f90be61a956d2a680f0fe3db87 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Thu, 16 Oct 2025 14:49:28 -0700 Subject: [PATCH 09/10] ruff + cartesia line formatting --- .../cartesia/form_filling_node.py | 333 +++++++----------- examples/integrations/cartesia/main.py | 45 +-- .../cartesia/stagehand_form_filler.py | 141 ++++---- 3 files changed, 198 insertions(+), 321 deletions(-) diff --git a/examples/integrations/cartesia/form_filling_node.py b/examples/integrations/cartesia/form_filling_node.py index ca9d859..fa18ca6 100644 --- a/examples/integrations/cartesia/form_filling_node.py +++ b/examples/integrations/cartesia/form_filling_node.py @@ -8,13 +8,13 @@ import asyncio from dataclasses import dataclass -from typing import Any, AsyncGenerator, Dict, Optional, Union +from typing import AsyncGenerator, Dict, List, Optional, Union from config import DEFAULT_MODEL_ID, DEFAULT_TEMPERATURE -from stagehand_form_filler import StagehandFormFiller from google.genai import types as gemini_types from loguru import logger from pydantic import BaseModel, Field +from stagehand_form_filler import StagehandFormFiller from line.events import AgentResponse, EndCall, ToolResult from line.nodes.conversation_context import ConversationContext @@ -25,29 +25,30 @@ class RecordFormFieldArgs(BaseModel): """Arguments for recording a form field""" + field_name: str = Field(description="The form field being filled") value: str = Field(description="The value to enter in the field") class RecordFormFieldTool: """Tool for recording form field values""" - + @staticmethod def name() -> str: return "record_form_field" - + @staticmethod def description() -> str: return "Record a value for a form field that needs to be filled" - + @staticmethod def parameters() -> dict: return RecordFormFieldArgs.model_json_schema() - + @staticmethod def to_gemini_tool(): """Convert to Gemini tool format. - + Returns: A Gemini Tool object with function declarations. """ @@ -65,6 +66,7 @@ def to_gemini_tool(): @dataclass class FormQuestion: """Represents a question to ask the user""" + field_name: str question: str field_type: str = "text" @@ -73,7 +75,7 @@ class FormQuestion: class FormFillingNode(ReasoningNode): """Voice agent that fills web forms while conducting conversations. - + This class uses Stagehand to read and fill web forms dynamically, maintains conversation flow while automating browser actions, and intelligently extracts form structure and asks relevant questions. @@ -91,7 +93,7 @@ def __init__( headless: bool = False, ): """Initialize the Form Filling node with Stagehand integration. - + Args: system_prompt: System prompt for the LLM. gemini_client: Google Gemini client instance. @@ -102,32 +104,31 @@ def __init__( max_output_tokens: Maximum tokens for generation. headless: Run browser in headless mode. """ - super().__init__( - system_prompt=system_prompt, - max_context_length=max_context_length - ) - + super().__init__(system_prompt=system_prompt, max_context_length=max_context_length) + self.client = gemini_client self.model_id = model_id self.temperature = temperature - + # Browser automation self.form_url = form_url self.headless = headless self.stagehand_filler: Optional[StagehandFormFiller] = None - + # Form state self.collected_data: Dict[str, str] = {} # Pre-initialize questions so conversation can start immediately - self.questions: list[FormQuestion] = self._create_questions() + self.questions: List[FormQuestion] = self._create_questions() self.current_question_index = 0 - + # Browser initialization self.browser_init_task = None self.browser_initializing = False - + # Enhanced prompt for form filling - enhanced_prompt = system_prompt + """ + enhanced_prompt = ( + system_prompt + + """ You are conducting a voice conversation to help fill out a web form. As you collect information, it's being entered into an @@ -136,80 +137,69 @@ def __init__( each piece of information. Keep the conversation friendly and natural. """ - + ) + # Generation config self.generation_config = gemini_types.GenerateContentConfig( system_instruction=enhanced_prompt, temperature=self.temperature, tools=[RecordFormFieldTool.to_gemini_tool()], max_output_tokens=max_output_tokens, - thinking_config=gemini_types.ThinkingConfig( - thinking_budget=0 - ), - ) - - logger.info( - f"FormFillingNode initialized for form: {form_url}" + thinking_config=gemini_types.ThinkingConfig(thinking_budget=0), ) - + + logger.info(f"FormFillingNode initialized for form: {form_url}") + # Track if form was submitted self.form_submitted = False - - async def cleanup_and_submit(self): + + async def cleanup_and_submit(self) -> None: """Ensure form is submitted and cleanup when call ends. - + Returns: None. """ # Submit form if we have any data and haven't submitted yet - if (not self.form_submitted and self.collected_data and - self.stagehand_filler): - logger.info( - "Call ending - auto-submitting form with collected data" - ) + if not self.form_submitted and self.collected_data and self.stagehand_filler: + logger.info("Call ending - auto-submitting form with collected data") try: await self._submit_form() except Exception as e: logger.error(f"Error during cleanup submission: {e}") - + # Clean up browser if self.stagehand_filler: await self.stagehand_filler.cleanup() - - async def _initialize_browser(self): + + async def _initialize_browser(self) -> None: """Initialize browser and extract form fields. - + Returns: None. """ # Prevent multiple initializations if self.browser_initializing or self.stagehand_filler: - logger.info( - "Browser already initializing or initialized, skipping" - ) + logger.info("Browser already initializing or initialized, skipping") return - + self.browser_initializing = True try: logger.info("Initializing browser and analyzing form") - self.stagehand_filler = StagehandFormFiller( - form_url=self.form_url, - headless=self.headless - ) + self.stagehand_filler = StagehandFormFiller(form_url=self.form_url, headless=self.headless) await self.stagehand_filler.initialize() - + logger.info("Browser ready, form can now be filled") - + except Exception as e: logger.error(f"Failed to initialize browser: {e}") self.browser_initializing = False raise finally: self.browser_initializing = False - - def _create_questions(self) -> list[FormQuestion]: + + def _create_questions(self) -> List[FormQuestion]: """Create questions for the form. - + Returns: A list of FormQuestion objects to ask the user. """ @@ -217,37 +207,25 @@ def _create_questions(self) -> list[FormQuestion]: # This matches form at https://forms.fillout.com/t/34ccsqafUFus form_questions = [ FormQuestion( - field_name="full_name", - question="What is your full name?", - field_type="text", - required=True + field_name="full_name", question="What is your full name?", field_type="text", required=True ), FormQuestion( - field_name="email", - question="What is your email address?", - field_type="email", - required=True + field_name="email", question="What is your email address?", field_type="email", required=True ), FormQuestion( - field_name="phone", - question="What is your phone number?", - field_type="phone", - required=False + field_name="phone", question="What is your phone number?", field_type="phone", required=False ), FormQuestion( field_name="work_eligibility", question="Are you legally eligible to work in this country?", field_type="radio", - required=True + required=True, ), FormQuestion( field_name="availability_type", - question=( - "What's your availability - temporary, part-time, " - "or full-time?" - ), + question=("What's your availability - temporary, part-time, or full-time?"), field_type="radio", - required=True + required=True, ), FormQuestion( field_name="role_selection", @@ -257,13 +235,13 @@ def _create_questions(self) -> list[FormQuestion]: "Software Engineer, or Marketing Specialist." ), field_type="checkbox", - required=True + required=True, ), FormQuestion( field_name="previous_experience", question="Have you worked in a similar role before?", field_type="radio", - required=True + required=True, ), FormQuestion( field_name="skills_experience", @@ -272,24 +250,21 @@ def _create_questions(self) -> list[FormQuestion]: "that make you a strong candidate for this position?" ), field_type="textarea", - required=True + required=True, ), FormQuestion( field_name="additional_info", - question=( - "Is there anything else you'd like to tell us " - "about yourself?" - ), + question=("Is there anything else you'd like to tell us about yourself?"), field_type="textarea", - required=False + required=False, ), ] - + return form_questions - - async def _fill_form_field_async(self, field_name: str, value: str): + + async def _fill_form_field_async(self, field_name: str, value: str) -> None: """Fill a form field asynchronously in background (non-blocking). - + Args: field_name: The name of the form field to fill. value: The value to enter in the field. @@ -297,122 +272,99 @@ async def _fill_form_field_async(self, field_name: str, value: str): try: # Wait for browser initialization if needed if self.browser_init_task: - logger.info( - f"Waiting for browser to initialize before filling " - f"{field_name}" - ) + logger.info(f"Waiting for browser to initialize before filling {field_name}") await self.browser_init_task - - logger.info( - f"Filling field '{field_name}' with: {value} in background" - ) + + logger.info(f"Filling field '{field_name}' with: {value} in background") # Use StagehandFormFiller's fill_field method which # handles the mapping - success = await self.stagehand_filler.fill_field( - field_name, value - ) + success = await self.stagehand_filler.fill_field(field_name, value) if success: - logger.info( - f"Successfully filled field: {field_name} in browser" - ) + logger.info(f"Successfully filled field: {field_name} in browser") else: logger.warning(f"Failed to fill field: {field_name}") except Exception as e: logger.error(f"Error filling field {field_name}: {e}") - raise # Re-raise so background task can catch it - - async def _submit_form(self): + raise # Re-raise so background task can catch it + + async def _submit_form(self) -> bool: """Submit the completed form. - + Returns: True if submission succeeded, False otherwise. """ # Wait for browser initialization if needed if self.browser_init_task and not self.stagehand_filler: - logger.info( - "Waiting for browser to initialize before submitting form" - ) + logger.info("Waiting for browser to initialize before submitting form") await self.browser_init_task - + if not self.stagehand_filler: return False - + try: logger.info("Submitting web form with collected data") logger.info(f"Data collected: {self.collected_data}") - + # Ensure StagehandFormFiller has all collected data # (it should already have it from fill_field calls, # but ensure consistency) - self.stagehand_filler.collected_data.update( - self.collected_data - ) - + self.stagehand_filler.collected_data.update(self.collected_data) + # Use StagehandFormFiller's submit_form method which now # uses collected_data success = await self.stagehand_filler.submit_form() - + if success: logger.info("Form submitted successfully!") return True else: logger.warning("Form submission may have failed") return False - + except Exception as e: logger.error(f"Error submitting form: {e}") return False - + def get_current_question(self) -> Optional[FormQuestion]: """Get the current question to ask. - + Returns: The current FormQuestion or None if all questions answered. """ if self.current_question_index < len(self.questions): return self.questions[self.current_question_index] return None - + async def process_context( self, context: ConversationContext ) -> AsyncGenerator[Union[AgentResponse, EndCall], None]: """Process conversation context with real-time form filling. - + Args: context: The conversation context with events. - + Yields: AgentResponse: Text responses to the user. EndCall: Call termination when form is complete. """ # Initialize browser on first call (non-blocking) if not self.browser_init_task and not self.stagehand_filler: - self.browser_init_task = asyncio.create_task( - self._initialize_browser() - ) + self.browser_init_task = asyncio.create_task(self._initialize_browser()) logger.info("Browser initialization started in background") - - # Get current question after initialization + + # Get current question after initialization current_question = self.get_current_question() - question_name = ( - current_question.field_name if current_question else 'None' - ) + question_name = current_question.field_name if current_question else "None" logger.info(f"Current question: {question_name}") - logger.info( - f"Question index: {self.current_question_index}/" - f"{len(self.questions)}" - ) + logger.info(f"Question index: {self.current_question_index}/{len(self.questions)}") logger.info(f"Events count: {len(context.events)}") - + # Check latest event to determine what to do latest_event = context.events[-1] if context.events else None - is_agent_response = ( - isinstance(latest_event, AgentResponse) if latest_event - else False - ) - + is_agent_response = isinstance(latest_event, AgentResponse) if latest_event else False + # Handle initial greeting - speak first when conversation starts if not context.events: logger.info("Starting conversation - Agent speaks first") @@ -423,71 +375,49 @@ async def process_context( ) yield AgentResponse(content=initial_greeting) return - + # If last event was our greeting, and user responded, ask # first question - if (len(context.events) == 2 and not is_agent_response and - self.current_question_index == 0): + if len(context.events) == 2 and not is_agent_response and self.current_question_index == 0: user_message = context.get_latest_user_transcript_message() if user_message and current_question: logger.info(f"User ready to start: '{user_message}'") - logger.info( - f"Asking first question: " - f"{current_question.field_name}" - ) - yield AgentResponse( - content=f"Great! Let's begin. " - f"{current_question.question}" - ) + logger.info(f"Asking first question: {current_question.field_name}") + yield AgentResponse(content=f"Great! Let's begin. {current_question.question}") return - + # Check if all questions have been answered # Only submit if we've actually collected data - if (not current_question and self.current_question_index > 0 and - len(self.collected_data) > 0): + if not current_question and self.current_question_index > 0 and len(self.collected_data) > 0: # All questions answered - submit the form - logger.info( - f"All {self.current_question_index} questions answered" - ) - logger.info( - f"Collected data for {len(self.collected_data)} fields" - ) - + logger.info(f"All {self.current_question_index} questions answered") + logger.info(f"Collected data for {len(self.collected_data)} fields") + submission_success = await self._submit_form() self.form_submitted = True - + if submission_success: - goodbye = ( - "Perfect! I've submitted your application. Thank you!" - ) + goodbye = "Perfect! I've submitted your application. Thank you!" else: - goodbye = ( - "Thank you for providing all the information. " - "Your responses have been recorded." - ) - + goodbye = "Thank you for providing all the information. Your responses have been recorded." + # Clean up await self.cleanup_and_submit() - + # End call args = EndCallArgs(goodbye_message=goodbye) async for item in end_call(args): yield item return - + # Guard against no questions or empty state if not current_question and self.current_question_index == 0: - logger.warning( - "No questions available or not properly initialized" - ) + logger.warning("No questions available or not properly initialized") return - + # Process user response - messages = convert_messages_to_gemini( - context.events, - text_events_only=True - ) - + messages = convert_messages_to_gemini(context.events, text_events_only=True) + # Add context about current question question_context = f""" @@ -497,25 +427,20 @@ async def process_context( Listen to the user's response and use the record_form_field tool to save it. Then acknowledge their answer naturally. """ - + enhanced_config = gemini_types.GenerateContentConfig( - system_instruction=( - self.generation_config.system_instruction + - question_context - ), + system_instruction=(self.generation_config.system_instruction + question_context), temperature=self.temperature, tools=[RecordFormFieldTool.to_gemini_tool()], max_output_tokens=self.generation_config.max_output_tokens, - thinking_config=gemini_types.ThinkingConfig( - thinking_budget=0 - ), + thinking_config=gemini_types.ThinkingConfig(thinking_budget=0), ) - + # Get user's latest message user_message = context.get_latest_user_transcript_message() if user_message: logger.info(f'User response: "{user_message}"') - + # Stream Gemini response full_response = "" stream = await self.client.aio.models.generate_content_stream( @@ -523,37 +448,30 @@ async def process_context( contents=messages, config=enhanced_config, ) - - field_recorded = False - + async for msg in stream: if msg.text: full_response += msg.text yield AgentResponse(content=msg.text) - + if msg.function_calls: for function_call in msg.function_calls: if function_call.name == RecordFormFieldTool.name(): - field_name = function_call.args.get( - "field_name", current_question.field_name - ) + field_name = function_call.args.get("field_name", current_question.field_name) value = function_call.args.get("value", "") - + logger.info(f"Recording: {field_name} = {value}") # Store data first self.collected_data[field_name] = value # Fill the form field asynchronously in background # (non-blocking) - asyncio.create_task( - self._fill_form_field_async(field_name, value) - ) + asyncio.create_task(self._fill_form_field_async(field_name, value)) # Log the collected data logger.info(f"Collected: {field_name}={value}") # Move to next question immediately # (don't wait for form filling) self.current_question_index += 1 - field_recorded = True # Clear context self.clear_context() @@ -561,19 +479,14 @@ async def process_context( # Get next question next_question = self.get_current_question() if next_question: - yield AgentResponse( - content=f"Great! {next_question.question}" - ) + yield AgentResponse(content=f"Great! {next_question.question}") # Yield tool result immediately yield ToolResult( tool_name="record_form_field", - tool_args={ - "field_name": field_name, - "value": value - }, - result=f"Recorded: {field_name}={value}" + tool_args={"field_name": field_name, "value": value}, + result=f"Recorded: {field_name}={value}", ) - + if full_response: - logger.info(f'Agent response: "{full_response}"') \ No newline at end of file + logger.info(f'Agent response: "{full_response}"') diff --git a/examples/integrations/cartesia/main.py b/examples/integrations/cartesia/main.py index 9f583d0..2cd8056 100644 --- a/examples/integrations/cartesia/main.py +++ b/examples/integrations/cartesia/main.py @@ -12,11 +12,7 @@ from google import genai from line import Bridge, CallRequest, VoiceAgentApp, VoiceAgentSystem -from line.events import ( - UserStartedSpeaking, - UserStoppedSpeaking, - UserTranscriptionReceived -) +from line.events import UserStartedSpeaking, UserStoppedSpeaking, UserTranscriptionReceived # Target form URL - the actual web form to fill FORM_URL = "https://forms.fillout.com/t/rff6XZTSApus" @@ -25,54 +21,45 @@ gemini_client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) -async def handle_new_call( - system: VoiceAgentSystem, - call_request: CallRequest -) -> None: +async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest) -> None: """Handle incoming voice calls with real-time web form filling. - + This agent will: 1. Conduct a voice conversation to gather information 2. Open and fill an actual web form in the background 3. Submit the form when the conversation is complete - + Args: system: The voice agent system instance. call_request: The incoming call request. """ - + # Create form filling node with browser automation form_node = FormFillingNode( - system_prompt=SYSTEM_PROMPT, - gemini_client=gemini_client, - form_url=FORM_URL, - headless=False + system_prompt=SYSTEM_PROMPT, gemini_client=gemini_client, form_url=FORM_URL, headless=False ) - + # Set up bridge for event handling form_bridge = Bridge(form_node) system.with_speaking_node(form_node, bridge=form_bridge) - + # Connect transcription events form_bridge.on(UserTranscriptionReceived).map(form_node.add_event) - + # Handle interruptions and streaming ( form_bridge.on(UserStoppedSpeaking) - .interrupt_on( - UserStartedSpeaking, - handler=form_node.on_interrupt_generate - ) + .interrupt_on(UserStartedSpeaking, handler=form_node.on_interrupt_generate) .stream(form_node.generate) .broadcast() ) - + # Start the system await system.start() - + # Wait for call to end await system.wait_for_shutdown() - + # Ensure form is submitted when call ends await form_node.cleanup_and_submit() @@ -85,7 +72,5 @@ async def handle_new_call( print(f"Will fill form at: {FORM_URL}") print("Ready to receive calls...") print("\nNote: The browser will run in background (headless mode).") - print( - "Form filling happens invisibly while processing voice calls.\n" - ) - app.run() \ No newline at end of file + print("Form filling happens invisibly while processing voice calls.\n") + app.run() diff --git a/examples/integrations/cartesia/stagehand_form_filler.py b/examples/integrations/cartesia/stagehand_form_filler.py index 1d25d28..c29b13a 100644 --- a/examples/integrations/cartesia/stagehand_form_filler.py +++ b/examples/integrations/cartesia/stagehand_form_filler.py @@ -6,10 +6,10 @@ """ import asyncio -import os from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, Optional +import os +from typing import Dict, List, Optional from loguru import logger from stagehand import Stagehand, StagehandConfig @@ -28,16 +28,17 @@ class FieldType(Enum): @dataclass class FormField: """Represents a form field with its metadata""" + field_id: str field_type: FieldType label: str required: bool = False - options: Optional[list] = None + options: Optional[List[str]] = None class FormFieldMapping: """Maps conversation questions to actual form fields""" - + def __init__(self): self.field_mappings = { "full_name": FormField( @@ -83,18 +84,18 @@ def __init__(self): field_type=FieldType.CHECKBOX, label="Which of these roles are you applying for?", options=[ - "Sales manager", "IT Support", "Recruiting", - "Software engineer", "Marketing specialist" + "Sales manager", + "IT Support", + "Recruiting", + "Software engineer", + "Marketing specialist", ], required=True, ), "previous_experience": FormField( field_id="previous_experience", field_type=FieldType.RADIO, - label=( - "Have you worked in a role similar to this one " - "in the past?" - ), + label=("Have you worked in a role similar to this one in the past?"), options=["Yes", "No"], required=True, ), @@ -108,13 +109,13 @@ def __init__(self): required=True, ), } - + def get_form_field(self, question_id: str) -> Optional[FormField]: """Get the form field mapping for a question ID. - + Args: question_id: The question identifier. - + Returns: The FormField object or None if not found. """ @@ -123,7 +124,7 @@ def get_form_field(self, question_id: str) -> Optional[FormField]: class StagehandFormFiller: """Manages browser automation for filling forms using Stagehand""" - + def __init__(self, form_url: str, headless: bool = False): self.form_url = form_url self.headless = headless @@ -132,19 +133,19 @@ def __init__(self, form_url: str, headless: bool = False): self.is_initialized = False self.field_mapper = FormFieldMapping() self.collected_data: Dict[str, str] = {} - - async def initialize(self): + + async def initialize(self) -> None: """Initialize Stagehand and open the form. - + Returns: None. """ if self.is_initialized: return - + try: logger.info("Initializing Stagehand browser automation") - + # Configure Stagehand config = StagehandConfig( env="BROWSERBASE", @@ -152,33 +153,33 @@ async def initialize(self): model_name="google/gemini-2.0-flash-exp", model_api_key=os.getenv("GEMINI_API_KEY"), ) - + self.stagehand = Stagehand(config) await self.stagehand.init() - + self.page = self.stagehand.page - + # Navigate to form logger.info(f"Opening form: {self.form_url}") await self.page.goto(self.form_url) - + # Wait for form to load await asyncio.sleep(2) - + self.is_initialized = True logger.info("Browser automation initialized successfully") - + except Exception as e: logger.error(f"Failed to initialize Stagehand: {e}") raise - + async def fill_field(self, question_id: str, answer: str) -> bool: """Fill a specific form field based on the question ID and answer. - + Args: question_id: The question identifier. answer: The answer value to fill. - + Returns: True if field was filled successfully, False otherwise. """ @@ -186,101 +187,79 @@ async def fill_field(self, question_id: str, answer: str) -> bool: # Initialize asynchronously without blocking init_task = asyncio.create_task(self.initialize()) await init_task - + try: # Get field mapping field = self.field_mapper.get_form_field(question_id) if not field: - logger.warning( - f"No field mapping found for question: {question_id}" - ) + logger.warning(f"No field mapping found for question: {question_id}") return False - + # Store and use the answer directly answer = answer.strip() self.collected_data[question_id] = answer - - logger.info( - f"Async filling field '{field.label}' with: {answer}" - ) - + + logger.info(f"Async filling field '{field.label}' with: {answer}") + # Create async task for the actual field filling fill_action = None - + # Use Stagehand's natural language API to fill the field - if field.field_type in [ - FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE - ]: - fill_action = self.page.act( - f"Fill in the '{field.label}' field with: {answer}" - ) - + if field.field_type in [FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE]: + fill_action = self.page.act(f"Fill in the '{field.label}' field with: {answer}") + elif field.field_type == FieldType.TEXTAREA: - fill_action = self.page.act( - f"Type in the '{field.label}' text area: {answer}" - ) - + fill_action = self.page.act(f"Type in the '{field.label}' text area: {answer}") + elif field.field_type in [FieldType.SELECT, FieldType.RADIO]: - fill_action = self.page.act( - f"Select '{answer}' for the '{field.label}' field" - ) - + fill_action = self.page.act(f"Select '{answer}' for the '{field.label}' field") + elif field.field_type == FieldType.CHECKBOX: # For role selection, check the specific role checkbox if question_id == "role_selection": - fill_action = self.page.act( - f"Check the '{answer}' checkbox" - ) + fill_action = self.page.act(f"Check the '{answer}' checkbox") else: # For other checkboxes, check/uncheck based on answer if answer.lower() in ["yes", "true"]: - fill_action = self.page.act( - f"Check the '{field.label}' checkbox" - ) + fill_action = self.page.act(f"Check the '{field.label}' checkbox") else: - fill_action = self.page.act( - f"Uncheck the '{field.label}' checkbox" - ) - + fill_action = self.page.act(f"Uncheck the '{field.label}' checkbox") + # Execute the fill action asynchronously if fill_action: await fill_action return True - + except Exception as e: logger.error(f"Error filling field {question_id}: {e}") return False - + async def submit_form(self) -> bool: """Submit the completed form. - + Returns: True if form was submitted successfully, False otherwise. """ try: logger.info("Submitting the form") - logger.info( - f"Form has {len(self.collected_data)} fields filled" - ) - - await self.page.act( - "Find and click the Submit button to submit the form" - ) - + logger.info(f"Form has {len(self.collected_data)} fields filled") + + await self.page.act("Find and click the Submit button to submit the form") + # Wait for submission to process await asyncio.sleep(1) - + logger.info("Form submitted successfully!") return True - + except Exception as e: logger.error(f"Error submitting form: {e}") return False - - async def cleanup(self): + + async def cleanup(self) -> None: """Clean up browser resources. - + Returns: None. """ @@ -289,4 +268,4 @@ async def cleanup(self): await self.page.close() logger.info("Browser closed") except Exception as e: - logger.error(f"Error closing browser: {e}") \ No newline at end of file + logger.error(f"Error closing browser: {e}") From cf8d070789d1b9eb4113ea942c38ecbaef139248 Mon Sep 17 00:00:00 2001 From: Kylejeong2 Date: Sun, 19 Oct 2025 21:15:19 -0700 Subject: [PATCH 10/10] remove headless options, readme nits, adding image --- examples/integrations/cartesia/.env.example | 9 ++++----- examples/integrations/cartesia/README.md | 18 +++++++++--------- .../cartesia/form_filling_node.py | 5 +---- examples/integrations/cartesia/main.py | 3 +-- .../integrations/cartesia/requirements.txt | 2 +- .../cartesia/stagehand_form_filler.py | 7 +++---- .../cartesia/workflow_diagram.png | Bin 0 -> 60691 bytes 7 files changed, 19 insertions(+), 25 deletions(-) create mode 100644 examples/integrations/cartesia/workflow_diagram.png diff --git a/examples/integrations/cartesia/.env.example b/examples/integrations/cartesia/.env.example index 9777dfb..6ce6cdb 100644 --- a/examples/integrations/cartesia/.env.example +++ b/examples/integrations/cartesia/.env.example @@ -1,11 +1,10 @@ -# Gemini API Key for language model +# Gemini API Key for language model (default) GEMINI_API_KEY=your_gemini_api_key_here -# Optional: Browserbase API credentials for cloud browser automation -# If not set, will use local browser +# Browserbase API key and Project ID BROWSERBASE_API_KEY=your_browserbase_api_key_here BROWSERBASE_PROJECT_ID=your_browserbase_project_id_here # Optional: Model configuration -MODEL_NAME=google/gemini-2.0-flash-exp -MODEL_API_KEY=your_model_api_key_here \ No newline at end of file +# MODEL_NAME=google/gemini-2.0-flash-exp +# MODEL_API_KEY=your_model_api_key_here \ No newline at end of file diff --git a/examples/integrations/cartesia/README.md b/examples/integrations/cartesia/README.md index 2071ec8..5c34e98 100644 --- a/examples/integrations/cartesia/README.md +++ b/examples/integrations/cartesia/README.md @@ -2,6 +2,8 @@ This project demonstrates an advanced voice agent that conducts phone questionnaires while automatically filling out web forms in real-time using Stagehand browser automation. +Here's what the system architecture looks like: + ![Workflow](workflow_diagram.png) ## Features @@ -32,14 +34,14 @@ Voice Call (Cartesia) → Form Filling Node → Records Answer First things first, here is what you will need: - A [Cartesia](https://play.cartesia.ai/agents) account and API key - A [Gemini API Key](https://aistudio.google.com/apikey) -- A [Browserbase API Key](https://www.browserbase.com/) (optional, for cloud browser automation) +- A [Browserbase API Key and Project ID](https://www.browserbase.com/overview) Make sure to add the API keys in your `.env` file or to the API keys section in your Cartesia account. - Required packages: ```bash cartesia-line - stagehand>=0.1.0 + stagehand>=0.5.4 google-genai>=1.26.0 python-dotenv>=1.0.0 PyYAML>=6.0.0 @@ -58,7 +60,8 @@ pip install -r requirements.txt 2. Set up environment variables - create a `.env` file: ```bash GEMINI_API_KEY=your_gemini_api_key_here -BROWSERBASE_API_KEY=your_browserbase_api_key_here # Optional +BROWSERBASE_API_KEY=your_browserbase_api_key_here +BROWSERBASE_PROJECT_ID=your_browserbase_project_id_here ``` 3. Run the agent: @@ -78,10 +81,10 @@ ReasoningNode subclass customized for voice-optimized form filling. Integrates S Browser automation manager that handles all web interactions. Opens and controls web forms, maps conversation data to form fields using AI, transforms voice answers to form-compatible formats, and handles form submission. Supports different field types (text, select, checkbox, etc.). ### `config.py` -System configuration file including system prompts, model IDs, hyperparameters, and boolean flags for features like enabling/disabling browser automation and headless mode. +System configuration file including system prompts, model IDs, and temperature ### `config.toml` -Additional configuration for questionnaire structure and application-specific settings. +Your Cartesia Line agent id. ## Configuration @@ -90,10 +93,8 @@ The system can be configured through multiple files: - **`config.py`**: System prompts, model IDs (Gemini model selection), hyperparameters, and boolean flags for features - **`config.toml`** / **YAML files**: Questionnaire structure and questions flow - **`cartesia.toml`**: Deployment configuration for Cartesia platform (installs dependencies and runs the script) -- **Environment variables**: +- **Variables**: - `FORM_URL`: Target web form to fill - - `headless`: Run browser in background (True) or visible (False) - currently set to True for production use - - `enable_browser`: Toggle browser automation on/off ## Example Flow @@ -136,7 +137,6 @@ Test with different scenarios: ## Production Considerations -- Set `headless=True` for production (currently configured this way) - Configure proper error logging - Add retry logic for form submission - Implement form validation checks diff --git a/examples/integrations/cartesia/form_filling_node.py b/examples/integrations/cartesia/form_filling_node.py index fa18ca6..db87a26 100644 --- a/examples/integrations/cartesia/form_filling_node.py +++ b/examples/integrations/cartesia/form_filling_node.py @@ -90,7 +90,6 @@ def __init__( temperature: float = DEFAULT_TEMPERATURE, max_context_length: int = 15, max_output_tokens: int = 1000, - headless: bool = False, ): """Initialize the Form Filling node with Stagehand integration. @@ -102,7 +101,6 @@ def __init__( temperature: Temperature for generation. max_context_length: Maximum conversation context length. max_output_tokens: Maximum tokens for generation. - headless: Run browser in headless mode. """ super().__init__(system_prompt=system_prompt, max_context_length=max_context_length) @@ -112,7 +110,6 @@ def __init__( # Browser automation self.form_url = form_url - self.headless = headless self.stagehand_filler: Optional[StagehandFormFiller] = None # Form state @@ -185,7 +182,7 @@ async def _initialize_browser(self) -> None: self.browser_initializing = True try: logger.info("Initializing browser and analyzing form") - self.stagehand_filler = StagehandFormFiller(form_url=self.form_url, headless=self.headless) + self.stagehand_filler = StagehandFormFiller(form_url=self.form_url) await self.stagehand_filler.initialize() logger.info("Browser ready, form can now be filled") diff --git a/examples/integrations/cartesia/main.py b/examples/integrations/cartesia/main.py index 2cd8056..95b465f 100644 --- a/examples/integrations/cartesia/main.py +++ b/examples/integrations/cartesia/main.py @@ -36,7 +36,7 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest) - # Create form filling node with browser automation form_node = FormFillingNode( - system_prompt=SYSTEM_PROMPT, gemini_client=gemini_client, form_url=FORM_URL, headless=False + system_prompt=SYSTEM_PROMPT, gemini_client=gemini_client, form_url=FORM_URL ) # Set up bridge for event handling @@ -71,6 +71,5 @@ async def handle_new_call(system: VoiceAgentSystem, call_request: CallRequest) - print("Starting Voice Agent with Web Form Automation") print(f"Will fill form at: {FORM_URL}") print("Ready to receive calls...") - print("\nNote: The browser will run in background (headless mode).") print("Form filling happens invisibly while processing voice calls.\n") app.run() diff --git a/examples/integrations/cartesia/requirements.txt b/examples/integrations/cartesia/requirements.txt index e7c06e2..25b3aa7 100644 --- a/examples/integrations/cartesia/requirements.txt +++ b/examples/integrations/cartesia/requirements.txt @@ -4,5 +4,5 @@ google-genai>=1.26.0; python_version>='3.9' loguru>=0.7.0 python-dotenv>=1.0.0 PyYAML>=6.0.0 -stagehand>=0.1.0 +stagehand>=0.5.4 pydantic>=2.0.0 diff --git a/examples/integrations/cartesia/stagehand_form_filler.py b/examples/integrations/cartesia/stagehand_form_filler.py index c29b13a..2b3f20c 100644 --- a/examples/integrations/cartesia/stagehand_form_filler.py +++ b/examples/integrations/cartesia/stagehand_form_filler.py @@ -125,9 +125,8 @@ def get_form_field(self, question_id: str) -> Optional[FormField]: class StagehandFormFiller: """Manages browser automation for filling forms using Stagehand""" - def __init__(self, form_url: str, headless: bool = False): + def __init__(self, form_url: str): self.form_url = form_url - self.headless = headless self.stagehand: Optional[Stagehand] = None self.page = None self.is_initialized = False @@ -263,9 +262,9 @@ async def cleanup(self) -> None: Returns: None. """ - if self.stagehand and self.page: + if self.stagehand or self.page: try: - await self.page.close() + await self.stagehand.close() logger.info("Browser closed") except Exception as e: logger.error(f"Error closing browser: {e}") diff --git a/examples/integrations/cartesia/workflow_diagram.png b/examples/integrations/cartesia/workflow_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..59d60e8e6e7d0695b8021e3355bd276f622235e3 GIT binary patch literal 60691 zcmeFZbySpH8!rq9q9CCNC?S|Ms7SYjN-NT((lJ90Aq@g55(Xk&5>i9W3_T*!odXOo zl+-YUzz{>6n|R{&Ti;setaHvkXT7e)aJ%Q;ab0^~{oB0JRDVE4!9+nsL`3!Ip~4d) zqEp>OL?k_DP6I8&y-#e2h$x^o^75LG@A3h9=?f=A=msk#L&3BnGyQx z!>flJeVU~6q)``M63ve)JtQH$e~#<*>%@9`qsIygg?V#nuLJFf%^#nCY#)~P0!4P7 z9%9TpSXf1KDX`LE%xkTqt$Dk>dAmbuv%_8RfJk;=O8Le$Jyw7FA|oc&>DGs;%9i6H zM8w&|OzgzGF&rNYuV1etdhu~}8NYPezhP1+zR2WYf6q^dsz$cq6wzA+(P)7wi|gK` ztL~C-o>CLp%6t&f=Ztx`O8?5{iMi}6ws5B%Qol=6eNIXr8|+@B@6lQk`Exjx_qY;0 z?0qkJJtih%{c1R_R=th!?RkFLK^_S&Vzvq4SA0=SXMdRO>3_p)JUTG!d}^V=FJjzG z=XF`63aNSPyc6SIa^otJ&+zLPqo6mUT=GmT;R+PamXXB_uekLSn6BJuzQ6CWCu+qR zICU$QV@)N=JeD&=u-Uu7FS|IBQ_}uJ@2h|orql#FiDnXg*|r-hj0!v`wU0YR9I-r! zadPkJv)ZiAh_>u+UoJZPF@fhDjc&(hdOC6KsjM~?`tFwMGe6~Ow6LeSJFgnzN5_I$ zewF-wH~8$Am}Axqz6Evglka*b53y?r`BsUX)<#Vew;oyX!S zmX#F#H0k**ksHn*7$l6ch;JD#~0BUZ~PUj;SM()Wa%yDHek z1A_ME@Yp}33R&;>4J9MnY-wEWg!48zt=^(&a^|C z{_KKk(8hBYkJD{|Lynv*l%2u76D*=^{I5{8(T-GAY~VnRTE+>JG8$j%$P^-FGQ|g% zQ|&c*Ke2I>M88v(fAm=8aeX$$Q>q-|{p*I zt1nhF7zdAqf$zJgK~o9cb#u^5ca><&!kro&Z@>BC+IC;{d?txjnAlsfp!xc9FUFo1 z>4|@S1F1i4_OUQ_uznCW2pUwfmw=N?__NkdI1Y>2C2;VQG}bpeDVFh-?Uj8j%Po@| zA+Mqsqh0o=`N=U=zZJbju`9iMc2|3sZkJOo@tKk%hXU=^E5%n&et_@uOx%4Qbz8ZQ z{qCEjpL#EDIgu587?hQiuX^B}o|g6^Z!+^j#f8|EoncZ%(xor1NwU0(lSmXpa87ukb5 zS)`ge1->WSZ>Sf9_PQ;t2=(avkNzu;h&+gCNcod(? znv$4NEmx^esG&A!ROV2&O6_n<6ciV5wW+vLAvyYDRKCJ}{h1U`wRLs<%gS~B`S3aB zIr4dxb;MlqfZuajk(E15_c%pFpWilrD71d}BGYH)qt#Ho)3wbNF+Vvk)ax)DWj`ofF1#VU2X{r^Mn}Wj;R04)Ivyq!Jy$}#gSvIN zU8@{*bH{Pvui|-dhB#9RJ+T#*9M%Gf53HwHW5nR1HO|Fu7eqns6~9y=Nx!Ow%j>qo z!g9mc!%oS>L+t>C%|pRc;N@h*Y%;8pRfnxB{7 zs9X@ZHGkvmM}rSe&8y9@=IbS=EFI#cL>oQTyAzmQVqMl|l9ICoHSR5MdBe6xwnMdU zbDiZPJdt*}UM4j1{;B%HK;QEuHV46FF-)jw2ipY?iWX9h9?9O8o}^5#Ob1oAmX(g| zCXcJ@bUJ5EElyi4HF+n~zlU*P<2mK}zJzzGH%s5jd|mQ0k@6hFG~e8NJ(agNe8iT- z;+;b>QhN+8--ze2d*4g@-u2P*)GfL0WNfryBH{V--5+(HOrDcfDq(pH`TFZOWNshC z)X&vd)w7Z*k?~OM#1zIX8d31684nk=nd?s!*Zz`g-7H`)XfjwOC|5>X@L&p*_4*C6 zP0Okr$3s)t7Q&u}eN`0E^~xJty}t8cB68G@1!b?l2K!>_o%*xjYufah!PqdR%+SHp z?Op`L23w(Gtc-v*7BE7QMYUdaV|T-gcZ&nUON51^1zW;eG2l4lymzoxeMI}u_P6am z(a)p#3{??%nBV2x={bmDM1@oC*np5B#$n2#vSQTAtB!h)^`?aM9`Ek+I*j^EGK)=2DN;1r*EYq>w{f)hO%~K$3F4M~ zR?20`etf9cc(LH)tYjxF(Ot%Gkpz2xO)(}+Eha&Lb#HWgGkuhQR4v9>0Ab`**)uFy zGW%0|IXA0d+f>3U(Pi(2gNH+Bco{PS+)+N{9_ELg2X*6g-JO@;jT!sWxInjRcJ|(q zzofCdaX7K>8hIgx;Z9rd~OnB=bRPM^bO=%Wm1^n~9GVS=1Wn5R%XW$@o3{ zmGbHBxv(@Dq5YAap2=VDk>2|i_Yy=Ux0H7l&{&v5DQ3|VyAC(I;XGYix#*t0942G! zH%NHDQH0g{k;RctDKjOl>sz$jvm>;Qt#%l~)I2JCvQ&0a_9}H&2Jy;E8lveNMA@(T zIXaTM2BuD{?Xq3sSV{J{?-vU)TZ3_NlWn=~tx%0S{KkLhq5Kni>%| z@O*}dIMjxS6nG*AewctCz}fu}LPQSyz5x6vq>%je?WyjRQ-3{^^c=n@rzQXB5%62f z%-O;M>|*WUs%b0|0}M4_^Hk4OPfbYBBytE0GpfV;apzq=s6gR_;u9WgO6f!lWl?%w4C-r#fb1iL=>-~+o{KOW?- zaTF|E%$#i;U2Pn|?1$q%f8hXemAZEAaH7Be9k0{E!{(ovz%D0b0R##hej{*)|F*#2 zV*_0!4_n1GZ9FXO^%ZPD0DFKrq($!Dl{|X?|MSg1Gybcm-akFXL`44G^IzZm*;Cua z!dcz{1Wf5F{m;&vbpH33CmkgP4k`Z^Cyv>C)C%xfnnF_G?>&>Ia8+r)OhhD0^hiPO zsR!}mD7p7j?cv5%s}Hi-sq(V23W|%YUW$v=d5N&iu?=l^5YK=y1ji3I_NpE8S~h-M z8L0!ovF01Gk8U-qX6|(~np-Z7crA%xBuF}aI>hL>1pvE9Moda8OZ4v# zCVEa=R~onfY&?8NK$f$S^}-5PyIQ-4K7*Q#2XhYul_*`z+|gG7WQ8z|BKWAW%57F`0q*nN4Ed})CFh2 zu`~tTr{(0~DZ%5=jQ4ftuaoF=6!xE|F?LXpM=$giyQncE2xYz5bR{@H?~Cu@2@@O7Tg-1tMtx7h(1r_p^ceXIkc zZUyve$e_R#^D-MVAK4&%t8T4)>G^i3g0r0?h4^nKN$*1GS#s6j8)vV5C=zp)8PoELVy3vt8ZIk)mZ?O!YhgzfmCAn zVL3TDgYA62V~8q7fY0}bXoFTBvMy?l9QjS;?voEM9ubqwm4Y6#SXttpzcMq7eyZvJx#Txq=D zyPAn4*QJpm_^>TAv$VINR?0}i6C)#gEEJgvEYoBFYIeEAM?;FrU1rPj)+1^nIFh|b$m_h8tD#(?I*(_z zE3gG{Q{U|&b;;GINZe{8TM{>&qDpHglDJ^Z# zDM!xwLPJKX;fl|C>Re5;WcmJ%VcN!WQR5gM?J$K4r0lpuwoh30D;#yKlfiw|kY3kj zh4eWIyUuaD^*#23$y1B{fR8wPtY2JcMD9Z}d^Y-H+~!gr41Eu1HGn{1Tn`VnXOkEA zHZkE#t#l_OveOysq2kAm4kLS)Ik82$w%d?}`+=x=lO|y*@-n}?F^D2%wmsfzHqkxP z*rolKw4Kj-H%A%B1-DpoU9ME7X-|A_-DRX}B|_M;E!}Nvf_y0ZF(X=P^BsN&dp%Ne z0JVldCStL1LUx(Bs%m{OW(4Tj%4r^tpJISqImrl`+%6$h5(dVhFd?pu6*|%9J zwK>wrAYihH)$vDvN@IVy(1*b_U5|9BD<^ErlT$G*qJQHMn6h^}>=@w<#XXPxZL8fC zC!C&sd6QCvWScO?n@1+nunrNFb`^&hbxWVGjDYm|2Ojzbnq`381bgA;#f~NN5vBLu zhOV#ZW|G%_y_F5Uw=I8ZOQg&`ZT{>ghJ!E*nY~9&j4N}JQQD+RT3?p=E(7Y#%->k( zM;|=Z(b?g3(ljk}FpG>a^$lnB+vTbOA@_u9mraaVVyp3kW;8qBjV^=RK_2EHWgx_In&eWCluYha?+#Czn!2JVnm(Q)k0oN8hJ zt_|Occc}Rd$t(QvcK`7*WO?km!Sc^HbbD*jx+XU(_&n}D|KV})lo;PGV#;O@{+(Xj zY9PDVuWS0d?k9bdsSiZWr{$%cI^u47%V%;>k}xRF^wr`Ujw@juG2q|LVn=Ev2w9iG7aq8^ zXjzE2UHzh9hbU^yV@e3w0=rvd;9)=AB)_LTM1EyUH-`AiY=~Z@qkMj{+NC}Qdio&~ zuf3c2i@vo2AWi?gXQpAn-$(*i*uv+`@8oK*ZrFQnW%f->CW@VI-Nb$KhTn_(V=EL4)dTc-l1yMG}B+F zw)Aclpjn+M--W+5Y|MTfGyI7QNq~ll?{9tF3p8a?&Zqwlt=VxF;VAF%(;m3fnD8u! z96HWG#(W8JrCnp-zG<8cE1|c>hn%of=`b>a3&-`SK-Xg(Es|g)?wS{G=kn?meh*Cj zC9}U(J?f5;0vEV?NsRlyhcDNV$AM3uV-amy&Ru(aZ0^pG-v9OM*KRwl^~&qRgBgV& zv9PchfZ3s{aHe*yd1~>(z6C`j6BUYU>5O~^)@!q!P=Zc1%D`L|b2gv82K#xOsXa-m za>?)!a%rTzt<&A4%rQVlNxmvb7`vVQ7`4Mpoc;i#168kvG84D?6+Gyjq;i>QyR)Zx zsD6zY*4}~*&6&{dw9+KN-^c2^+#;oY z2qkh!>&2?zPE2IfO%lL(tzb5GSL!zDWUZQoQZFB>2!4vFHy!o4O7Ta7$MMSEs%SQ6 z@t0L5Yf{Mi^s$9jmOY4sm-n-rw|e*??f-bsQHFqcmP4wo`TnEXU-mTF7rMm1-j70( zs0?owzTJG=l35kf<5}@1^-xh2_R7M^$l|WrV^kx zm{XS4__c1L^!0y;KYY5U;?G{8Z_o1TKl!5h`cQ5CpB`S;mQ*~3VTjLf-VVxw%&Ye7 zVfUK&65uvzIT~;5`M60H0xwOFs>%u%^Y;H%lljRc@hz_2jC@!Q3wh%;SWV7nhFTop zSn_Hg&32hjs0($?)2^rHasQ2WH_ms-$3aZLrmNM86`)H-@CGHk9c*NH&b%DsH4^ewO<=X1HuV42Mf*&{MR42-=0Q;D0-{4zKc^ z`4RL{m;W0)XQ8nCAnxgK^u^en{t~m@XW-Qjh1wO#Pd}p?`Br8}+;PLT3(|Jc+c<}J z@tE=Xp6*+Tg=Xk$hLwI2Xs`B=psmx)s8=EyLur*#P96uhSZWK7HmLnPr%i_(OVA1T z`4~mLJZS7tx6IxB?xB}F9Nb1IP>ZOJ0i(>mnq>H(^d;o&icaNfv_XtpJ=iM)RPux) zzQplc`xj*2@Ws$PQ~fufqWk2`5|ARbM8r?F^mD&-!0~rmm-j2WGTXxKQSrGOe6VQ3 zr(qtMHbgAt=i!tk&|=A>u*FeR+YW?1LKt3~f`=D!+jn{kLYr%09VG*Iaf?ZjR_JN5 z_b$+-4vA4%c^^z4g2YdG$D0&AtyBu@s9besU8F%3xcizsTVq5an{PY0MK2SSt5x!U zLLA?O?fxqC?u6sxy&O8yY}vZN;$=b6FrH#}UwvAT{HXB!&$|W=vR=%S@BAE>*wnL~ ztefr(&&ee3Li8B5t|9$U!UJ`qPq*qegy8m*I~iCgp2yI)Ki6*y(I49WdHLC|M3u#o zH(~a9l?~iD<@-w|T^-Wfp!dj{9D;>=`_qaFY-I=`?_#+b1P4i!ez#qvHYOjnik)JU%pE_&*0&1GS_t$tY;n2JmU8V7ZIpxw)P@4Cf;H*(x`#lQ+(xX zdnI~aLlq_;0udrB3o~l)_^CE!d<)j5?xx?7k^r}GuiOg0EImgBzq#h|tDxG)atqBB z?Y1tDL}L)k7KtNWYt;N^xoOWAHN2%d3-$%SNV;IJNSB>k&~fy}YxPc^4(;@o=-jXH z)kT|yci>lMUG|(PLK?k=I$f_%CVQ=~2EZ1c$Q%$>St>V1^>q+x6vGa~Sx_2y&{_*V zo7~T#>&~DE-YH949DUK9PQJEK73#@%;Rg0rJ&u51BX%uBf&+~<8&t7V_-Y?yjr1JN z7{U4XHHrQAT<`+Q#Rs?^H0I*mB=~f8LbaL(dQAp7gy(TDrT2nup!tW25PUf~aF9dN zxak_@p##niBRMy#p3eb0PGv-W@+XnBV6ZA{!%k+bhKk5+%^R_iF1er-2%UrypJ$M^ zTcc?kxe=SjI{LLeu^Q?niQq6&wN7ut=kcZoxil$4GCNi0rHsd6ebkkH#jLxSN5Z3I zZsbr*y@i2_57AyavvSQIADy?-u2~F3YH0%Fb6Qxm+XbUNhy_0^CBbO1B`*Z7MzJ#X zMm$3{TRo#3A2nnJ|t%vI;iG>t%eD8kgo- zt~gg2g24{c$u{nNDLfA_|KS_J!YW_-MtU3xWuCQeDrl){8J71pv+KS+X=Nq+K1$*2=O{uk=IGKPs;BYxV)Suia338xmYS-;o=Cxn(JL zZYH=ObeMo`tWOko^ukoh*{8^#du5e;~$$g9IhJcV@ts}q5 zKm)Lk8+l@M$7c31F_|1i&pmI^$6`LYSKQDwJ|HQPuLOj?wlpVC#CCjT51AoF3Nv?* zmqN%E976m8$$xX->b->e(CjL|zm}VU+|)kSwTL6)5-sa6tKn>+1-g=~YX^NLH)g2s z?M>fo$H%G?FE&BVYx4zQdW$R}QiBYy&`Ik`p_m!&7D#4n_@|Ic$S1vyj?wbM za|>*Kj&kM2eW4X7XWc01yESL2YqRce_N|lq{fv3qg9;2kK};s4A5hdBW-y3I@;SaI z%ng*xs2)2NeHXsm^LoSOi2lKMErZua^AoI(VxTgAY)If;hG!SIKzTIcBbUX}RthJP z)^-C~h@YSD%fj-3^bk?IvK4(W_3t~(?j>uKU`F64fNF zUyP!agt{S^J(GA=NTS^p*l^~#N`_Gi5>>vR3>W!sXZ}`EeU^`sOUW1e1yQ*hQG2I0 z@I?zhmZu;v?dlklzhiAl?;a|Y6>p`PAAd=tTB>)ZVzIO(u>QH9%XLwH57k>!c{@S% z{ee>+N1oQhd_Wv*rM?|!U<1CADOKOC1G|Ok8JEfnc&&$d3J>LfsUGc$;J-dJ;8yJu z*m;+`F`IXeKNs?Q%`~XK?n0t@0P{dq0kcV7f1*&R9*;@#%-vz8h`jpR(rnZfyH7(w z$sMb;^L}{jtPa+M)|kH12fd@O)}Ey^^E6l6PIQ}AWY;iPw$ac@+&X4v)%Kx_X6g3< zEpT_Cc^JcfPtJ%{`PFtG@lN;)cWJ&Tx14yHMC-YVh5euTmer}P33l3c^kjP!Pb{*_ zn|h7lUK}E!dUA047h!N44;avYWI7A^tq?TAf_ucD!iJmqDl&b^u(KoA4Zq=lo8co@ zbQz*CLWJR_#|-Q9UloXTJ5=^JKwKty+=Ju;N;+gZs8&sCM@h@8Me>(-7R_;HRexE@zYvuH)pwhmFDu%xH8<#ecWjxn4!tV=zZ z2rQ(8=I^Jl0xora#|v*e#Es2Q{EYkg1u6}I5633gmd z?N=h(1u{CNHT%k`ZllW<)Ob|-jDDnRSPYrW1MtPmEi7no;?u)nH zZ@g)4n&~1KY|U92^4Sif5K-&7o(*m5c^Ig1aBeba^IoZuBIa_w=YY!SBbaz)pb3(D z`~2P>A*(DH-B39(5nmb49#dggR$jUN-hSZVj{9{bQ=80qQgLCoJA$TS$Rj=Uwe1+H%n` z83qOy1#bUfx|X-htTS-;arYeqPlL;SgG7&G&7vzXx_K!Y7A6*TYq=XfjV%i~b6#I0 z%ItF(POG>-#v5V+MhAD7Q&=g+gC3(}}-W^uVJ4gk>*?89{f z)B_h-kX`MjJqoF2BVyenS@CI-K9H-g+bsl7u zUB!gcv#z0&Yr%^(kf7P|>W0ACXUIVyVY!=dnq+(Y%Tvh)tcv_b17!*fxf5$|8trK^ zU0kuMe67)g(16aIAjHK;wdS|vpW6nU;Jt?dgKpT6+Mh%1m2#_I7n0w5;9eY6hGd5S(7Bl}fF*;DRAAa4YY6uyeNfUoPFa-z<*j(N#(-mW2@umu zc28kfQiLP^C?^yZe;AC$qr!Fm2*%8Ta+3I|xr7TxhWYOgb`fAam$iy++v6n3aX=3Q z)#uLK=J@;lloe9$*ozP*RGg6tvHJ9qKrvO8mps?snUc_meplX9O5Dp zA93m=i*Xcn0v{A={;IC2#!fq7hfZ>rElDCr)sXFM%O+i-*H~~(Sj@rHr1vuS-chjI z6LtR;tXf|303ovquLio)Y7r-~8jU{_y>{BY#_y6Y%-?Kk=x^{4t{e zuPp88i~HPvWXL%$0%PAV?^&n&n-#~AaXqjje7C-m{K1a!=y+2xmVxB4HWz5`-=tKAD-M1Qm! z0kd^P^C(LELCjr#Ab-EAB75quZ8}-Jvm&s$x&;?;$Km~7+@@_R1GcF_fSBqJ>YEPh z14iXfO8=mqeF~udGLrP(-y@tPq5ez238(+pcR==0gW*3L7NA7>SwGY7y4Q;EvCQXR z2hOK>N5Wrq?++I_`S@UJ!b6fvV}=e=X#;o~X%Ey)XR@gsv%vATKgz1cWPS zBu=U~w!nFK8uo%|i(J?Whz24M0M{AS?hkYqS#~c#`;^QYbY1vE%kUoNO@C7=texz|UtNkH0NUh#V}fGk6i|`=LGd-a zIwV8!LPgi5qodeHu_DzSyD__X%!;oH&t{{ChfPuZu*oQWSgzBL#SHu);40!)RhaS) zFcR*imnm~_%TWG21SaIv@ZfxWX^D!7sWqF1ZCTPwa*;wgx$RYEcnQ@`jGoP-Cf|51 z5UQr6tD#e;bt)#&=s>BUA3heJtZHJbTceG{ZMfObgcSEgwdbGXkgO`Ue` zj*mGHm|kN#%3b@*PLUX+gH>avA~^(#XgK&+FHiRh+hD92JXd=PwKMsp!Bwf?$Wth8 zSpH`_v*Ze*tqCZj;WxX@Pj7z)MLOH?{7@kcz+=aao9o-05Tek?30HY60ve!FJhxa4!#9e+}Q7Khk!?6;TdGxHWi z8C}8B+2QRjL;bP*r}mme!ayxmapxT0x0*wds&60;Y{fZo?cGYeXjqdD&e(o=5B@Vk zADL)OSh~je>H*1k{|G+%B|YJtvwSR;Z*-ecCm5H5xPEz>v5^n1C2VVj@GWh!6tETj zvcE|P^FBzUa@OceZZ4piGC~FwuYSJ}zd*9Y-V}inwy5oo5y<2%3P1fx?=h#j!@j|< z+_}j-o%wu_WsY4cdUt5~&9d+fXOt&nxg{A0ySQLntx?1k#E%$b4YQYb2g6cRRlU0e zt{mC6y8wtMaobLo^7srs{3e^Mt_W}c4N{%3zMViI74d#4#z>@F8D0-&i6g-g8^85- z+YG&|f@@=B*kUK_B#Ik|Fv>RaAaDcSPZ2HM*!c1ra4J^_=WQgoF(Q*lf|H^r(#N)j zW*`VuJLMtL=bc}3Q@GTsC>%1YLz^eN5dgg(e!5$ZwJ7od)YSIoQXfHDZ$bn*NwQrc zV&@Q9Ik70ysXQW_c(BWdCJ1wvX^TA%dLps7x()9i7Zxrv{3>HrUU6~d^ag=-YWMA| z?-B!U&vC^pIbk_+u*YINQV-Fm?&lkTD|^Aryi}m|_0zD5jCL&e!eQltn6%gs5VcAu zP5#LVctoP>fVo<6;D^l46G}$iG}s%NvBT(3hx*zTEY}er5lc{i4dV4R8J!`Yr9rJx z?>U%pVQz5{<<`X;V{7^u)wc^;z+w{ZbnSI}brPq<8NYWL(Yndm-?%gYulSYoWq%YmoF=<17Gs04g2CbH z$O|G0-fvh7Ex!dD`}<*HqWL-1M}#frwC`~c9H9J;`UJS!r`9nC9Ld}r3?IB7$U#c! z&Ms}X=c3=egfcrr5o^iJ!Y(2-pKzGXT2J90WW8otDDxgV);hOim{3o#;BjI|-_3Nz9X8hfG7Q+ci+L<_S!aV>s|WKq|z z;Bt**8y`vL?{}1vJPe^~Lw4=81Ckbew3#W8%ZLw%5DOZo%O=$(1@Y*s>W>}se}C5v z!?`hP`mN8VSs0wbMQAW}{Io5D(F_=WFYwp@l?vtA+uolfv9TBudbOh^vi`Ct?9oRX zw4e+xo*<3AV)1oiQH_tPoFm6>a>Q1~YA-EcVvqp#*o9K@=6%klh95cLr8R)9#>TrS z9qZnnTPqu-8TjeJdN+I+rFUV~c1oPTru+wb;0PPAtDK4pDzAiWcE7OTo&}F}2phwH z4sZ%M<-UBdfbF_9txGDfY-C>BDZtO(cg}urM1y5W1=)F#h<_)^Gt2%{w+bv8<)hKR|+T~4&k#N%J%u=<0gC{YU2igNNKZlIa z^+O^ml}uIj7X-)BHy5Xk>2R&b8?$&mdplmpYgj?$FN;k^auckbXO5iGkF|=XfB7?# zJ=sOR3SnH7pWK?a-DYrUacA?IJZ!ddHVz12Dc5|I5Jj2T!5+~AlI|Z??R@FRr}r@u zF@wZ*pN4P5Oe8M)K4r2synES-Y`O)^Q>G68Ss~!m$Q4DSZ0}zX&%{_${M}+PY%vZe zQBmH58)3o!lphKbKhCc-<;f->f80g0cSM|&IocV!lshS?VVW@v=$F4{o2oDkwoe&! zYO_MjO(m;>47O zQ4}3KP&LCv->7)sJ_Bhfy(@5XNJ?}a^cKh~1P3Mn%Vsk}C{?SFYjnUvQ3py+B{ z5SGn~?;M(v-^-iO_+G5xxuTu#cetO#4o|Vv?0D#%@O95D6{*RmnIc4t`t&=6BgyozC#Jz&UJ#r`{RUslPj_ zLtO?u`?fL{on!sutSoDKJ!9Eb*lY}}ZY;`l6c$7|6N}J=9;FWsiuFU|blq3D8 z^gk`fPol&Ce)RO8tv9%UMDE{U(*XPPTV^|@_BIr_<0G3g4tSxIOCNflAL)A|hA%S6 zAIE$G6iYzcp;Ifklt6zJw*39UP7ajWXU7%On~$ao=IEO5ji-S2-jS-=7?9Y@1@KV! zEO|7J!(?k8fYt$#*>}fFyQj}yT3U!qE;oME6i;6v|4~Mi<%m9j-Vm3pgijQIMAyHy z0S22wdH0TR7$=ejEahQ2pu<}peWU$*S;re-3ZzcfHd=ZFj(K~cABh4!9fnE&uME%L zPxA4Wnkpo^FZAea$o|iP0HT6O08CVKtxm7`(SCc=0kH;xN7?T$E}ihQ=N91q2^(-L z9kKN9k0z0T58hbXy%T`o`|lras$8h9$MZ6t39ZXyD|r6dlr!#dK^Fn6wkxrbdBu_Q z{!G|?$><?tf1XplAQDixuai&9m8ThkAtl zfb`|&5(qa8YehzMr{~dI_hyFkm8kkLi#k3VfWJS=9i6$S@ob^*n~uKzP*sqi6`4J#91je` z;nB-Q63bPc;Bt#n9P*&nv%9||Q3AEl7{Ux*B#2mdIm6(H^?Cr%JiAccyR*sG)srK7 z<Iyg0|qic2E_S1jyRZzfsM_#z4cyE01*z)5dW?U3i}8>0_p9EueE(l zC)d{IE6Pxb*-4&jxF|W_lM!Z`o(e+4xw6B#4-IMqV4RpmUeNqyo7jJmgkE|1iRNCi z_irb!#iG#30XB+Y`*5Lw-}(c~$b*iCa64YU82LroOM$5=2u6e1WDA%9+{0$@(w=P>n*c z`EHH*?yh&lnwbMp&&oeca&*2zA2r_u;|U882p_Ba~@P4&G4 z0JXP=oeNO&=s`Hhm^P*U;)@%=e0wJG~!|U_C zs(XeG)l_-e*-gyN28tjPaCt3$17$jFV3%)ZT;U1>u&`HuiIDF$AEJ8n4{yjs#{?gN z^AA;i4P{<6UYvnC6!$IbN+epFYNKYcIQI}g-@WO#GykA+`U52hF^VsJUMKv!G3JxP zzL<#9q=OrP@f`wA%=D>w0ASioUPwXGDNXQZtKwb(dhpm%`RLGkc8F!|Jo`QUv9s`| z%4a2A44dH0d5Ou_W_cRP&Okbh`jQQRi!a{;VBT*1+NkpHgNh9Wi`ARH_+dMNLr66| zh(`J+D`xC~1%UG6*E**b0jNLR2S0>a_S%`}hzndubQ^AGML3jFTYQ67F4+=(w|3$N z%`{4>S;e=+U86$EkoaL9x6M)aE%6U~LN9*e-Npm_!k46-r=Nr8Hj$%Lp=^t<=+t3A ze!3G}v;Fd5e`Nw4x8uIIfhuD@$1~jhfQ=1={ufsLD%!a<9~5JZUm->obWhet<9|?g zDs|QZ2=gXC;u(e61EI{&6XP6ZK|&#ohV#LOO04v?wY5hESUsnmr2Tz=H#Zt-dX^nR z)}_nPFR|x+{VddzU)sK7_1%(_=E)JOO_YckIz_XKLLv!EUW5njmj`NSq+F9(-3A{^ z`|FJR_B{?A45HcZwE7S!z4OYN`Ci-ZQssdefV(fP-W-zv-f=sGI}2L0BlmZB*b;bh zy;}K1%7}LFvH3f|`h{yEcnZq;fW+-=NjV@={xmzuo->d9>>V$RUeYAW-|LWAR%QEw z4Y_YGFul3;}j^Wc+gnp`F~zmCMA!E;Fj} zcYnDjlhJUaz1Q-0VVVsG08o4bL_lLYHgH-x-TE@l}XLU;dl+r*Lh!zI-{##7io$PthB~VduQH!QsnImxTB^@1lPQRJ_ zB}FDP4-k$<>bm&T=h=zES^~)^cUN#K0@b%CZvU=;@Bm4jp{!vBLwgW#`oAWl^TrK^ z)*q+yyM?*c(LGufJ_#^?fy|d zuzv9E$oYr+8pj`|X8Edn+PpDLc zM&MmM5hKoJi7yxQ!QkKM?&V0|{p~CtS+pJp^)QcE5^J)(b}qo?j+(iC|1Y}_j zDtZ*jfB2+6Rh5lpg2!)rlL+T)o@Neh0k*nXpkei5)z* z#7>8V!?tJB=9pI&r6U0yXIFspTTcQYDUM~^Glr-IQ&A&hEX>5ZW63feG(STMpi?x^ zfQvPCN}=6C=ke`t&&PJ+xZK|wD6WNit- z>oVyTWPUfDa%;FeyWIvGLm8P-U8i|l172ugKKRAJBhgIWJ6up-Sx|2I38Ux}4hl2j z;I8$G2UmFJ8v}0A905@HS%i4{VGWmDU1JF3<7N=e*z9a%u7GVNgPK;iE!v=K@sn1^ z@3rO!?{2dV@&x6xi;J4(e_SO2^rABRQxlVY+BH?4a1UNBvoXm+>{ke+!6bc)3P;1EIOb1;Aqb!!Cg`$yia4OLh zeSxg3C@jhbIY?vx{b=K!CcLoqG#Ws*`1}Suo!^uh^8~n%Bw(i;!o0WxXUll)6;j5@ z^b#w_Wk`rl3c`sbk&?TzKSK7F{oFacBSmN*Dy8J*wcS$P>dzPT4fk28^=fAMahMwN zXJn`Q=NfkL6ZXf#;+Wzt{WqpW;V)Ne59nnD=C6^Q`6>@2!^EF{`wMXZfQ|&1)fmKY zmOecSu)9G_e>G)qQX;pyM#=i{Bf!mkNKO&2I&tEcHy zfUxn~wG_tSUNW-oqbpMZBjGRj$0x<3Etbp2$H5XX1R>VO}g)4^@?2Z`JbM5=4LRN6J@^Ol-i9ew0 zG`XSm&)5xSA+I#;1Fyy%v{u>8(gQUH1K-}4LLo@sh973Y;^>kQD+w4r@6J>m*A?rM zE3Nt)8M%Z#Yz;hOG=FY7hHrNue?QA4;}KXh-hMKsAi^;1!K=^cuKVO;mi-4(KNe9m z>FAH17lv_c3{2ubat`1x?pBu29I`@|8pzAduGFW`9}{|muj^ZkKAPH6t1*r*fmw=I z((eAcXGPMhPx|2AG5g&IFpWDKp-tGE(U#g_w#b+{?Xuy#m)~L|Y~)r!&TM=drh(czap8iz>f3Ke*A4Ma&$3gVDpyU4KT6cNcBN|IiIhD0q=7$E>%t z)@W2H&0zo|!7Ox>_f=6RQ88Igh=Ac=1GhCFe_i4bC|vp3=|h#8iq_l8a&POyOY`V& zeGFY78%&LcHk}}SC&8D~1dr)oDa=B8dTuEBwS=C(vPTbI1JEq=L z6kUBzk>-uGlMf}8UL)B&=7D6o!^#n;n?2?bF}A(XeN-PJCVg^)mdP`Q^0QrtQK-=~ zJ)b(fCBFwXt7C3(PK?6cA#scy%FsH|r){{>^E=2mWQd;aen-6JK)L($27Xq5E7(tO zYgA$ntAKnzGFcHQvUIyCzGgrn^jZQGK|Oz|iE1t{kXi?3Bo;_ls!Z3>}}w8sB&} z6I{qU9<5{!8Y2kCjG7^}6zd zR$O8fcO=|x*SEUBG22AqyA5hOKFtdqE}tlWk5c(p7lbd>9pB*{p=YQ2m&)og4_MLF zh-;6#BnJM$@xBp;Uy1CGEDvrRpLMj-tELp=knRW_)IV|^iuNOwzRc^*77^na6d`+V&!Htn7TaF;&8N4EWqF8=%@`-`gl?WD3IOS z^IB+sLtXCKEovK^Y4@d^X99!7 z=LUwt#hcq_P3A(qLy_KTCUiN=l3j1r{m}Q(NpwC?xsT&B-#4L3o6wD~r5`0c2k%#W zqy5BXX6#jS)@1eg;$z)m#NoYaeS``mBKbyprZTnZYC8tv@*1>L_AuJw7k`*x$y;~s zj*m}STgDmaQHutfkamf1X-XN#z~Bz=PH)=$>d_xBCKwB{{Sx9gS+BT6v@)U2nL2dc zC>y%nN5yxrKC!c*-LV$ny@3Odc~I(}hnk8R9%P?Wb&X&F3Oa(N)C!vcPd=t1WDlUnqM|6E5@GsCD z*y-q>N4#BOG~<c^(0>$U&g3hr97X>Ie5gtyr%Xar&VM=H|DO1O)U zAKyv=^ft_xnfJ(b-_LWupZERG$DFp$-e>K#*ZQvCX0H84?p24E;S(Pi z0%=8wS$zzhcY|vP^k%VnH*wI33Q1nZGu~0q{To3gC53|hm z3^gn{{|x)SKK;b$U(9Nl{B!@nfH{7buHe)@L^A(6A$CZjhf$_$lOZwnOx!u&m_d`6$)-rFd!zxpuz zD}Qz0iWU}xy$`ox*?%=eWxTVYZb#|<_TICtIho2;&y4H^DwX|;j2{e!uGc+tpW#zp z>sMyawH7_D881M+u4bACMtg8GeX6*7v8I;RhARH`r~F?+|1OTM6OIW|Gu7wg?W16C z>sZ{4xul<=-)su*VM>LabW0+eykS$mU9W;JF?FN<+ft(;w(ctOh%Bj!y(M7?{R?8( zLAzQ`zuMLKq@A)7$Lb1msJjeY5f$C+m_0Nq<^8X}b`Kcp;G99R|2Rb1lSJ(>gS7~U z2z1%%hHgqDWU~?O@1Md{+v+7(QupWwoq$38Z{iX8h%z&D5s$ATh&Nk{G-M}jns8R* zVb;|Uv0(r_ptRtkD+AoCbmi^AHPvRF_jZtaOLg3%&U=k*CnGt0)1+m{@%XMXr*;P5 zxPYtU@WQ!a|4!<_G(SZyB=l9H_rtVg*$(Et=z++C=*&~bw43*XWQR$CEF(hTF2#S+ zO~Ef2t?lo4E}DpoM6(bsRa~!?>@#CT7kt4z2$HP?8rb)`;=fEJd;9|e3X6)#FaP;9u|*r{WVD;3_3zJ#n~8j1QVk*_ccm_t+bxC@Gr#C znOi#)lW;HO%#EuyUL{L*TBWQ1^)0VtBMfyM&^hcQ2G@yx17FIbk6v0A&2vQR%C@T#ZTgXu{i(+n>(6KjZAbg(NL{z{kVOJ{(JRm_y?}GJx(J>$ER()hf9o zM-KZ~ne^3LrQi;z7XK6vxPv;LJEg+BM?78W-PELWzit2U7hB?SvN9&zoB+YjPIGKE z8Gwjh`OGv51!y{1f1pcbuLEAE6--g@cS~-=RlWg0Vck^U?JHX3b$(q=1B3@V*&zeqWGQgD@4TkZ6CSRgEN2dLN>h9r!5@qAR zFJKiAL+Y`xsfj7Nl`&~e(7s_TzLVxO{ry2G%x}p2&t3aT^`OPu7H5j=SD~OERfxgA zqXhgAU{qg^JS`v%iI`EXSaMXZeIatqURmnt(BqkcisYB<&li2z!j|zdX+3G)GlzOK zA4^Ef|7?|00C{SlIo+96e|_TWTww8yG~weW3BN^rJ!E*{Taaks^FqiY_nO$3j+tR5 z;+V1&X=EEh$|I-Bi~rH@*n)Nu;M)YuVu1PZzq#lu@BYLvXqgRDH29*VrdYK0O$g$| zP<~SNNv09}!=#buZ%Q0KS{sT3kycLo$Q+4RS{pZGX93BoKER*la_Y+Z+a@ty;Hq=v zjF%l!BE6G$-_dN+N*0{y3D<}ajq|oe5`=q;YCd_6A;zmuNs34lOOTYG;=!*EQh%^I zYY~0|Dz(My${4|a(E{smpjppHR+-<4{+<*lBFWHHUu=A@3E9pUG7K_l+_IeY`HX{; zh1k_p}>7**t=_pmA>l zmGR(_?2}A5C=@vD$Hg?^^aa*C+w=d^n8j-H>3hLddI|>%)yQvsE#{D(iyel=P*{hy zjM2|bNioh2%1WYGN%LLz(|$E|5l(M0PF)R?o?HYDn!QVgXa!N%pLYiCNyNweN9Zh9eM9i{Qt%3 z1`7dY1xhAJ%aG8(>ilE2Qt<);Yx+#3;AB>wi>sGtJY6O1Bk#O;uRr5~Auz(XzWp-# zYk*2=xKG%{jSe#QOltSUlwuk`FN@jX;$ErCZ{Hl-Y8Fe(Vts2TLh_K#KuKC3ez9>R zHV|H-<>derIW*h7iMz5CA#~gm?2V4>C4u(&5?)ggC5}&oEe9VEp?>hKQMzKuFSZLH z`)C6qMRN9TKJ>9#*l+PH%K3L!Ig;0E+kX*?-q*n|$#ao6VRFl*fjaq3gsvccJ`;7% zz(S}uK?O+8A6K6+CaDx_tHb`IR5;kZBfNQD68IiA8eDGwyJ91~{hFGEsebdQ+VX(b zcW+A4iPHZTd1Xc5x?xM=XEQfmaXX z_vPH|uknRWQJR_G_%$W%nQJ$EwnMXgoFFxw`&OTpNvDU2tW2iv!=0N(g`oI+R608_ z!kL4w*4gpq1Q*N-JvX`0#MzbpU|lnqdVYoQx+~Iv%q&E6u3_|>{6Mk(43YcR%vpi5hwgM5l0S4a__G%LH=lL%m@H zc=nIpsL<@jy#uGp3EhAdy&`X5Iwl;l`Fqo6T=D91Zq%|?yqMCGJ4~{4Bo=$FnI%>m z$B8A{W##iMf`wC{4glWo0YYErqd>3!j((GJ)$1?mdQ;fNdzUSI&fP|0RV~`ccaPKK zuoMJas)5PJqsh|0jM0>qKc#W}Hwy_Xa^K7C~`L=`u z$Fr04bj1rmN4ZATO+z|+ocP~K{~j#~fi?DlsxjNI9#zK5ksqxD7rwYx*)+PRQh~n8 zFfvWqSsSca8LZ*ixaXof9(0^fuK78@>SfT%!v7K30&~WL`HYD>XhT=NfN!=(9T~Bw zzAuK|0YRFf3qIyR9dC$5 zlaI>n+(?7#3D6Qj`>*3vI=XKI{z(CKV)5UPQcsCpI}|ZBy7KtyMXr!JhgLd6{O!&% zfcH`(_O}F3HkyzmOTV+H?dk|Q67KjZI3t>)P#{ueX*=^_q@(+%H-FceBe#PagR5dE zWxjkeo$-*zGE1B_g)ePL5$Zk8hNa^0IqYw(3EJZKULr-Y4pM+c{XYfP%4CH2gE+&- zs=-wuA@)X39{245B9&lXS&=f$1~FX*(t}x8Zghf`8b{=C75VF{GM!)p`bfqXC4v|6 z?y)VLax&(1hkvyFszYc**@{~?(wItBQpy+r1_F0Uh5;yqoh4tT`Svh!*G69}kbkTH zQJ>P;(SV>(5nHB&XRY_~hVWMEhg}2I0+VBX1AVDfD4+DZpOKYa)T6-SgeNt;2#;3G& zEo$@#5=>`r!OFtg|EkwrJ&6SHB+X4fi}vFgI2sXIqphpyj_5cr6c;WXDZtD7R|_ow zZIfZV=5VndF+%_ZpG&R5E@}fEbnk33j=!tZ8rSInp2fU4OdF(BW)n76<+hJd(FN z{?DmX`{zS}?8m43z$>&sf<*^WPkV8^+{V7Ur=?fK$*dJ<24Fw@`~l%V{}Cx5=ss@} z8nbY=wTF%Kw+zIdiXP}<`x3d({--5Q@POLxdv>$`;Ym=IbF9^TXA6OsPdiTAc7PO0 z9dMwHsPSq8lHyc8kK`s5>&_tY9MFNb0JV@1ns)Bn8=q4<_L{G02L-!o)p?%#?iG&$ z^1j7TI(BO4yX%fUO^SN9g#_0HZ@K0n^I=i1<5;Dzh;?9WRo6`E^?!AfL39}7e?FAAR|>O4&I#{F6^W$DF2&t8mqX1aXPZ3P zB(Bu3+Cqo|nre9>CI5VNbpV*25fx*hTbB#2u<}gLr6DaNZ%ql@`2#?q@1hoaCV_G2 zVf9UgJ3(4-s}Zz(z}!8et%gJ92}md9;pVu*}wHbCX< zR*JCkc2%KI;9#oK>djFg_xy{8MDw6_-qCk<(G%aJU5CdJH+euDui%M5u>FoQSEi$2 z%Sp406(GB4xVUSGQLg|6vRb7We9Ga;mPD4~pMx17xwX>AgLG#H9aQy=sJ}$bJpJ3^ z-dy>JSn0@VyT!Nl0Ip)+8M(Xr`v$&Pt@SIjck1G&0qAaC+)~{Y3%WA{F6#f6Z~;ZdvPhs4CUN_5aA zAUAn-y((nVA0_phxFKrTP;YM7W6_-|FiTBz@qP|QQybf{pXZU~F?ZOCXEc`V$MC#z zdhHE>RDSu`A68k_K*8+7*^IdpX2AHY4!D0;zUsxI@j!O#kSM(0stJn6Ni+c}nOK8Q zi?>FfoB-Ta&!X(?qLJTWgNdgTCJ^r#e*E~0;~(X1BB8)5!lgE@$&uIn`QThWPz#jR z^x(nU+d}_x_;*g(z z9Xucr_Lz5b&K2gr;Jmo*Kcy)jd6gm${2Kl)qQex0Gro*ptE_zHyVp?Q_oU2wWd%2zh+68C@7f}X}1Npg{K*X?xk9GFvynW#{)}5ah#rM)6Al)j$;lL6Gm`V) z$(V~^YnAb#>(cIpFpzVb03~dwmP_-4Iq3PZwtcv6I!UUzyVrl~ct|FI>Qo@cmn=?j zuMEttAp=Y}&xvyTpV+GQffK`%!*L~{@-IK4d(f4XXb`9q{m=*%_oqq zrjtpPA>G@2I&W34XSJ^Ka^G78%hne2eq#ZoLt`Mu(NUB9dYKa3Z6QXXH~AUuzgT8- zsimzk@FN)(mu3<>sAQ%Df4aAmDxqDCy;gn+bV!#^nN}dmPQ!giQ+*e)>Bo8k`i^1? zRP)k^jB!7DK;cxons-rhl-X(V;i!8tz-tD7dh*U>p`I=8_*Wir16P`zy0Q^FNi0*p zfKtf1(_*{vp?3e2HK8HfYb1&F&)x*3fBu1WW@$`6sgG?tlc$WjRYhfV94Y&r5bz>! z^-Xrg8uksx!}FK$wu*VLSXlw`TTHb%ES?5fC-oKBtU+t>?4rltu&nN=IFSNbmJnR` z`Zq1LsgSp5-k*(%u0BbcQl}HocN3IY56Hu8jLNlkwMJhr^#|GP9m!K+VbiC5Tx<;& zBTRF^%(sUcg+vRWBiN3>fjcGEcu~0DkjI>jHo)`=97n$cm$y^14H|o|TLgf*19SF($&V{j$cqKfm}o7ukS8n-Aq zi@1W2nwA0;Jhk(6rj10Ize^>DItp0_H)Knm&B!m6E5&7vLZ}kEdJ`hhnnS{?vY_h} zd4Yh%&FfeW{o`FqLlUyi-o)HoHv8b{ajyVUa$$v{r3*ElX;`2jZtcHK(YRb6U8ILxuzly3VMGFKBC|cKybC-<QR@mW#?R!%Zj zdQ5Cy%SQO54|+uUit>s7C6kQpvW_=L>(C9&WHTr9?|YbCa|6}M6Kqmz30!N0HAbnm z!I#W_kKUJqw1Ty$!HUDfGWf?JvB?+$qVX%i+HechLB~K1)R8R1rP~`vWz(W%c<&|- zm@^U_nszDY*0hsb54(|<=DBCwfQ(+wmRtiK=Zif!J%&MNKpK1mlQrv!vd&Yg;B zj0SuXU;kd!d>l!$5$a+wC#=Z+0t9ck7D&|QLj5?JeTHFxhOkG@LwBEO-VF7}+kPV zk2nvjAF_qb&60=E{HoH5ilNbwDQSP(XC@WPuMkujvI_?@04|vo3T^#DsV$V#hqC?GCH53&Ciz;zoax zCG|6nl5c!X;CJuFx+it@>}DLAU8lhk34ldN!{~K1p2n3gvt8a}g{0p-dYg>iNLRv4 zf?$$WxN=^LDa{bgne_Q^14#jruT>m6Q#B=p*0auzt*ga0pqGZ8F(b;~W3(6mT6{iD zi^ul?wUR8HFoBy1{P=_q(!2>Jcs874|B}6c=mb$ZHEsi9zxoBBv{T3Z2NB)snc|ZW zitl{>59u=bw0Vi=ixl%#lT75mGpJP}5TFHQ5@=x(dNI{j`$3wMEGyKt6PYrXR@l-63du2<|3^pBY02HiV)9V>deY&Wf1Gzu$X^!NK+L-U+ z3!|1q3cAosiL~}=4>en`$Xn`?UFfWSK%^Pf}3f)0FG;2&Dc!hWjS+6+i^0>@E#AbY|a?|6RbMSMvx$O_*w7dz{)@jI&lrzjSANjk;7EE^4EzDjn`RO`NL(doB%V?X(sj zCdSEp0#)tc10TG%+!q{N!jm>Q4q-m{c_1`JX0T#F(8kH3KN|KC3#uAITKL4Fe`5m^ z5{*~B8zo!s0*Ko85H5Zi9V>LeRKB<`p4L_w{z9aNAUc|1#;S$&t5$K)m3*~3oMq`b z`c%dhF0SM39}hQbFzML8mRrYR^1H_OXJ%8-e-@~>WzY_*{5E0sNP2u~~cM~~-C zvuYleR+dmM(CiQI{Z{=}R7)Rb(RI%sb!zYt9PJ?)+Tx+`N|P>lpMU?`_Xqo5$_ zO#D`o@E%hzP-ZY)+$Xwlkf`_T0SzJh|9uUW%0Z@R`?37TB(lFnoR;i?6`J!m1<5QB zE9B#i^Z$Ds`$!=PdoGiv=eQ1ukYw=K*t;g6%Aunwd)*;mEg|At(p;^B-I1%iYi=Ux z|DJX&_$IsT3(p&}1+PeC+2z>v9_|Da?>*%g$)5%y_Fzw^EQw(;g8$Pi?D6S@)T0fv zf)iu}F|o1_3%OM5A)_^!H|78BwW}vzqq^SSa9jW>T>S|#3l+kn2 z?(zh44n^-SgTB@-DY?~g4yEhNqQPei(?%5*`=z&ZQ{1dA^|exVZJr9V1}9GdT!-`^%I; zJ@?nuA9v08pa>>Qa;|B+0kaNii;Bdus-wURi`q?W+Ogy0=obP1?o~X-)k{;2S>jI| z15&EJKhWEG(opLJjMww)vX6#uI24;z_)}KRRKP2a^xwSRi=q&lGK(-WSr5dje$tl< zJYee16}((5?V&!Vc~e!X#t?{Pjg~nazjX2#ptMZa;+oeea24?Wuq)H~1{2m5&WsS< z0uPEnYU+)jZw)uiA#@VnwL8pPR_)Gj`)$4~al)9)lXVZZq8-uTA$n@nIww#zvHdvQ9X0zn$m0$Mv;&()((CAfd` zK}}UVvLpm>(ZiSKX4JnSyGKmjdZVIQ<@G3p0Hh|?7lVJGu79cPgdq=nHNRILI3~{k z#UE8;W_2V8A~)w;?oYDP-Z=$h!TX0T4t-1m4jq>BG z;@8^JikcpSeqyqk{c%5kt;ZSqHT~d8Vy2#(0I3B8=L}8)&sS`p`XB`7YrUJ2ocQa! zy#16~-bshYXhQd2^>LA`@dwY$;;kxz*`Ci=wmFsI>?+>)4>jH@*MXVJQBT-))MA!R z8jJ&LXl|l4xZ!(@h~#R`$eMkJsY>%X|Eb23u3E>6>Xe=AJOfEeVY~53du(Or;h$j< zq`NG-narWY_v06Vz&=G|@+zXl$9uQTq)>v9S`7ZX70WKX_|pW5x%Vlqjy)PR=np5F zfoU(P*fI>OMpmC2RqLSS82D@wam^)~u~7<*tme|aG|*jxXqaCErBc4Sp4;i|X!SIc zLsGa3C_AXOn^E08!li514}QZxsBnUKhc-0$P?-Fz{FhCj#@Q3tf`nu({Ez`Dv$P7s zPp!TO+3H;1^)l-*u$-!ZbnQ1xtd1_1;EZRsLsgq9Kc2|`*=#ag>0Jv@aO2$i=J`z) zs(w}hW~!ZgZ;UqnlrAl_|JKY<$^8g_&8!wx(PEFxcc|T5|HYv;J(>wlhia)AI*=Q` zwpWp=N6|AlC-5x(H&C2Z`z zxeDCClr}ZNrm4C2(Qevo1ZueKp(KWRvV#FyIViphZxi2{AGTbO>8W)(OkX5>k6pKL zn*F7yw||tu#W2%}3L|7qN1#&jDqy0Xy|PD)MuX zk=T5HV4xp$-HMjHQOQseapR!VD|7#Ue(+}+^(E3U`F-t@`E>p1?-rrVv)c1&#GuxY z@VW!IN$1iq&LiPYa>R%kL*OQ{C7(3e5LM6XhbQ+eD;z^ZM1>+Py%07x)SPvicqNiA(zvui9Fb=s$4Uu#=OKtOKX3Bm!DYyAfsK~{OoAB*Q2q;O|TH% ztyhb}`AylMUMjr5eKxZo^|ORg#~!cF>1b^jIbeqI!ZQkk-5h6@%=tIW3<;j=xFh0~ zh&x3~NkqFUa(Ze7QvGeaep11PrwRvkHH~klUal`&mt|~t~XM0p{ z@a?t4P+&h3wXLnzZi_K)=NyBfN>3hxeiTB6)qhPiWbFCf^5w&8+?A6R%v#l;;EKqU zaGCIyJaDdZ#OV1AcaG%8m)np>pR(8;>O$%n=jS)KRukEjZ)d0Dt!#SUTv3(4-tf6_XXP=oKiCnY=(Bz%`y(w^au6b_#|&a@Nfd zbItPHVH!_f6Zw@SK^}8{x@Tm?2TwltKeJ=UwevF=O9_79yX0|Pora+7B9<6H*txR2 z!im6u#fOk47O_JcQBtAVp+7Hf5xC!HKb&@P_C-EzE#VKSX{TEiJCX5*uwP4>U4h=S zntxq43#Ia#iM<%$m^wDW)093TJs?*1;`MTSbWOb;Nn$r<*0WXe_CVCp3#NB!y@H9o zOxI_)`)@BgrX;2*j? z_gx$M6B?N`k{r`=bj|lrg3e(tkvx=JW~hM!4 zu+Oe`glPNRPDwXHZa1Vr=f(}%cK${o&jfh&yLpuqC(3#Gma-SWmoC?RfgOUO359Ll z#?QwZV|Q*hkK$R6-%{|L%MJ%Ky{g*E-vZ# zW}twXx>t^%BF>Q6Rucz_rWN(nV0lke4WD52-_@up!~EmRUuzSSgr8DOhV~5CcbTzL z+CO3)-e$m@YeuC%U^H35D6_U=iSe0t{oF|MrfSdC8hP}-zo>PKi54ccmpMxmNLzPE zJ#OrhHm31GEetW>_xIazJ{Nyp8Z*q}KYxx)W}H$~n-S?$oEQkC0+FApt7e^hEjYcz zr?y4~nCrUjIgqZb^O*JEe|K!*KOPo1RnH_CdpdHV57$it&x?%Ca;Sd$jqNNShs4L? zw@IMhM%0eW(XDPu)K3rNjEXOq#gAbO;}oBSoDP3M^;COaUlviaJ~Ip$GKu!?3>r@8 zHRCd}JH!Z(- zX=m*hGN%!9MLcTlv~tlm?++3edc#tPx=ln~iC<{*^h z%-KYQIPlk;^WjOxb*lv{-$n#_N7hgUbyB|QGb_Ng!!Vqto;&sXXeanSKW@S4vDUb^ z{wX?v*t_eo3?65GueUUs-hV?>`p%rF1`ZMEln?)dVk>dISn;!8LDb$iS|D&y#3@X7 zaFrdw;UFST4msgKGb_w~#m(f)IypR7CuFIoAor60a1nBN1ofLk&cnV4_>4gCQyeL8 zlgZDHp!>@iY5s$O@OJhzr?38({S$_MOE(CXRPGQyQ(qop;=%;!HLzf&<##)M9{pL|mt^?Mu>ZhmSt#t&>7m&ZBtZ8}9i zWrZbF)F5Y_4cH7C=h?+1cjq-bCE6ap_H-o_|Jnvu-k-!nXvi7Ne;tfoa-A`+-Au}C z*$9Q*GeM|I^EXQ$8$&NXC9)?6Ej9b7Sob_5r*OclOLc7kY6!SkB`(NiTGzj z4;$e}xbI%lLchwskiBh1v7FX}GaJg1#S(83=yERSowdnVm|0eu42345UM}6x-B|5Ge_4s zF`Ei@)_(l*s;WmeqQWI93E$l!dg{=4*KSX=T3;ls21(P|^!bN^1<6xL>J1%u*M@3< zE%fFsx93dWA|)RLA_deLp-8(ZX+TvF5pBHL?8Eu4u}3QG4BXn}$_~P5qw-mg!33Z97w12-CBh$s!=b$Nyv(r4RLF*F!c(lRK zJl7#AZQoBie@89AP2Kd)*VNUQk7BysU@WL_(aNpMFx^%7c6vAAcJB(cw{l=JsEe&h z?!L9Eew>pGxa-3 za~?MFUI!oVGZH@vV8iC~d!QkqBa{SlQoj#<1*<+WMw3C~plhN0HDXTh%RuMRfhTQ= z`XNEF(8K*sr;4&kz0_@+FD7qWG>J(!7rA9_PgZyK7|miS4kTsN}xo#@(4*;c!JS%816VR4Y`K|UXUubR~^2D)NEmD;RRKI=sA=jf*G~9>V zb^mQ!ZR=`{YLAnyHp9z`M^g{I^c%QzS1a8iS{x6X_qlwC)q%}DMtHqz;7#i{pm=^y z7irWe7U`i@hM2X{$qdAF zz~vk2&gWc%(Ui)qkgV3JCD-fsj09FK{AKobkDlejh4~`igjGP$CNVbjE<<^g8>j|+ z(-Fn|hUwmDyIs1r11Wi>vL7V9iNkB3KCRVs{=oM+Q{=OZ3L7}`u zzDIfzX7Q0j;Z0VL02UiQ9u`b~oit4c)z+7{CYBsTYU)OI!(er~hJ0mt#f3_>6xFMs z7d6St06Nw)t6&Y|e_d`8o%s7Z&|Ct)B~!ocL=LxiqAO+$FHQJjNfWN8P2c^ox5`YG1Ljph44ok8yqPcQqj%TMM88lTkVKvTnbu9My67vB2Y?cuL!(9b zgsWp`5r^i*4LIp2Er)R?i062vM2f9sLom6~qmv`1Ywa`%HQExHc8}(B*_h_RXwNy) zlkh1u=v)xLI^Er>WaFw=awzUryN;KfCZz%5cG)D8ZB3U)-|VSM&fV0 zgOWO;9gxtAK9>IRBXGp;wEWeqKtRFmHk!}1_}bZe!OxFZSsUmi4)0MNOGt6t6z1Ma zczVz8)(u7a9PGXGk=#J7=wp#nG%4bzv8btRH*vn3+N=bsKnv;!@+|O6DtGzyqrG3V zRq5TPPq`Rf$Np#ji{BlCP19i3t#@{(&J;IOE`D!5iSrr@GIXm#nr>+FD|nyRXqCx{ zf;?{DOfTkK#W>P%&w=w^kPtM<$r60X85a!j&83=RoPC9A*Rmoulp2%vMkJUn?Kg@s zg_uOsH?6uQj!ZPKuP{o# z@0a;Z|Nh;M=U3)57W|?*VDqxyKzK(I(6;O*ow<2xcSahk} z7b_dZ&FOUcjn6%#SRF=8TK1cz=_42d@rTYg=$Wv0QL%0wf{XqPsD>r9!3d{QyaH&0 zs-TL}zkRBn(B&h2^s`@0D>0C?lvkQGlx%^A$qK3KGtcQ<>5Q>TW`PdFIg{jhv~=WI z4We(BK6yt)x1!78j7`(CPG6#1vERD7v*@)pD5C#q5N)hw7{4@$%Ce5Fqnw}!P{exn zD}_=_X2vlYu)lg`C+#n8H@2YowIE9MINqJ5W}Dh!_OML6fj#b*L4<^dmfsw`JjF{* zFU9qi&SGloX}$`%+_Lsn4Z>K$0(}P<=1Yg&DFXZs)#~ZzO|e8817w&jHpf5+~g|vdGvN=uXtLr?-qeD?Z|z7Vq?*{xxHP{V*31Yiy@3=Zyf-QpkJEO z!qQy%B#ws?`QD*V@&}CSV3NRMdUUQZtZS*!rc!yPzxu^&9>`n3?+tdmOR9Tv)+=z~ zy~cPhH3NSeUvSxU*<;$(Oq7aiVtI7vv}WlPn5aG1d(|+UYrEd@EZmUU5ZaU%YysvJ zC_(!p(GDDbm_xzlWsQiWFdvbu7DFibQC+txJ8p%X;kaSA;(g#A$7i@|_Q6G|3)Q;H zt?`R|e&nM5_)>?tp|chpL5Aop_o`Czxt9AbsKL%yoEEjnQEhj1c`C#Bx8EOLnCGPV z8nFE4^7Ma?}IQ_mI+Yaz1-%gJf;G_ZT;|$RM$@{W(RABbA;NzbF<_ zy&Pv`x>pYSL%D?b17*8;z5Rjb!*aoT_~S?S?|&F#oGBh&{o*#VfSZs`7PUWa+@>t@ z@b$}&_6D2D;1)V_l{H?wOi0c=!v~f))LGk+2vW3L#zb$;>7De7{epM7-;o^Qg*}in zD_Dq+BuhX&e+CU_s!uPp?;P(vNBPeU);`|=waZj8a0c2 zMq%6M#$uCI9|3*vlkU|v(rGv1IkH6I$mToY+KLofJ%Z4Md4s-7fZcC&E>jS78p^uQNQ;T92FSvnUA1N1jtiA2WI!>V9nOwsIqD z7<9a2TCJD*h-)wm^4WQt@AFyFCooo-IO)uAgP^~KAx#v?5O_Mpo?YVuxk;Q0>}7LJ zk3;|BGoSpi83+sRl0L+i>u4uLYj}Hg1Pam4w!Uj*xE;;1x-#)VovQsY4i{(}_6TRx zZda<o8+bfsNknH?W2>ol@?y z$(fw-6<#cZ*1!s^n0{&@2&^iDj%;g^G)%7 z{CUwxel9udtCNCfW5Ro@PbI0O?%f_e`{(0I^GR^#Oug&nDG{6X!Nma!#%D8wirFUS z^SxnTaIOH~QCqh)M}6*ovzd>Z&Zno%@wpo^X?cS0dtJ`KQfrcYjpqr}uUUuXGS$&B zr;S@Jo0ph&N!t>ncZppmBg6ZTYB~yby%m;U8fOhl>@J%0hxeEZG<#C~sBf0GyKjbu zJ~gIRgBrI@4N^hxRy+lfLzxb#Ed8k*@^gjK}^zaG)9frs22NPC&GOjCp9+yx`svLw=w>yPv zb&~WW?_aQ}!AsUDsSQB`=~3yk=n9cN%!78zA+CV4+>{QhNjt9b?)sA1+bU`)dXV09 z=9U6xTIue(eQwz$&!@>;2FD;_HmOt0JTyS&mf7ahj}hMf7G&N|+olJ12#>x5Md+o# z{i^Bx*WVZqd47kGSqqR=NyRPP6V-*u1+B2wkQ|q!rcrS5z|d?w9SfC$nDfW za@r2lT10Rk#;U*|h}Xusx3}O)5csVwO3%Z1TVS(1$XDMbkGX=k8(~rKx#h5f>chG{ zHD>;}@cwCosW0h^6ZYgARWi1m0V$A5f3zBd#JNh8rFYYxL{_&^BzY&WyBNE`x+z7z zcZ;Xtw^Uf_oYb6d5tO#C@QW!sFRxB@YH{8Uwt?FlmQ5p3 znHOA)j7wHac)bN>@@3`tR};2xU%6h;9p9IVlKjepF!u6G(!zNh(qeXoastwNAl_5T zD&W^?XOG4&hMHK(3+%W=)?Pes`H1T+c`|)eVHYy}RMPBK+}(HnHTN4gJ~vwVbE)tT zNcH--ebah3?YiJ_hGf9^=uR3%m}=UXAGOIU+I8Z0`Kl%MM^e&c44)FUuO{V@c?!hyH{GX3F17eJJ~!3w z5kAKODPZIj|0DatHoJ*>z9+U92(XPeIdW~~0XD5pja;5%eivn29XY|+mBrgv=fQ_klkPjsXq;67dtlSRZB$_MO=t;KO?~Ri z(f35YubQJ}s-*m!f62hAORW=I#=BZLbV6x#YHX7nX5QA><=I*QRJ83-x;EQCdcyv- zt?+5rgcGDMJ@$No0KO=Nr*t z+G*jL+L-lPmp7|*`@REBGZOlKPt%#J1-j;UfgHX3kRFil9unVc7tKB}jtX{LK^|R- z*wM2pY9aD%AMLxr?w29Oe!fM<@OkWkCJ^2#o!mmtPcu#t2WgUTkKSooy{_`v8}`;t zY0clg_jY?{I()-at3~7k^vs`@6_$4Ns!q_S+tpUb!|?7u7q+c6XhqiP+t+D#mxLgL zwPkQe*E*3J@|2A|?Apcd)Lg-QDeAhA)9?s`#wqelfKo4#chU8Wo%sG_%Ck)E)uN&s zTSu>c7GFRDRHg;h1b?44{ZvH0gKcrwHD=X`N7OnlX~YV$-Z>`16jz;hO0aqWQJLLg1|;`*Ms8qXAZC{z<1l$r?{A;o)*4iI{(;iub0= zhUn3j>Z*w+(M$2`;Ah)DlwkdDWtK@!-yT^Pe_6N2*`f33^z9P!^Z&d@@!U6GS)1UO zkS=j-(%0$&loWql6f{2jN~xvOeDint$qh-Gl(b>Y#IdSmUeV3b3U_Zvk)SQwuo&6! z;gtE^=QoW-l=SXY<09KtmxfP9>1c|Yg$-@nxm0n( z{=vmYI<9faN{blB4 z8!8a}4>9uhCw6TBgK+)DRY?3_aNr6ix%z2M@=AQtk8xd_=(M?77svhJ766*a%F~X# zK1yde=WWx;<0yA{-yrxz86=uU>GYXmT@*~?A+9#83*?tGcGE6PXLtiCpWFmsi0m9v zdqZSgLHj-W>*!_;ch5g|rM^5bdYFvs##a{}1p(ECt$ihFUFPdrS_T(lFp9|_PPH_> zfn|iNn`*6km~U~kFwiM*80#q>#C;$Wxk<2Zl1BV5O49qB;DR&v z2VJHW*PY24>axsxwJ~007+nqF?-I1*p~O^&2Pi6%(=v&7vH$75D=f(RAwY9DK4dW@ z8R|UFm^FGhAyMV$%Ht_H+AzU1LG+&n1p6H;JjfNZa?QH7D%NE9IdxcIKb)I_W|4Y= zhwi_}Yy-ojGex(+jIgx4+qI(fc`k-FmO;*2iB%@4dTMuU@%VuNGXe&!A7Ysd8I>B_M9AWgJ5G8v@yzBH~6d z?<7uq7aw_ai9S(o-h!DZHQtTf4w|F6++@49^Fqta6BzkP?{7}}`~sjv*tuhDgZ$!xVuY0FTMkeDdugUA%t#E_y3m_2ltv zkYg9|>?6Z~D4^B((KQmS;Kx7ke**<#a`zoK@GR}PM4}}iUlEaQy0}ZobJT=8(p9RB ztU37VZ&KY`)%qALNuN?85%c~Zb|lUu1TuRp?hlAjh}(ihe!eHmVvuQ2$D&{n#L7DU z6xG#vA|bx;woZ}d@ANJa1IEu*43*yi@x3rcm8>F9Dy1Qg-+QtYsH3+m4&8^m&<{w^>y7%$(Bw?@ef& zeLx^}&*Pub>CCVr)MG!PV1BO zdi76~@6gASk3DBRm)9bx%-U_ME|^tZyP%TyycDlSY7b!p8IAkJ!euq zt}z`Ge=uoqooQjV;^W__KKsb@9v~7(hy=_SlGedq)&|6xL|8o^Z21cw{|5Az#(4-Z zMgFdA5JDz-^bYoNtot>?S-%!at%)~3i2lL-nKJoXXuP@G!MsVfsh0A5aN{paOIsm{ z1y%2WyDk@d?2o?0P}$YSOSdU{v%Rz8Gai;>D#x>M!E>x$!d?ylUa6JhPH_beDUxbX zIuTO7>@zVX!+p%dPw@Ex)}L{8%K^FVK&dqYQruF$o80I4pnFr6WOB6qmingGIWL_} z&3Ex2X$W^KiW~Lu`H*Q$94i)S?<2MdR%F}FtX@V24L!6xoiZAJ$(^bePM39 zo|hP$6&9@@{rT>1I5{5zV?Q4l<0Qk{m!R`^Z7a}F0{=mrhbX{ZY_sEO78l_8^;59` zNN3#f5B>RDkey5N&j36G6%Hnp|8uN<7GPCtrtfk5>Hlve+aIAZYnN#>YQf&(AKAWzYU5?{g?bYDh)I5iIgg{|L3eKmGrIDs+Q2E*Qs}`KYv? z($dno;Brg#hj|-hHY<4!NN>WG&`U8~EJZ-Uc6E~_Cg65+m!a`WJIX5Hf zlVf1qrG&-jP^{JLeVh58+<1|TjF(wMzx$Of31Dg5DjZ{62aXy>dD_^@FCBeZat$b3 zI<8nq7$0Ncy>SuwJEFjfu-_rz2?gp}HxA~3!QTkK8uE$h0;;4cw*yEjwvYD4zPMU# z_kd+*S|Sq}?@O2C@`4V&0Zc5NKFi%_Nh;Wub;pB`<&r9!HboVp-s#v?4d3{~7(h8D zVnEejwSkfsvsiI9Fx4IMsG26;Da;zD3?%1Qb@q4uN4n7g1^wkNk&JyitW(J=_yIhv zRKb1mS_W&tDRn~>K}z@IoIoyNKa~f>n{|`dOA)(qJI{%*}VNf747!{R?6_WVsq zD0W2NxN(QdTqMG>yB^f39~V~fPP8+&dQ=(_J}0W2dF7925CH*8jJP6h*aLf^_fsN| znd(jG73`RZck=ThJc?KUhzbv^yFj|Nhu!_o-v+~P&iD990#Hd1E8lNx53t04(S?Wy z@PJ&+rFzjhwwp`^;&Sxp=p`Q4W~ago%FbY z`rnRnjJzG?5x6Na3rWy=Zd(jZM0RlUlClSnv_Hr}9SbWEp!}PE`hz)(EMm(HVevCi z&h`ie4HKmkeo*S%r<1jJEE6i01;^`o@$Du9scD2qn!;v?x;*ulKg)8*^$T>i?C(6L zIBX0d>swFJp)g!2NBUz_h9xm;yoomsHjGDLx&7mZ$NitJT9Z*0bp_Xp)2#9fiO_Ks zO6umn^$M^?_a6az;`*-9RuJRVbJv0G(s1eF>i0Hj=$+%V7hC#fy(vRwxerQqws=&! z#wBd_BT&yYSEWT%g$nH_DudSEkZxo#R3cg{aYDYBkg&eIGeJ|10+%E5ivu4i=kmT4 ztE(;uw_-1sU}v|Z9}~n~!fn*6`^!pZLd~)dy>}n^x)6OBFWjbKYoXUMp+5D2?=9&7 zzk`3wEp_V>1{N^*ZEQq@hRnqHp)bv#lNyc-YPtMM@LN=j#{S^udPSy5$OHDdCo9Pn zS33f{FQ~0mK)sLGGu%z&J_y|FP^}H&w(=_7y)(h(ctTcJHdkVg zYb7^w;=7fW>RU4*4WnkZAjVHFwBwE3Y!6Bq3s_6O+~~|*NFLL?o?E=L#r^uB5s+^* zG%L(l{i!F2+oiPZcBtye7r%~Fxv3gEw94S|ceZD%Y*2_(;JB36^sZoTRd}9EKM&X4 z(k$R?0m#+D9F@CMKa;iH%*M^Y@?OEt0izA<|M2@(^gEj!dS9h%q;nT^m8oGf&5_-2 z|HuZ{jSPwZFy!~Gc{C7dq9z$TJ6=C<`y*Mq*}ecvUSq9Ap4Vo+wJs zi`7_|q88iRNr{YDv(>&Irqjyi<`9lLtT@@N&!a`|;+Io|Q87>1dfO+_@ zo05C3>XUC%T=L6}Pdu%>_S#2T2_YVvmlmL{btOCU!0jUuab9eRP=yXHr+c51BQtmf zA!ElBl3SU#|1M81kBMJ&q4N_yy5t%SKuPAu@}nJZh4~9!l{KK9JnLClA|Xq>g{UmZr) zRrTVli%T{Xk9^D5hS>$@2_?pwn9#kA^U`LE!USO=X4gh7c{z)zUTfgq=I!~s z%Z#1TgWq4v?bZt)k5Uc{>%>3wGsauF@o{8i*~H9z7|deY{sD49d0!RR^>Hb5rwEsk zGwI6+k3U+p{t?;qwV|3Zsob$`(MAW-tW9XcJ;Qx4()dL=(pPhL{=LiUwozz<+6Sxb ziEiQd?Hpy6YudB96{KQSi`f^0pPCAkfavgLK&YYV2xAXkwY4aQquEk7bER z28Q5g%CSv?8u6WKw2i4j{;;H$(4#`v`J>2`djjLstx7tsJqZQGOULr7H+Kp7jA%!e zZQN*2kG7Iok>8v(<_Zs~qi?k^dogz$X4`BQ#+Yk!*^plf49&atZUmI*eKj>P2TMqc}vUE-v`c3m5_fe0)*?%H~{8de{c-I}R2 z!edqP_5s1_ZLYv!U16U$o1z96RzA=Nhz2!6Sgq-kDQl*|Gc@>Ptd5wsRf;d$vIT23 zs%7*=#z%Fbr|b%AWxc5IhIHXdH~FaGik~%I#nEi~VQ)`t0zP2M#(?CTs*|=x%5Z_> zE>Dc&h!TP{Ks-(S0~yswNAoAqgu4nwy^@25Z-9%ILmykI%t?p+kfBj0Cprw5tAe~W z4rp+_{Xt-1;rZ@0+i_t5U$Z%-J}i@VgI;vHXFa)>ckt;_6ou;b$hM&QXF0NjbhmCZLxphS z!-@NlK^HXVEg|5%9LH#DtZL*iPPwXf=8J|`5&RQMTkc$voXCuKW_~lsnCiOX``0xR z8M$*J@=#fW#byjtrjkWv7}lk75U1t`V#Q*FLzF`$wYMt3 zw(_vl<&%E2p->Y2v&?5$gg`tZNi0(pE=EeTnZ2c@6wLY+P7&2DINLJ;qPtG@n!Gpn za{Nat-tL+@vTH8wgO;UaTK-}>ChtW1uy*ToKNHzMB3Q^*`8MbTPPw`>daKrIL;U{n z{9NqE&+@HBqs{hegd+}XD=7joRs}X<#|ac#AQf~Jj9 zXfgNl;LN0DeMf#px3b(7wr072JX@2LYU8?VnY;XDMo}F3Hj0Q}VfudzV#PhcwIS?o z)gXSs1kazJYUeC}Ri>wBZ`D+4969gZuEYvQ%g;Ax&^(V8gcsAyR1Q^h61;l=E9-Bp zDwPVWuBN_LPC#ED6Payq_b!B$-Ec*lRZiP6Z<3WgmFB8Syo9!#Q%0XUGx_^sIktB& zq^$*NhqlhZ=j2F)=<0+pRf=*}VPeJT*s{@$P&%RM=ZC_CpV(fN1g^b8EvPaBDclM(esoQ znWkJJsrj^DO}C;o{vPhCNLfCI{8C8T${KZEt;t^nuZ*S&Nn@DAB0$7V4JTlRTvGs&;>Ot z9jq7Wk~d7OGXYgUmlGQ4sr-HA;2T?KY6L=4+s+RP+I(S=X(p5`>a;02eo|CB6m#u5 zC)}YR7=w@QNl_zhSXRhu$!nfY*9xGJz9{BNv<%iC+TmvIQ&w%>*1hr?nK4DBLtEmgH$ z5gmHXD;$*MR`^rHODW{9%NmAm&ll`tAVF&+*{KgmLP{NOZ2CLg=^w8d?QHa)%wma= z%&9ih_&~PZM{%jr@>QR>)5eA9fT9|9Z)-Q13MFMe*X^kuWtODJC?2b*TMe&?-aV);QyTBqWRyW4J^ z*Bf5#gkfIgfLHc}Br&inZjXBz;D%8FsN|6lU+u{)WyV)rr|M)*_rQC1eN|*NZj0}S zxC!!l>Y3TS@lugY|Gc{){FenpGfe7H-Eu&D+6^{a2Zg zah-|PD@1S(|H|4_@~HFAp&>M3xfoQ4I~qnTe2;9(RDbh+$CVn3KKs}cbab-LfOe-A zdFU}j!q!QL%SsI{Gq?~UWSnxD*3ZfdYzD2k_Imq%Nzh>_kjdjI? z?vt_HrUluxprwj-(t#e16bI(u1ODfFgZN5${N!MpS73Vwbb`wmLuk1|LaXfuVd=Zi z7_D^pSvH!p?lr&;%47}AysYz>YTIhMLQe%*am)H!iLP$A?nt?;kfXg8-e%;ORTlSJ zC>?VJ3px+(gt+nYK6<~q{z3sA<~p@$S7sj0Za;sTu)4hvzq`=x?P1usn$+3qxqO}f zTLza!f>5`(O%m7rkhM4^DLWgCpjlCYv7#tkTSZt(q~n%E($!&3LOT2s)ziR@!na<$ zlEL3!X%PChQRZHbLdGo9O;PRCv?E;2p>iJ zefSE@tm^DkGN`lwx@FqJ9oIhJ~}e79weHcG1Rj3xrsr-h562ziMwB8;=72D4_wqME->uDx?@5zGJgz7e3HGtazBipNz* z!Mgp|eFPsx@`HqM3nBdqD%~i_<>~I(uNz16v3G z9pryt^8YNlA26C8gYa zSpO{k2+*hTM6H4GYm=q7;2LMUDX!0C7YyrJ*0VwsWha;K95=d7hV6Qu7Aml|F8Ij z^T$Yxk~J=`2VkQGH0u}Me~iV**eMzi!)c=58Oy_7IM_o)GF0uvL3hr{BHF-a>w;nV z%ca*?T2=OP$L$MPbU=ao)^2~5JPnFt@Fi-EG(12+Ns6oLHK_ic)}{}zm$cuf+JEcM zF52I0KoNBoF%@v1roZHY?E#d1)`;b{sK2OlJmQ#uZ6J9EqVSI*W4N}9-97nrC+YDw zG^*?aA(*O26s%_1o7(~zC2B$006cmgE8q}xJj<+_!3zBubp-|M6EXuiZ^Je>-vb+N z4ygH9E7@3k1#akm2l>!toKLV0wyL4&jlG07%|#cq<5elZx^a(}4y9nf%nD2Q`UdQL zngx)tN`FwRaW(|X1JMFSm&x^#%h0Y1y~|%Xhj!1d17B8UF9MR*h6&gOjmD}lg{YBo zqpw21j%@;l8{78z;+H>#e%jms1$%@R)41djk1HFu7c*wvnipP%Ssfm6-d!~T3Xui{ z^yKMH6zVlvtPDOSA6v--Dp~BsEv7YWM82aJnYDqoE?43L~eo&ba-TZIW*Hyd?uER(2K*t*SRF z368J+fN(*0fmEW#B8Q1%c9-R>);=a`K}TQ0JB0l)LfTBVk!xi>Sr*^?a``Z$e5v4b~%6`2=hrN7W1U(uLn) zO&{11E&^__W()?h`MjuI*Y(P5mq=ru8a_=CeBu4aiJ+@HQx#RGnILRaxg;Rdba$sf zhYWk#3c4i&RAg2dUJ!Cf2LWXn2kTCS*6J-uv~>=VJQjnw!J=wAXZ454wf9PA4Vr4# zy=+iDlFUQEhPr_ym30_oSt$eA5=fZ`7~5>G%D)k#jBsdpg>awa$q#?Hx`o0K{>I>X zg0ecDH39p;GZLIDI}?Qw(7i^MMoi3!>)sTs@bI(PjS`?BdiED`jbPrx9|}-QV4MHN z+akTjYgL==9kS?1?E?$)j*-yd_88733!p@D*cb#;hXRTS6if3 zSfv@Q3WN4ffEkEZ-JgAcUIlKPC2!g_jMUOQM7xQoJvKi+u-i>Mm4dab&PD1J2C!8& z&4MB_95-h%rsk{D%>lp{=|i9C4~G*^wuxN!rf|-Rg&bFpbtgVdU_PVe^8z z6Vtt}KRCGLam4J-45S->CKJ(R#_G#ZD8ZrmW|V=__#~5~A+z^lF+9Q&`9Kavj~{G_ zgsBsMLbCPTXU`RE?16?W$FqXV9<7=V=H9C|n_IUWtMpla>r{^c(o6_;kf+x{`?GEh z4f+Vpt-i_dK}|z1C9_gXKV%zF(~WZ8KyrdhQea@MVxepDrO-})Un3O?sIYv^BR=MN zVt5_)?x(-{SWwFYISzaIm%aMmH$SJ@IxFve560L~Pbv(4a4=s>>Lx<29mLIjY%j?NIo3@BD=+dUa zg*+<}OJce>0iFX74Ur^myz5lF_dNw!R@DoDOJ?|e`#acA6MQ(VN&u;uQ%g5=Eiv+F zhwiQNUW3a}vkAtzrWO(VTxa8p0W zJleBH2Cls?GLU%8aa0Y(%<`@SK<)5-I?vL!$cC2N z+0f?O6GW|uZArbw&3nKyv*Jc{(LlD73-?n3YDwBbYu{$Ha!(sp2JSubPv7sK*lQ`r zy2*+JZhv0z+>Qx`&V>uxTJ3k!#VO18r`ygI68^Cq=-hB8{kjrsQ__huxjX=W+00p@ zOs~y9nAjIU zmHDhM-G@|SPL6T*q^GYm+)ZDrJGIoowJ2f#WS?P&LbCco{IsGyi;&s-6Ur;M=5ku) z0hK&XF5tq8yfuECf+kw!Y9!A7cAqe<-~{P`*;_mAL%toh;DRBA>>hCG4!~A;4JK zt|s&1>s{K+tdz4!U>s+3cQB0U$g3D2ZO%#$uiN>w+SZp)sj!t+TFj#o?S75DiTV#% z`+&6+A6)Z1J`@PCyts9BW2$mxyLzFfw@kt&xu^k1cwqYk{d)Uv#`I{BcIWE}EL||+ z!G8bwJGH|RhhS@k=|GC4q4HQ4;MV@}kS=(inGx>ux6JZwm1i~-mHpPK|IkAcaLsTE zrN7{uC~##W-mgR~`2fGb3sQhk97q)tnS20bluIf6ozmqjZ+;a$HziY= zo4&!O;jN|c;Ox_RH@8hAFs&hnP>kMsmuO<%@Y^DFaIqq>#5ss(;?lS4ROstfbtXlq zt{I0u?^9_=mPK*Zg0QK|bTid#b)EX8uMpEH-0 z4!9$wN8MeP@yzUD@$iiFZeUtu`_||GHiWsac;29HtB&#C~{;+%4i;^9!ts1aoIXPU)Uz z(DbjYZBX|?B+qGM|;YXi|j?cC;L zcc}GU%9n00XLC!nJ!g6*kiPPGW$ELUo00OxUX+pIRSk#V?IC$?RW+zmMk^ot=K|r- zHw(w+a>JF6zbRH)*uZjn4^=UTADWLQxU7yI_UH6=nbt~823gyujqr?syDVo0<>=== z-#8qaM}fcWXXYWfP3CY^=8p_XN=(e&dZW3H9|T8^C!S~@QKG4e{Q zEe41$0{FHY^V5b~9_7JF*T0B$xy*HnHL6k<6leFdRfBYYuG-2((L`?!tw z>?IFVJmZtKDNw8W=xI&aJ(4cS@cNdyi)$4ZI~!gr=<$jl9Yx(jifn@X@bF=RY4A^{ zmV~&|6s=WlbH7@h+N}j3vn;jF64P}Q4B|sy4RFfbQckUVTcU~0ZePFigq@}Ml+C&G zgnNfgU`VXwU`!X^;H0q?I^6(dSz(q=T1NzljB1y6biC0#)JIoV28(U>a=DMD7{(`xg^@ndR=8Li zCcQf=NxN5i2lHa#eB_C5X^N7d%gYOX)#%6*H1e_9)9*O+Y@kUQ`(R8D9&3>7NCDRq zIYfc)qAlnL_c`w?I$(L)Amaa$6ai9o^Fiz2-4!vYu}g)dYKjBIKT;LzfVOX~G(hLb z12jvsVs+pO#+#0vI25ir3STkbi72(#pGf^!s8!??PH6pviE?554YkYkxTF`_$CmbH zuiz7OU47`Iki%vpb|NQXE_CzcK<&Fa4zXcj$K&URYdJha*_~#nuh3R(H0lRFR&;3Ri>oe7nRxx<|Ago3WpCTe@P{*Cfgx^AbOm-7m2^gigh7 z<^`6rn^1}`9~1{BTacpK!5^dV54NyC#W~ zU@0G#a?9qdAf#{46P)WScaeC&CtvyW=?Wd7W*oX4ht~GipT!p0mq7+=;umfRft)5a zh)ke^USWdKjLmW%lfw604TfFnS8^cuCMv$00Rc=J9OUNsBSL8ttsnQkl&jEJweIje z!mUF@)~@K)$uw54po1#g#d~KW$8CU zGU`nK>cQM}r4#)$AQ=iEnA>uMq>Cm^2BM04T%gG-y!sN$tSq<0OjA~-`*{Siz(DY| z&VEKt?7^GegME*1A(l@%PQj?Bg4a#2uW+`_AAYdK=TkrwlXX67BU`DGAh@FvxOqND87WXK5gXwh91npy z$_=I}Pe0M7aL>$puzHM|Q(C3LGzFB4Z!+)l3pzQP)~#h*;t#Oz-X5*vnyw~pIi(C1 zRpdS8@s6h?OLu8mfOqunn1wmW$vO-a{E*(de8K9I$Bw$G#jJVH`}-Qn=klT#p-rv} z*?et%_g5BZM%$jN`bq(L7+1fMS!t3rZP@F0F4Gf*TF%jov{CLa#u+v0ZE9$r?$S1X z&=uZQ+$_O~r)bD8RmypB3f&Y=U0pdEZvjFZmw$2T!%pWsA(Y&2FAU0|GfrnxVZ}6bed$+5;%RahB=1>&9 zw?s-l46WTm9tyeBQ4X<@kmf^TX!EX)F!WK)wy9JxtJ^DLLUuQ&c~Uz|p{lfP+vZEQ zWjU3816Rf1g;Vj~o*q8s5ehd5{HPZR{Ie3dR5c>G_-)}+g)A+00uiHhcY8k;OgH3O&Gcxx>4M2ze8P_h($o5>eKZVsZnQsXA9wWUIH z!#|v9N@--$ZfDNbA1`blHf(4-UfCpB@R77oPi$HqAWe>OWA%vX;w;~PlZeQDXXy2% zL+LOI?iI0Qt%@&)kYNC)*!vRHmvw6RbX#AVij5g$1`W5kTzbpm=gp0~TBw=hJm zb-e?2e}sKPF89reJG^5YvG66wywpHqX?YT)%10zW2;K&z}+5G zeLxs8am-yfB=X(zrmr1(yk;|g!qTYOgSbuTY?A^AX@OM;#-SmR3pseU=3`xzWh<`> z3?&Kl)4*yn!EVxH`3pB!rsW{x#nsgsMf_Y^ub){&4$PVioY;3Fu|vMqCzj?Gns&zL zKWx7jRbEjzuoYq`O%9@vvG^kF?Qy z_a|)Uo(FgOC;XtIPIbUD=zJm{509&?6B0taF3&#BUSD5~o_smXwPU6g{oKitZL4CV zgWS77LLs+kC|7*Z6($^t26jfufe6;|{UX^xk3Z-2zOMn>Drh#opbk7NFQyb5tcT&o zw*Yr4FeTRH-fs>37v1;e(ZAfKBmTqliaPVWh6`Z7&nBPm*grjU?=EM)!8>pH$4~Kk z0l0Tj$I>|${FYxo>8=2M{VxV_l(*H}Wyx#D@{j{_kHi`JNn#r&^ZNfg58%B!5QfI# z_jowV?9mA>!Dx;h-4oBdm~o-#cm^&a19ra=Tc4o)sV3GikJb3-wHs7#fP?f!X)=*m z*nh2M zAl9QtDvl88fd81F*x3ZJWXk>ni@(9by*T*UTTg-LK|Foo8-oA9q9lOD{|@p$F!_Hg zx=ts? zrNE7+%Gv4JVfRgXr5Qa$yzak;)h&ZU+^bib1iqltx$OH3!?e#v7Q54UH4QVGNW{B!xd^$WJ=;ul?I znG0=~u?B7ur{wUYJB$CTl@aKGu|7NE&(}aV+01m}s#dZ)zW-{KCS3$>bKKQ^VV4sj zvV=7@HkOrqp~@%gfR{W44eA z^3>hls|SaNN>)}@ODN;tU?_Tv82uHV9SV%)bGOiRpdAM6m~xu*&eGFTa+%utyS+3m z1ugjqS6j6mYX6$`uZi*^u_A0}Ev!)_bltCYx8^(Lf)PYPG-J_gz)tCb%mm)~t1xj9 zUrW5I=Xk_-*v__AGV;LX*M&8!xe3O?nxCB`jSLsJhgAe|?=lt+zToN6rfuP{aY;9h zGoR>hNV_R6(^qn>X)uZ;`H7UAJ(WwMI>)?mv^(+GQ)j}PbdNQYhRtE>i&N6tH_uk- z3|`nP0Eo2OGCX2l5#7^YJ+JegFIu-g&~x*ZBquCvZ~RqZN((aQ%s}A2=6~jpN4_4n zI2i1~LibqTBZzOzWSdbeF->rqsJzEdM{6GiTlci-MV6YqTF7`?+7n(^B||-0$vgmc zU6gdKsfvO4|6a#QDFWOcNsZSKY-zGPrSDvSu)=B&P+1WPi>J!cH(&A79Du>SM?Knk zw0NrLz&+L!@ugv#=JMbsxx-OeC70iCNF%G8RK`L$fBN7YM$}z8@p8CY#Vaf^Y%9s9PL zgr@z$)8Y^$H-cF;)|k7!#U>L8uek>?*mvlQL0OS4Jc_c_KWIB5N_W$OO3rZ9xpnQB z%nvOsp`rWifd-9kd($cKjF;4@L{42NiTj}hCy$#;3E45ZM~T}%(-*vHcVo`y`knYP zM~@h9HC^{Omf3_xqRN6I#i>1NEL8puL{y{BKxsy8;(J7DZ~X|_srJFN6Au+EFS5K= zLvw@?M9$f@|2;0C_rnY3w62$_0S2l3{F^H``+`6MlnYdTv`qGLZOJaL8^5lV+Nqv~ zWNK5NuiYVkYLCjGmj`%Pr#Q?O*$0;&5XlAU$3gR^xnF*C_*Qt+u9zngd6xb19qrdCevt1Hk(4$hLu!G+f^Ijt++$c|{ z?HE9k!STC3bZkj0&(m1SSr z?FV1hG8tJb@11t7sks7aHe#AhqJg-Xbg-ZuSv&LVpPb7I162`UA^X;ra1!1WEFShF zO@4#T0gieE*nO#QFCcv<=!f?X|8(%})03mIW|!J?=7H4#vhTzII6MFR z-uik>Yu|i4WXZ@Z;w!;hHfh!Q!(icvD>H7jM-zc?qS;}DoAUuU>n?lR$*Ku0JJy=8 z@D_@J7rJ!B{+0C#xGzDl@hDoUfDLO~;r-{p$1Wxb8$s(7&oJN)w5;1k?MQFFCPG+n zXXp7Cz*Eh!G6BTYdQK;v!+du#ww-SbZZZFSAP+K`u7=qm%3NI^>vV9L9IjFGeHsrR zDM*VY*NzklGwP>RKXkOV{Aqf69I7K@(~7x;piVwijZIMK*phu;Ldr4d=CYK?J$}%k z3QgsnL*;bgH)_&_=zsFF?X zOBNh+f-2YiY*$h!C+wZ>fx_ZX7pp1z{&Fe;(|K$JNY;hj1~azO4I;@nCS_bA(`+*0 z)qftfl#0I#B9&=2NXNhgL#Y`fv|D$YHPI`(m=6_I;P&hY(=>f@z+McAh#C$ir#G z?^hA*?trZ?D}e$$zNMm!j$_bqTGCPKSpI7OrJ#wbaQxYmlJgr|410e3+6Fb~mJ?ekEY0zychoG@j?nXM*nn47Q^rNL9(%^c3QVe&i z%fkFv{!HZzbU~OG;}Bpt-i)^|gMDDU&@T89B2!DmC%P|YMw3$qNaYC$)IWh6s!)n>AxVdZ1M%R}E5JfMB=LHQkb(m;yROjs-=DMrjjMYXj z;dYdM}a@8K1`G^doUbvTfkoE%v7tT zLt6b^w%l$B^jfifdsxFxxwBv-iEtRSd<#RfEnNX;ncZNTIuxOn{Jm%>*6t@RyQ>GM z6K3dyZwE84lB%i%cDA>vcp&JgzW7Q@WO_?Jc8u8E1kts}^?c+RR*w$lozqSOr?-dfoLi!noR+rJ)2kHVKdW?GOG0A@ zbgZoOq3N4J$RPTKCObm68@6$@3(vExpk%_W^Tv>>vf0j7{;f~jQR#^RTQ@SAN>U8? zoXka3-a0vs^-dGrDP;t|LbV=7ghmdJ7rkO>&W>Gpk#+8Boo=7#*j= zArAo>yW6pztUus(L6UsbU2FY!{&)hf-p4DrAG>X7)KG4;gCmq0cv*< zN_^ZN3tnTk=!+d-Yn*uw|F?o`Tb8b#N6P#D>|loA`|vEki&OUj=!(c=CeJ`eS66p( zs7^{%>JM&346z?AT9yw{z~=o>_qau%3(U z*VBT|O~PNT!$1ea4a>~uq<%(n?EuMz|0PX*fnPJvIcKSVMxG#EYoJeh5&J>f8Hvp8slX2keXvG8N%}q44!{Kn(mBvHwNv|9gr( aqW!Rg*VdkRkK_{Y_d-HmJYQ7z!~X+RoGsn} literal 0 HcmV?d00001