├── .gitignore ├── README.md ├── prompts ├── text_analyzer.yaml └── vision_analyzer.yaml ├── pyproject.toml ├── requirements.txt └── src ├── analyzers ├── __pycache__ │ ├── base_analyzer.cpython-312.pyc │ └── text_analyzer.cpython-312.pyc ├── base_analyzer.py ├── text_analyzer.py └── vision_analyzer.py ├── decision_maker.py ├── main.py ├── model_manager.py ├── navigator.py ├── plugins ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-312.pyc │ ├── bitwarden.cpython-312.pyc │ ├── plugin_interface.cpython-312.pyc │ └── plugin_manager.cpython-312.pyc ├── bitwarden.py ├── plugin_interface.py └── plugin_manager.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | src/__pycache__ 3 | .python-version 4 | output.txt 5 | test.py 6 | src/logs/ 7 | .aider.chat.history.md 8 | .aider.input.history 9 | .aider.tags.cache.v3 10 | .ruff_cache/ 11 | .aider* 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webTalk - Your AI Web Assistant 🌐 2 | 3 | webTalk is a powerful Python tool that lets AI navigate websites and complete tasks for you. Think of it as your personal web assistant that can handle repetitive online tasks while you focus on more important things, similar to Anthropic's computer use capability but specifically for in-browser use. 4 | 5 | ## ✨ What Makes webTalk Special? 6 | 7 | - **Simple to Use**: Just tell it what you want in plain English 8 | - **Smart Navigation**: Uses GenAI to understand web pages like a human would 9 | - **Visual Feedback**: Shows you exactly what it's doing (optional) 10 | - **Plugin System**: Easily add new features to make it even more powerful (currently features Bitwarden integration for secure logins!) 11 | 12 | ## 🚀 Quick Example 13 | 14 | ```bash 15 | python main.py "Go to amazon.com, search for headphones under $50, and open the highest-rated one" 16 | ``` 17 | 18 | That's it! webTalk will handle all the clicking, scrolling, and searching for you. 19 | 20 | ## 📦 Installation 21 | 22 | 1. Clone this project: 23 | ```bash 24 | git clone https://github.com/SamDc73/webTalk.git 25 | cd webTalk 26 | ``` 27 | 28 | 2. Install what you need: 29 | ```bash 30 | pip install -r requirements.txt 31 | playwright install 32 | ``` 33 | 34 | 3. Add your OpenAI API key to a `.env` file: 35 | ```bash 36 | OPENAI_API_KEY=your_api_key_here 37 | ``` 38 | 39 | ## 💡 What Can You Do With It? 40 | 41 | - Search and compare products across websites 42 | - Fill out forms automatically 43 | - Check prices across different stores 44 | - Log into your accounts securely (via Bitwarden) 45 | - Monitor websites for changes 46 | - And much more! 47 | 48 | ## ⚙️ Options You Can Use 49 | 50 | - `--method xpath`: Choose how to find things on the page (xpath or ocr) 51 | - `--show-visuals`: See what the AI is doing on the page 52 | - `--verbose`: Get more detailed information 53 | - `--model`: Pick your AI model (currently supporting OpenAI or Groq) 54 | 55 | ## 🛠️ Want to Make It Better? 56 | 57 | We love help! If you want to improve webTalk: 58 | 1. Fork the project 59 | 2. Create your feature branch 60 | 3. Make your changes 61 | 4. Send us a pull request 62 | 63 | *Got questions? Open an issue - we're here to help!* 64 | 65 | ## 🚧 Coming Soon 66 | 67 | - Support for more AI models 68 | - Vision component implementation (beyond XPath) 69 | - Performance optimization through caching 70 | - User-friendly GUI for real-time interaction 71 | - New plugins in development: 72 | - memGPT integration 73 | - Proxy manager 74 | - Email integration 75 | 76 | --- 77 | 78 | *Interested in contributing to any of our upcoming features? I'd love your help!* 79 | -------------------------------------------------------------------------------- /prompts/text_analyzer.yaml: -------------------------------------------------------------------------------- 1 | system_message: | 2 | You are an AI assistant that navigates web pages. 3 | Be decisive and provide all necessary actions to complete the task. 4 | Use the exact information provided in the task for any inputs or actions. 5 | Do not invent or assume any information not explicitly provided. 6 | 7 | user_message: | 8 | Task: {task} 9 | Current URL: {current_url} 10 | Page elements: 11 | {elements_description} 12 | 13 | Information to use: 14 | {task_instructions} 15 | 16 | Plugin data: 17 | {plugin_info} 18 | 19 | Decide the next action(s): 20 | - To click an element, respond with the element number. 21 | - To input text, respond with the element number followed by a colon and the text to input. 22 | - To press Enter or submit a form, respond with "ENTER". 23 | - For form filling, provide all necessary inputs in one decision, separated by semicolons (;), including the final submit action. 24 | - For search tasks, input the search term and then click the search button. 25 | - If the task is complete, respond with "DONE". 26 | 27 | Your decision: 28 | -------------------------------------------------------------------------------- /prompts/vision_analyzer.yaml: -------------------------------------------------------------------------------- 1 | system_message: | 2 | You are an AI assistant that navigates web pages based on visual information. 3 | Analyze the provided screenshot and decide on the next action to complete the task. 4 | Be precise in your instructions, referring to visual elements by their appearance or location. 5 | 6 | user_message: | 7 | Task: {task} 8 | Current URL: {current_url} 9 | 10 | Analyze the provided screenshot and decide on the next action to complete the task. 11 | 12 | Plugin data: 13 | {plugin_info} 14 | 15 | Decide the next action(s): 16 | - To click an element, describe its visual appearance and location. 17 | - To input text, describe the input field's appearance and location, followed by the text to input. 18 | - To scroll, specify the direction and approximate amount. 19 | - If the task is complete, respond with "DONE". 20 | 21 | Your decision: 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "webtalk" 3 | version = "0.0.1" 4 | description = " Python tool using AI for intelligent web navigation and task completion" 5 | 6 | [tool.ruff] 7 | line-length = 120 8 | target-version = "py310" 9 | 10 | [tool.ruff.lint] 11 | select = ["ALL"] 12 | ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "ANN101", "ANN102", "E501"] 13 | fixable = ["ALL"] 14 | logger-objects = ["logging_setup.logger"] 15 | 16 | [tool.ruff.lint.per-file-ignores] 17 | "__init__.py" = ["F401"] 18 | 19 | [tool.ruff.lint.pydocstyle] 20 | convention = "numpy" 21 | 22 | [tool.ruff.lint.mccabe] 23 | max-complexity = 10 24 | 25 | [tool.ruff.lint.isort] 26 | combine-as-imports = true 27 | lines-after-imports = 2 28 | 29 | [tool.ruff.lint.pyupgrade] 30 | keep-runtime-typing = true 31 | 32 | [tool.ruff.format] 33 | quote-style = "double" 34 | line-ending = "auto" 35 | 36 | [tool.mypy] 37 | strict = true 38 | warn_return_any = true 39 | warn_unused_configs = true 40 | ignore_missing_imports = true 41 | follow_imports = "silent" 42 | show_column_numbers = true 43 | plugins = ["pytest_playwright.mypy"] 44 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | playwright 2 | litellm 3 | python-dotenv 4 | langfuse 5 | pyyaml 6 | 7 | -------------------------------------------------------------------------------- /src/analyzers/__pycache__/base_analyzer.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamDc73/WebTalk/f9dea8420629d5af7223e54b5de188da3ec00f79/src/analyzers/__pycache__/base_analyzer.cpython-312.pyc -------------------------------------------------------------------------------- /src/analyzers/__pycache__/text_analyzer.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamDc73/WebTalk/f9dea8420629d5af7223e54b5de188da3ec00f79/src/analyzers/__pycache__/text_analyzer.cpython-312.pyc -------------------------------------------------------------------------------- /src/analyzers/base_analyzer.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class BaseAnalyzer(ABC): 6 | @abstractmethod 7 | async def analyze(self, context: dict[str, Any]) -> str: 8 | pass 9 | 10 | async def is_task_completed(self, task: str, current_url: str) -> bool: 11 | prompt = f"""Task: {task} 12 | Current URL: {current_url} 13 | 14 | Is the task completed? Respond with 'Yes' if the task is completed, or 'No' if it's not.""" 15 | 16 | try: 17 | completion = await self.model_manager.get_completion( 18 | [ 19 | { 20 | "role": "system", 21 | "content": "You are an AI assistant that determines if a web navigation task is completed.", 22 | }, 23 | {"role": "user", "content": prompt}, 24 | ], 25 | ) 26 | return completion.strip().lower() == "yes" 27 | except Exception as e: 28 | self.logger.exception("Error checking task completion: %s", str(e)) 29 | return False 30 | -------------------------------------------------------------------------------- /src/analyzers/text_analyzer.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from model_manager import ModelManager 4 | from utils import extract_key_value_pairs, format_prompt, get_logger, load_prompt 5 | 6 | from .base_analyzer import BaseAnalyzer 7 | 8 | 9 | class TextAnalyzer(BaseAnalyzer): 10 | def __init__(self, model_manager: ModelManager) -> None: 11 | self.model_manager = model_manager 12 | self.logger = get_logger() 13 | self.prompt_template = load_prompt("text_analyzer") 14 | 15 | async def analyze(self, context: dict[str, Any]) -> str: 16 | prompt = self.generate_prompt(context) 17 | try: 18 | decision = await self.model_manager.get_completion( 19 | [ 20 | {"role": "system", "content": prompt["system_message"]}, 21 | {"role": "user", "content": prompt["user_message"]}, 22 | ], 23 | ) 24 | return decision 25 | except Exception as e: 26 | self.logger.exception("Error with AI model: %s", str(e)) 27 | return None 28 | 29 | def generate_prompt(self, context: dict[str, Any]) -> dict[str, str]: 30 | mapped_elements = context["mapped_elements"] 31 | task = context["task"] 32 | current_url = context["current_url"] 33 | plugin_data = context["plugin_data"] 34 | 35 | elements_description = "\n".join( 36 | f"{num}: {info['description']} ({info['type']})" for num, info in mapped_elements.items() 37 | ) 38 | 39 | task_info = extract_key_value_pairs(task) 40 | task_instructions = "\n".join(f"- Fill '{key}' with '{value}'" for key, value in task_info.items()) 41 | 42 | plugin_info = "\n".join(f"- {key}: {value}" for key, value in plugin_data.items()) 43 | 44 | return format_prompt( 45 | self.prompt_template, 46 | task=task, 47 | current_url=current_url, 48 | elements_description=elements_description, 49 | task_instructions=task_instructions, 50 | plugin_info=plugin_info, 51 | ) 52 | 53 | async def is_task_completed(self, task: str, current_url: str) -> bool: 54 | prompt = f"""Task: {task} 55 | Current URL: {current_url} 56 | 57 | Is the task completed? Respond with 'Yes' if the task is completed, or 'No' if it's not.""" 58 | 59 | try: 60 | completion = await self.model_manager.get_completion( 61 | [ 62 | { 63 | "role": "system", 64 | "content": "You are an AI assistant that determines if a web navigation task is completed.", 65 | }, 66 | {"role": "user", "content": prompt}, 67 | ], 68 | ) 69 | return completion.strip().lower() == "yes" 70 | except Exception as e: 71 | self.logger.exception("Error checking task completion: %s", str(e)) 72 | return False 73 | -------------------------------------------------------------------------------- /src/analyzers/vision_analyzer.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from model_manager import ModelManager 4 | from utils import get_logger, load_prompt 5 | 6 | from .base_analyzer import BaseAnalyzer 7 | 8 | 9 | class VisionAnalyzer(BaseAnalyzer): 10 | def __init__(self, model_manager: ModelManager) -> None: 11 | self.model_manager = model_manager 12 | self.logger = get_logger() 13 | self.prompt_template = load_prompt("vision_analyzer") 14 | 15 | async def analyze(self, context: dict[str, Any]) -> str: 16 | # TODO: Implement vision analysis 17 | pass 18 | 19 | def generate_prompt(self, context: dict[str, Any]) -> dict[str, str]: 20 | # TODO: Implement prompt generation for vision analysis 21 | pass 22 | -------------------------------------------------------------------------------- /src/decision_maker.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from analyzers.base_analyzer import BaseAnalyzer 4 | from model_manager import ModelManager 5 | from utils import get_logger 6 | 7 | 8 | class DecisionMaker: 9 | def __init__(self, model_manager: ModelManager, analyzer: BaseAnalyzer, verbose: bool) -> None: 10 | self.logger = get_logger() 11 | self.model_manager = model_manager 12 | self.analyzer = analyzer 13 | self.verbose = verbose 14 | 15 | async def make_decision( 16 | self, 17 | mapped_elements: dict[int, dict[str, object]], 18 | task: str, 19 | current_url: str, 20 | plugin_data: dict[str, Any], 21 | ) -> str | None: 22 | context = { 23 | "mapped_elements": mapped_elements, 24 | "task": task, 25 | "current_url": current_url, 26 | "plugin_data": plugin_data, 27 | } 28 | decision = await self.analyzer.analyze(context) 29 | 30 | if self.verbose: 31 | self.logger.debug("AI Decision: %s", decision) 32 | return decision 33 | 34 | def parse_decision(self, decision: str) -> list[dict[str, object]]: 35 | if decision.upper() == "DONE": 36 | return [] 37 | 38 | actions = [] 39 | for action_str in decision.split(";"): 40 | action_str = action_str.strip() 41 | if ":" in action_str: 42 | element, text = action_str.split(":", 1) 43 | try: 44 | element = int(element.strip()) 45 | actions.append({"type": "input", "element": element, "text": text.strip()}) 46 | except ValueError: 47 | self.logger.error(f"Invalid element number in input action: {action_str}") 48 | else: 49 | if action_str.upper() == "ENTER": 50 | actions.append({"type": "submit"}) 51 | elif action_str.lower().startswith("click on"): 52 | try: 53 | element = int(action_str.split()[-1]) 54 | actions.append({"type": "click", "element": element}) 55 | except ValueError: 56 | self.logger.error(f"Invalid click instruction: {action_str}") 57 | else: 58 | try: 59 | element = int(action_str) 60 | actions.append({"type": "click", "element": element}) 61 | except ValueError: 62 | self.logger.error(f"Invalid action in decision: {action_str}") 63 | 64 | return actions 65 | 66 | async def is_task_completed(self, task: str, current_url: str) -> bool: 67 | return await self.analyzer.is_task_completed(task, current_url) 68 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | 4 | from analyzers.text_analyzer import TextAnalyzer 5 | from decision_maker import DecisionMaker 6 | from model_manager import ModelManager 7 | from navigator import Navigator 8 | from plugins.plugin_manager import PluginManager 9 | from utils import format_url, get_logger, setup_logging 10 | 11 | 12 | async def execute_task( 13 | task: str, 14 | navigator: Navigator, 15 | decision_maker: DecisionMaker, 16 | plugin_manager: PluginManager, 17 | ) -> None: 18 | logger = get_logger() 19 | url, parsed_task = await decision_maker.analyzer.model_manager.parse_initial_message(task) 20 | if not url or not parsed_task: 21 | logger.error("Failed to parse initial message") 22 | return 23 | 24 | url = format_url(url) 25 | logger.info("Navigating to: %s", url) 26 | logger.info("Task: %s", parsed_task) 27 | 28 | try: 29 | mapped_elements, current_url = await navigator.navigate_to(url, plugin_manager) 30 | except Exception as e: 31 | logger.error(f"Failed to navigate to the URL: {e}") 32 | return 33 | 34 | while True: 35 | await plugin_manager.handle_event("pre_decision", {"url": current_url, "elements": mapped_elements}) 36 | plugin_data = await plugin_manager.pre_decision({"url": current_url, "elements": mapped_elements}) 37 | 38 | decision = await decision_maker.make_decision(mapped_elements, parsed_task, current_url, plugin_data) 39 | 40 | if not decision: 41 | logger.error("Failed to get a decision from the AI") 42 | break 43 | 44 | actions = decision_maker.parse_decision(decision) 45 | if not actions: 46 | logger.info("Task completed or no further actions required") 47 | break 48 | 49 | await plugin_manager.handle_event( 50 | "post_decision", 51 | {"decision": decision, "url": current_url, "elements": mapped_elements}, 52 | ) 53 | await plugin_manager.post_decision(decision, {"url": current_url, "elements": mapped_elements}) 54 | 55 | all_actions_successful = True 56 | for action in actions: 57 | action_result = await navigator.perform_action(action, mapped_elements, plugin_manager) 58 | if not action_result: 59 | logger.error("Failed to perform action: %s", action) 60 | all_actions_successful = False 61 | break 62 | 63 | if not all_actions_successful: 64 | break 65 | 66 | result = await navigator.navigate_to(navigator.page.url, plugin_manager) 67 | if not result: 68 | logger.error("Failed to update page elements after actions") 69 | break 70 | 71 | mapped_elements, current_url = result 72 | 73 | if await decision_maker.is_task_completed(parsed_task, current_url): 74 | logger.info("Task completed successfully") 75 | break 76 | 77 | logger.info("Task execution completed") 78 | 79 | def parse_arguments() -> argparse.Namespace: 80 | parser = argparse.ArgumentParser(description="Autonomous Web AI") 81 | parser.add_argument("task", help="The task to perform") 82 | parser.add_argument( 83 | "--method", 84 | choices=["xpath", "ocr"], 85 | default="xpath", 86 | help="Method for element detection (default: xpath)", 87 | ) 88 | parser.add_argument("--show-visuals", action="store_true", help="Show visual markers on the page") 89 | parser.add_argument("-v", "--verbose", action="store_true", help="Increase output verbosity") 90 | parser.add_argument("-q", "--quiet", action="store_true", help="Reduce output verbosity") 91 | parser.add_argument( 92 | "--model", 93 | choices=["openai", "groq"], 94 | default="openai", 95 | help="Choose the model provider (default: openai)", 96 | ) 97 | return parser.parse_args() 98 | 99 | 100 | async def main() -> None: 101 | args = parse_arguments() 102 | setup_logging(args.verbose, args.quiet) 103 | logger = get_logger() 104 | 105 | try: 106 | model_manager = ModelManager.initialize(model_provider=args.model) 107 | 108 | # Choose the appropriate analyzer based on args or config 109 | analyzer = TextAnalyzer(model_manager) 110 | # analyzer = VisionAnalyzer(model_manager) 111 | 112 | navigator = Navigator( 113 | headless=False, # You might want to make this configurable 114 | detection_method=args.method, 115 | show_visuals=args.show_visuals, 116 | ) 117 | decision_maker = DecisionMaker(model_manager, analyzer, args.verbose) 118 | 119 | plugin_manager = PluginManager("src/plugins", "config/plugins.json") 120 | await plugin_manager.load_plugins() 121 | 122 | async with navigator: 123 | await execute_task(args.task, navigator, decision_maker, plugin_manager) 124 | 125 | except KeyboardInterrupt: 126 | logger.info("Received keyboard interrupt. Exiting.") 127 | except Exception: 128 | logger.exception("An unexpected error occurred") 129 | finally: 130 | if "plugin_manager" in locals(): 131 | await plugin_manager.cleanup_plugins() 132 | 133 | 134 | if __name__ == "__main__": 135 | asyncio.run(main()) 136 | -------------------------------------------------------------------------------- /src/model_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Mapping, Sequence 3 | 4 | import litellm 5 | from dotenv import load_dotenv 6 | 7 | from utils import get_logger 8 | 9 | 10 | # Disable debugging long messages 11 | litellm._logging._disable_debugging() 12 | 13 | 14 | class ModelManager: 15 | def __init__(self, api_key: str, model: str) -> None: 16 | self.logger = get_logger() 17 | self.api_key = api_key 18 | self.model = model 19 | litellm.api_key = self.api_key 20 | 21 | # Set up Langfuse 22 | self.setup_langfuse() 23 | 24 | @classmethod 25 | def initialize(cls, model_provider: str = "openai") -> "ModelManager": 26 | load_dotenv() 27 | api_key = cls.check_api_key(model_provider) 28 | model = "gpt-4o" if model_provider == "openai" else "groq/llama3-8b-8192" 29 | return cls(api_key, model) 30 | 31 | def setup_langfuse(self) -> None: 32 | langfuse_public_key = os.getenv("LANGFUSE_PUBLIC_KEY") 33 | langfuse_secret_key = os.getenv("LANGFUSE_SECRET_KEY") 34 | langfuse_host = os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com") 35 | 36 | if langfuse_public_key and langfuse_secret_key: 37 | os.environ["LANGFUSE_PUBLIC_KEY"] = langfuse_public_key 38 | os.environ["LANGFUSE_SECRET_KEY"] = langfuse_secret_key 39 | os.environ["LANGFUSE_HOST"] = langfuse_host 40 | 41 | litellm.success_callback = ["langfuse"] 42 | litellm.failure_callback = ["langfuse"] 43 | self.logger.info("Langfuse integration enabled") 44 | else: 45 | self.logger.warning( 46 | "Langfuse integration not enabled. Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY in .env to enable.", 47 | ) 48 | 49 | async def get_completion(self, messages: Sequence[dict], **kwargs: Mapping) -> str | None: 50 | try: 51 | response = await litellm.acompletion(model=self.model, messages=messages, **kwargs) 52 | return response.choices[0].message.content.strip() 53 | except Exception as e: 54 | self.logger.exception(f"Error getting completion from litellm: {e}") 55 | return None 56 | 57 | async def parse_initial_message(self, message: str) -> tuple[str | None, str | None]: 58 | try: 59 | response = await self.get_completion( 60 | [ 61 | { 62 | "role": "system", 63 | "content": ( 64 | "You are an AI assistant that extracts the website URL and task from a given message. " 65 | "Respond with only the URL and task, separated by a newline." 66 | ), 67 | }, 68 | {"role": "user", "content": message}, 69 | ], 70 | metadata={ 71 | "generation_name": "parse_initial_message", 72 | "trace_id": "initial_parse", 73 | "tags": ["initial_parsing"], 74 | }, 75 | ) 76 | url, task = response.strip().split("\n") 77 | return url.strip(), task.strip() 78 | except Exception as e: 79 | self.logger.exception(f"Error parsing initial message: {e}") 80 | return None, None 81 | 82 | @staticmethod 83 | def check_api_key(model_provider: str) -> str: 84 | logger = get_logger() 85 | env_var = "OPENAI_API_KEY" if model_provider == "openai" else "GROQ_API_KEY" 86 | api_key = os.getenv(env_var) 87 | if not api_key: 88 | logger.warning(f"{model_provider.capitalize()} API key not found. Please check your .env file.") 89 | new_key = input(f"Enter your {model_provider.capitalize()} API key: ") 90 | os.environ[env_var] = new_key 91 | with open(".env", "a") as f: 92 | f.write(f"\n{env_var}={new_key}") 93 | logger.info(f"{model_provider.capitalize()} API key has been saved to .env file.") 94 | return os.getenv(env_var) 95 | -------------------------------------------------------------------------------- /src/navigator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, NotRequired, TypedDict 3 | 4 | from playwright.async_api import Browser, BrowserContext, ElementHandle, Page, async_playwright 5 | 6 | from plugins.plugin_manager import PluginManager 7 | from utils import get_logger 8 | 9 | 10 | class NavigatorException(Exception): 11 | """Base exception for Navigator class.""" 12 | 13 | 14 | class ElementNotFoundException(NavigatorException): 15 | """Raised when an element is not found on the page.""" 16 | 17 | 18 | class ElementInfo(TypedDict): 19 | element: ElementHandle 20 | bbox: dict[str, float] 21 | type: str 22 | description: str 23 | 24 | 25 | class ActionDict(TypedDict): 26 | type: str 27 | element: NotRequired[int] 28 | text: NotRequired[str] 29 | 30 | 31 | class Navigator: 32 | def __init__( 33 | self, 34 | headless: bool = False, 35 | user_agent: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 36 | viewport: dict[str, int] = {"width": 1280, "height": 720}, 37 | max_retries: int = 3, 38 | page_load_timeout: int = 60000, 39 | detection_method: str = "xpath", 40 | show_visuals: bool = False, 41 | ) -> None: 42 | self.logger = get_logger() 43 | self.headless = headless 44 | self.user_agent = user_agent 45 | self.viewport = viewport 46 | self.max_retries = max_retries 47 | self.page_load_timeout = page_load_timeout 48 | self.detection_method = detection_method 49 | self.show_visuals = show_visuals 50 | self.playwright_instance = None 51 | self.browser: Browser | None = None 52 | self.context: BrowserContext | None = None 53 | self.page: Page | None = None 54 | 55 | async def __aenter__(self): 56 | if not self.page: 57 | await self.setup_browser() 58 | return self 59 | 60 | async def __aexit__(self, exc_type, exc_val, exc_tb): 61 | await self.cleanup() 62 | 63 | async def setup_browser(self) -> None: 64 | """Initialize the browser, context, and page.""" 65 | if not self.playwright_instance: 66 | self.playwright_instance = await async_playwright().start() 67 | self.browser = await self.playwright_instance.chromium.launch(headless=self.headless) 68 | self.context = await self.browser.new_context( 69 | user_agent=self.user_agent, 70 | viewport=self.viewport, 71 | ) 72 | self.page = await self.context.new_page() 73 | 74 | async def cleanup(self) -> None: 75 | """Clean up browser resources.""" 76 | if self.browser: 77 | self.logger.info("Closing browser.") 78 | await self.browser.close() 79 | if self.playwright_instance: 80 | await self.playwright_instance.stop() 81 | 82 | async def navigate_to(self, url: str, plugin_manager: PluginManager) -> tuple[dict[int, ElementInfo], str]: 83 | """Navigate to a URL and return mapped elements and current URL.""" 84 | if not self.page: 85 | await self.setup_browser() # Ensure the browser is set up 86 | if not self.page: 87 | msg = "Failed to initialize page" 88 | raise NavigatorException(msg) 89 | 90 | for attempt in range(1, self.max_retries + 1): 91 | try: 92 | self.logger.info("Attempt %s/%s: Navigating to %s", attempt, self.max_retries, url) 93 | response = await self.page.goto( 94 | url, 95 | wait_until="domcontentloaded", 96 | timeout=self.page_load_timeout, 97 | ) 98 | 99 | if response.status >= 400: 100 | self.logger.warning("Received HTTP status %s. Retrying...", response.status) 101 | continue 102 | 103 | self.logger.info("Page loaded successfully. Mapping elements...") 104 | elements = await self._detect_elements() 105 | mapped_elements = await self._map_elements(elements) 106 | 107 | await plugin_manager.handle_event("navigation", {"url": self.page.url, "elements": mapped_elements}) 108 | return mapped_elements, self.page.url 109 | 110 | except Exception as e: 111 | self.logger.exception("Navigation attempt %s/%s failed: %s", attempt, self.max_retries, str(e)) 112 | 113 | msg = f"Failed to navigate to {url} after {self.max_retries} attempts." 114 | raise NavigatorException(msg) 115 | 116 | async def _detect_elements(self) -> list[dict[str, Any]]: 117 | """Detect elements on the page using the configured method.""" 118 | if self.detection_method == "ocr": 119 | return await self._detect_elements_ocr() 120 | return await self._detect_elements_xpath() 121 | 122 | async def _detect_elements_xpath(self) -> list[dict[str, Any]]: 123 | """Detect elements using XPath.""" 124 | if not self.page: 125 | msg = "Page is not initialized" 126 | raise NavigatorException(msg) 127 | 128 | labels, inputs = await asyncio.gather( 129 | self.page.query_selector_all("label"), 130 | self.page.query_selector_all("input, select, textarea, button, a"), 131 | ) 132 | 133 | label_map = await self._create_label_map(labels) 134 | elements = await self.page.query_selector_all('a, button, [role="button"], input, textarea, select') 135 | 136 | return [await self._create_element_info(elem, label_map) for elem in elements if await elem.is_visible()] 137 | 138 | async def _create_label_map(self, labels: list[ElementHandle]) -> dict[str, str]: 139 | """Create a mapping of element IDs to their labels.""" 140 | label_map = {} 141 | for label in labels: 142 | for_attr, text = await asyncio.gather(label.get_attribute("for"), label.inner_text()) 143 | if for_attr: 144 | label_map[for_attr] = text 145 | else: 146 | label_id = await label.evaluate("el => el.id") 147 | if label_id: 148 | label_map[label_id] = text 149 | return label_map 150 | 151 | async def _create_element_info(self, elem: ElementHandle, label_map: dict[str, str]) -> dict[str, Any]: 152 | """Create a dictionary of element information.""" 153 | elem_id = await elem.get_attribute("id") 154 | return { 155 | "element": elem, 156 | "bbox": await elem.bounding_box(), 157 | "tag": await elem.evaluate("el => el.tagName.toLowerCase()"), 158 | "type": await elem.evaluate("el => el.type"), 159 | "placeholder": await elem.get_attribute("placeholder"), 160 | "aria_label": await elem.get_attribute("aria-label"), 161 | "inner_text": await elem.inner_text(), 162 | "id": elem_id, 163 | "description": label_map.get(elem_id) 164 | or await elem.inner_text() 165 | or await elem.get_attribute("aria-label") 166 | or await elem.get_attribute("placeholder") 167 | or "No description", 168 | "is_dropdown": await elem.evaluate('el => el.tagName.toLowerCase() === "select"'), 169 | } 170 | 171 | async def _map_elements(self, elements: list[dict[str, Any]]) -> dict[int, dict[str, Any]]: 172 | """Map detected elements to a numbered dictionary.""" 173 | mapped = {} 174 | for idx, element in enumerate(elements, start=1): 175 | if element["description"].strip() == "No description": 176 | continue 177 | 178 | mapped_type = self._determine_element_type(element) 179 | mapped[idx] = { 180 | "element": element["element"], 181 | "bbox": element["bbox"], 182 | "type": mapped_type, 183 | "description": element["description"].strip(), 184 | } 185 | 186 | if self.show_visuals: 187 | await self._add_visual_marker(idx, element["bbox"], mapped_type) 188 | 189 | return mapped 190 | 191 | @staticmethod 192 | def _determine_element_type(element: dict[str, Any]) -> str: 193 | """Determine the type of an element.""" 194 | if element["is_dropdown"]: 195 | return "dropdown" 196 | if element["tag"] in ["input", "textarea"] and element["type"] not in ["submit", "button", "reset"]: 197 | return "input" 198 | return "clickable" 199 | 200 | async def _add_visual_marker(self, number: int, bbox: dict[str, float], element_type: str) -> None: 201 | """Add a visual marker to an element on the page.""" 202 | if not self.page: 203 | return 204 | 205 | color = "red" if element_type == "input" else "yellow" 206 | await self.page.evaluate(f"""() => {{ 207 | const div = document.createElement('div'); 208 | div.textContent = '{number}'; 209 | div.style.position = 'absolute'; 210 | div.style.left = '{bbox['x']}px'; 211 | div.style.top = '{bbox['y']}px'; 212 | div.style.backgroundColor = '{color}'; 213 | div.style.color = 'black'; 214 | div.style.padding = '2px'; 215 | div.style.border = '1px solid black'; 216 | div.style.zIndex = '9999'; 217 | document.body.appendChild(div); 218 | }}""") 219 | 220 | async def perform_action( 221 | self, 222 | action: dict[str, Any], 223 | mapped_elements: dict[int, dict[str, Any]], 224 | plugin_manager: PluginManager, 225 | ) -> bool: 226 | """Perform an action on the page.""" 227 | try: 228 | plugin_action = await plugin_manager.pre_decision( 229 | {"action": action, "elements": mapped_elements, "url": self.page.url}, 230 | ) 231 | if plugin_action: 232 | action.update(plugin_action) 233 | 234 | if "type" not in action: 235 | msg = f"Invalid action: 'type' key is missing. Action: {action}" 236 | raise NavigatorException(msg) 237 | 238 | success = await self._execute_action(action, mapped_elements) 239 | 240 | await plugin_manager.post_decision({"action": action, "success": success}, {"elements": mapped_elements}) 241 | return success 242 | 243 | except Exception as e: 244 | self.logger.exception("Error performing action: %s", str(e)) 245 | await plugin_manager.handle_event("error", {"error": "Action execution failed", "action": action}) 246 | return False 247 | 248 | async def _execute_action(self, action: ActionDict, mapped_elements: dict[int, ElementInfo]) -> bool: 249 | """Execute a specific action.""" 250 | match action["type"]: 251 | case "submit": 252 | await self.page.keyboard.press("Enter") 253 | await asyncio.sleep(2) 254 | return True 255 | case "click": 256 | if "element" not in action or action["element"] not in mapped_elements: 257 | msg = f"Element {action.get('element')} not found on the page." 258 | raise ElementNotFoundException(msg) 259 | 260 | element_info = mapped_elements[action["element"]] 261 | self.logger.info( 262 | "Clicking element %s (%s)", 263 | action["element"], 264 | element_info["description"], 265 | ) 266 | await element_info["element"].click() 267 | return True 268 | case "input": 269 | if "element" not in action or action["element"] not in mapped_elements: 270 | msg = f"Element {action.get('element')} not found on the page." 271 | raise ElementNotFoundException(msg) 272 | 273 | element_info = mapped_elements[action["element"]] 274 | self.logger.info( 275 | "Inputting text into element %s (%s)", 276 | action["element"], 277 | element_info["description"], 278 | ) 279 | return await self._safe_fill(element_info["element"], action["text"]) 280 | case _: 281 | msg = f"Unknown action type: {action['type']}" 282 | raise NavigatorException(msg) 283 | 284 | async def _safe_fill(self, element: ElementHandle, text: str) -> bool: 285 | """Safely fill an input element with text.""" 286 | if await self._is_input_element(element): 287 | await element.fill(text) 288 | return True 289 | self.logger.error("Element is not an input: %s", await element.evaluate("el => el.outerHTML")) 290 | return False 291 | 292 | @staticmethod 293 | async def _is_input_element(element: ElementHandle) -> bool: 294 | """Check if an element is an input element.""" 295 | tag_name = await element.evaluate("el => el.tagName.toLowerCase()") 296 | is_contenteditable = await element.evaluate("el => el.getAttribute('contenteditable') === 'true'") 297 | return tag_name in ["input", "textarea"] or is_contenteditable 298 | 299 | async def _detect_elements_ocr(self) -> list[dict[str, Any]]: 300 | """Detect elements using OCR (placeholder).""" 301 | # Implement OCR logic here 302 | return [] 303 | -------------------------------------------------------------------------------- /src/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamDc73/WebTalk/f9dea8420629d5af7223e54b5de188da3ec00f79/src/plugins/__init__.py -------------------------------------------------------------------------------- /src/plugins/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamDc73/WebTalk/f9dea8420629d5af7223e54b5de188da3ec00f79/src/plugins/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /src/plugins/__pycache__/bitwarden.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamDc73/WebTalk/f9dea8420629d5af7223e54b5de188da3ec00f79/src/plugins/__pycache__/bitwarden.cpython-312.pyc -------------------------------------------------------------------------------- /src/plugins/__pycache__/plugin_interface.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamDc73/WebTalk/f9dea8420629d5af7223e54b5de188da3ec00f79/src/plugins/__pycache__/plugin_interface.cpython-312.pyc -------------------------------------------------------------------------------- /src/plugins/__pycache__/plugin_manager.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamDc73/WebTalk/f9dea8420629d5af7223e54b5de188da3ec00f79/src/plugins/__pycache__/plugin_manager.cpython-312.pyc -------------------------------------------------------------------------------- /src/plugins/bitwarden.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | import subprocess 5 | from typing import Any 6 | from urllib.parse import urlparse 7 | 8 | from plugins.plugin_interface import PluginInterface 9 | from utils import get_logger 10 | 11 | 12 | logger = get_logger() 13 | 14 | 15 | class BitwardenPlugin(PluginInterface): 16 | async def initialize(self) -> None: 17 | self.session_key = os.getenv("BW_SESSION") 18 | if not self.session_key: 19 | logger.error("BW_SESSION environment variable is not set") 20 | else: 21 | await self.check_login_status() 22 | 23 | async def cleanup(self) -> None: 24 | pass 25 | 26 | async def handle_event(self, event_type: str, event_data: dict[str, Any]) -> None: 27 | if event_type == "navigation": 28 | logger.debug("Bitwarden plugin: Navigated to %s", event_data["url"]) 29 | 30 | async def pre_decision(self, context: dict[str, Any]) -> dict[str, Any]: 31 | if self.detect_login_form(context["elements"]): 32 | credentials = await self.get_credentials(context.get("url", "")) 33 | if credentials: 34 | return {"credentials": credentials} 35 | return {} 36 | 37 | async def post_decision(self, decision: dict[str, Any], context: dict[str, Any]) -> None: 38 | # We don't need to do anything after a decision is made in this plugin 39 | pass 40 | 41 | async def check_login_status(self) -> None: 42 | try: 43 | result = await self.run_command(["bw", "status"]) 44 | status = json.loads(result) 45 | if status["status"] == "unlocked": 46 | logger.info("Successfully connected to Bitwarden") 47 | else: 48 | logger.warning("Unexpected Bitwarden status: %s", status["status"]) 49 | except Exception: 50 | logger.exception("Failed to check Bitwarden status") 51 | 52 | async def get_credentials(self, url: str) -> dict[str, str] | None: 53 | if not self.session_key: 54 | logger.warning("BW_SESSION not set. Cannot retrieve credentials.") 55 | return None 56 | 57 | if not url: 58 | logger.warning("No URL provided for credential lookup.") 59 | return None 60 | 61 | domain = self.extract_domain(url) 62 | try: 63 | result = await self.run_command(["bw", "list", "items", "--url", domain]) 64 | items = json.loads(result) 65 | if items: 66 | item = items[0] # Assume the first item is the one we want 67 | if "login" in item and "username" in item["login"] and "password" in item["login"]: 68 | return {"username": item["login"]["username"], "password": item["login"]["password"]} 69 | # If we reach this point, no credentials were found 70 | return None 71 | except Exception: 72 | logger.exception("Failed to get credentials for %s", domain) 73 | return None 74 | else: 75 | if "items" not in locals() or not items: 76 | logger.warning("No credentials found for %s", domain) 77 | 78 | @staticmethod 79 | def detect_login_form(elements: dict[int, dict[str, Any]]) -> bool: 80 | has_username = any( 81 | "username" in e["description"].lower() or "email" in e["description"].lower() for e in elements.values() 82 | ) 83 | has_password = any("password" in e["description"].lower() for e in elements.values()) 84 | return has_username and has_password 85 | 86 | @staticmethod 87 | def extract_domain(url: str) -> str: 88 | parsed_url = urlparse(url) 89 | domain = parsed_url.netloc 90 | return domain[4:] if domain.startswith("www.") else domain 91 | 92 | @staticmethod 93 | async def run_command(command: list[str]) -> str: 94 | env = os.environ.copy() 95 | env["BW_SESSION"] = os.getenv("BW_SESSION", "") 96 | process = await asyncio.create_subprocess_exec( 97 | *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, 98 | ) 99 | stdout, stderr = await process.communicate() 100 | if process.returncode != 0: 101 | raise subprocess.CalledProcessError(process.returncode, command, stdout, stderr) 102 | return stdout.decode().strip() 103 | -------------------------------------------------------------------------------- /src/plugins/plugin_interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class PluginInterface(ABC): 6 | @abstractmethod 7 | async def initialize(self) -> None: 8 | """Initialize the plugin.""" 9 | 10 | @abstractmethod 11 | async def cleanup(self) -> None: 12 | """Clean up plugin resources.""" 13 | 14 | @abstractmethod 15 | async def handle_event(self, event_type: str, event_data: dict[str, Any]) -> None: 16 | """Handle an event.""" 17 | 18 | @abstractmethod 19 | async def pre_decision(self, context: dict[str, Any]) -> dict[str, Any]: 20 | """Perform actions before a decision is made.""" 21 | return {} 22 | 23 | @abstractmethod 24 | async def post_decision(self, decision: dict[str, Any], context: dict[str, Any]) -> None: 25 | """Perform actions after a decision is made.""" 26 | -------------------------------------------------------------------------------- /src/plugins/plugin_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import importlib 3 | import json 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from utils import get_logger 8 | 9 | 10 | if TYPE_CHECKING: 11 | from plugins.plugin_interface import PluginInterface 12 | 13 | logger = get_logger() 14 | 15 | 16 | class PluginManager: 17 | def __init__(self, plugin_dir: str, config_file: str) -> None: 18 | self.plugin_dir = plugin_dir 19 | self.config_file = config_file 20 | self.plugins: dict[str, PluginInterface] = {} 21 | self.config: dict[str, dict[str, Any]] = {} 22 | 23 | async def load_plugins(self) -> None: 24 | """Load and initialize all plugins.""" 25 | self._load_config() 26 | for filename in Path(self.plugin_dir).iterdir(): 27 | if filename.suffix == ".py" and not filename.name.startswith("__"): 28 | plugin_name = filename.stem 29 | if plugin_name in self.config and self.config[plugin_name].get("enabled", True): 30 | await self._load_plugin(plugin_name) 31 | 32 | def _load_config(self) -> None: 33 | """Load the plugin configuration.""" 34 | try: 35 | with Path(self.config_file).open() as f: 36 | self.config = json.load(f) 37 | except FileNotFoundError: 38 | logger.warning("Config file %s not found. Using default configurations.", self.config_file) 39 | self.config = {} 40 | 41 | async def _load_plugin(self, plugin_name: str) -> None: 42 | """Load a single plugin.""" 43 | try: 44 | module = importlib.import_module(f"plugins.{plugin_name}") 45 | plugin_class: type[PluginInterface] = getattr(module, f"{plugin_name.capitalize()}Plugin") 46 | plugin = plugin_class() 47 | await plugin.initialize() 48 | self.plugins[plugin_name] = plugin 49 | logger.info("Loaded plugin: %s", plugin_name) 50 | except Exception: 51 | logger.exception("Failed to load plugin %s", plugin_name) 52 | 53 | async def cleanup_plugins(self) -> None: 54 | """Clean up all plugins.""" 55 | await asyncio.gather(*(plugin.cleanup() for plugin in self.plugins.values())) 56 | 57 | async def handle_event(self, event_type: str, event_data: dict[str, Any]) -> None: 58 | """Distribute an event to all plugins.""" 59 | await asyncio.gather(*(plugin.handle_event(event_type, event_data) for plugin in self.plugins.values())) 60 | 61 | async def pre_decision(self, context: dict[str, Any]) -> dict[str, Any]: 62 | """Run pre-decision hooks for all plugins.""" 63 | results = await asyncio.gather(*(plugin.pre_decision(context) for plugin in self.plugins.values())) 64 | return {k: v for d in results for k, v in d.items()} 65 | 66 | async def post_decision(self, decision: dict[str, Any], context: dict[str, Any]) -> None: 67 | """Run post-decision hooks for all plugins.""" 68 | await asyncio.gather(*(plugin.post_decision(decision, context) for plugin in self.plugins.values())) 69 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from datetime import datetime 5 | from pathlib import Path 6 | from typing import Literal 7 | from urllib.parse import urlparse 8 | 9 | import yaml 10 | 11 | 12 | # Suppress litellm debug messages 13 | logging.getLogger("litellm").setLevel(logging.WARNING) 14 | 15 | 16 | def format_url(url: str) -> str: 17 | """ 18 | Ensure the URL starts with 'https://' if no protocol is specified. 19 | 20 | Parameters 21 | ---------- 22 | url : str 23 | The input URL. 24 | 25 | Returns 26 | ------- 27 | str 28 | The formatted URL with 'https://' prepended if necessary. 29 | """ 30 | return f"https://{url}" if not url.startswith(("http://", "https://")) else url 31 | 32 | 33 | def setup_logging(verbose: bool, quiet: bool) -> logging.Logger: 34 | """ 35 | Set up logging configuration based on verbosity settings. 36 | 37 | Parameters 38 | ---------- 39 | verbose : bool 40 | If True, set console logging level to DEBUG. 41 | quiet : bool 42 | If True, set console logging level to ERROR. 43 | 44 | Returns 45 | ------- 46 | logging.Logger 47 | Configured logger instance. 48 | """ 49 | log_dir = "logs" 50 | os.makedirs(log_dir, exist_ok=True) 51 | 52 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 53 | log_file = os.path.join(log_dir, f"webai_{timestamp}.log") 54 | 55 | # Define log levels 56 | file_level = logging.DEBUG 57 | console_level: Literal["DEBUG", "INFO", "ERROR"] = "ERROR" if quiet else "DEBUG" if verbose else "INFO" 58 | 59 | # Create a custom logger 60 | logger = logging.getLogger("webai") 61 | logger.setLevel(logging.DEBUG) 62 | 63 | # Create handlers 64 | file_handler = logging.FileHandler(log_file) 65 | file_handler.setLevel(file_level) 66 | console_handler = logging.StreamHandler() 67 | console_handler.setLevel(getattr(logging, console_level)) 68 | 69 | # Create formatters and add it to handlers 70 | file_format = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 71 | console_format = logging.Formatter("%(levelname)s: %(message)s") 72 | file_handler.setFormatter(file_format) 73 | console_handler.setFormatter(console_format) 74 | 75 | # Add handlers to the logger 76 | logger.addHandler(file_handler) 77 | logger.addHandler(console_handler) 78 | 79 | return logger 80 | 81 | 82 | def get_logger() -> logging.Logger: 83 | """ 84 | Get the configured logger instance. 85 | 86 | Returns 87 | ------- 88 | logging.Logger 89 | The configured logger instance. 90 | """ 91 | return logging.getLogger("webai") 92 | 93 | 94 | def extract_domain(url: str) -> str: 95 | """ 96 | Extract the domain from a given URL. 97 | 98 | Parameters 99 | ---------- 100 | url : str 101 | The URL to extract the domain from. 102 | 103 | Returns 104 | ------- 105 | str 106 | The extracted domain. 107 | """ 108 | parsed_url = urlparse(url) 109 | domain = parsed_url.netloc 110 | if domain.startswith("www."): 111 | domain = domain[4:] 112 | return domain 113 | 114 | def load_prompt(analyzer_name: str) -> dict: 115 | prompt_path = Path(__file__).parent.parent / "prompts" / f"{analyzer_name}.yaml" 116 | with open(prompt_path, "r") as f: 117 | return yaml.safe_load(f) 118 | 119 | 120 | def format_prompt(prompt: dict, **kwargs) -> dict: 121 | return {"system_message": prompt["system_message"], "user_message": prompt["user_message"].format(**kwargs)} 122 | 123 | 124 | def extract_key_value_pairs(task: str) -> dict[str, str]: 125 | pattern = r"(\w+(?:\s+\w+)*)\s+(?:is|it's|are)\s+['\"]?([\w@.]+)['\"]?" 126 | return dict(re.findall(pattern, task, re.IGNORECASE)) 127 | --------------------------------------------------------------------------------