From 34b43a66eadad217324daf527345311f3330e606 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Tue, 24 Sep 2024 17:57:53 +0800 Subject: [PATCH 01/30] add instantiation process --- instantiation/.gitignore | 11 + instantiation/README.md | 76 ++++ instantiation/action_prefill.py | 331 ++++++++++++++++++ instantiation/ael/agent/agent.py | 116 ++++++ .../automator/ui_control/control_filter.py | 28 ++ .../ael/automator/word/app_control.py | 73 ++++ instantiation/ael/config/config.py | 27 ++ instantiation/ael/env/state_manager.py | 52 +++ .../ael/module/action_prefill_flow.py | 218 ++++++++++++ instantiation/ael/module/filter_flow.py | 48 +++ instantiation/ael/prompter/agent_prompter.py | 264 ++++++++++++++ ufo/agents/agent/basic.py | 6 +- ufo/agents/processors/app_agent_processor.py | 6 +- ufo/agents/processors/basic.py | 3 +- ufo/agents/processors/host_agent_processor.py | 3 +- ufo/automator/ui_control/controller.py | 2 +- ufo/automator/ui_control/openfile.py | 3 +- ufo/automator/ui_control/screenshot.py | 3 +- ufo/config/config.py | 20 +- ufo/llm/llm_call.py | 11 +- ufo/llm/openai.py | 2 +- ufo/module/basic.py | 4 +- 22 files changed, 1282 insertions(+), 25 deletions(-) create mode 100644 instantiation/.gitignore create mode 100644 instantiation/README.md create mode 100644 instantiation/action_prefill.py create mode 100644 instantiation/ael/agent/agent.py create mode 100644 instantiation/ael/automator/ui_control/control_filter.py create mode 100644 instantiation/ael/automator/word/app_control.py create mode 100644 instantiation/ael/config/config.py create mode 100644 instantiation/ael/env/state_manager.py create mode 100644 instantiation/ael/module/action_prefill_flow.py create mode 100644 instantiation/ael/module/filter_flow.py create mode 100644 instantiation/ael/prompter/agent_prompter.py diff --git a/instantiation/.gitignore b/instantiation/.gitignore new file mode 100644 index 00000000..db001397 --- /dev/null +++ b/instantiation/.gitignore @@ -0,0 +1,11 @@ +# Ignore files +cache/ +controls_cache/ +tasks/* +templates/word/* +prefill_logs/* +requirements.txt +ael/utils/ +ael/config/* +!ael/config/config.py +ael/prompts/* \ No newline at end of file diff --git a/instantiation/README.md b/instantiation/README.md new file mode 100644 index 00000000..7c0ea7eb --- /dev/null +++ b/instantiation/README.md @@ -0,0 +1,76 @@ + diff --git a/instantiation/action_prefill.py b/instantiation/action_prefill.py new file mode 100644 index 00000000..72a056dc --- /dev/null +++ b/instantiation/action_prefill.py @@ -0,0 +1,331 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +from datetime import datetime +import json,glob +import argparse +import sys +from abc import ABC, abstractmethod +from enum import Enum + + +class AppEnum(Enum): + """ + Define the apps can be used in the instantiation. + """ + + WORD = 1, 'Word', '.docx' + EXCEL = 2, 'Excel', '.xlsx' + POWERPOINT = 3, 'PowerPoint', '.pptx' + + def __init__(self, id, description, file_extension): + self.id = id + self.description = description + self.file_extension = file_extension + self.root_name = description + '.Application' + +class TaskObject(ABC): + """ + Abstract class for the task object. + """ + + @abstractmethod + def __init__(self): + pass + + def set_attributes(self, **kwargs) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + + +class TaskJsonObject(TaskObject): + def __init__(self, json_path): + """ + Initialize the task object from the json file. + :param json_path: The json file path. + :return: The created json object. + """ + task_json_file = json.load(open(json_path, "r")) + self.app_object = self.get_app_from_json(task_json_file) + for key, value in task_json_file.items(): + setattr(self, key.lower().replace(" ", "_"), value) + + def get_app_from_json(self, task_json_file): + for app in AppEnum: + app_name = app.description.lower() + json_app_name = task_json_file["app"].lower() + if app_name == json_app_name: + return app + raise ValueError('Not a correct App') + + def to_json(self): + fields = ['unique_id', 'instantiated_request', 'instantiated_plan', 'instantial_template_path', 'request_comment'] + data = {} + for key, value in self.__dict__.items(): + if key in fields: + if hasattr(self, key): + data[key] = value + return data + +class TaskPathObject(TaskObject): + def __init__(self, task_file): + """ + Initialize the task object from the task file path. + :param task_file: The task file path. + :return: The created path object. + """ + + self.task_file = task_file + self.task_file_dir_name = os.path.dirname(os.path.dirname(task_file)) + self.task_file_base_name = os.path.basename(task_file) + self.task=self.task_file_base_name.split('.')[0] + +class TaskConfigObject(TaskObject): + def __init__(self, configs): + """ + Initialize the task object from the config dictionary. + :param configs: The config dictionary. + :return: The created config object. + """ + + for key, value in configs.items(): + setattr(self, key.lower().replace(" ", "_"), value) + +class ObjectMethodService(): + """ + The object method service. + Provide methods related to the object, which is extended from TaskObject. + """ + def __init__(self, task_dir_name : str, task_config_object : TaskObject, task_json_object : TaskObject, task_path_object : TaskObject) -> None: + """ + :param task_dir_name: Folder name of the task, specific for one process. + :param task_config_object: Config object, which is singleton for one process. + :param task_json_object: Json object, which is the json file of the task. + :param task_path_object: Path object, which is related to the path of the task. + """ + + self.task_dir_name : str = task_dir_name + self.task_config_object : TaskObject = task_config_object + self.task_json_object : TaskObject = task_json_object + self.task_path_object : TaskObject = task_path_object + + @classmethod + def format_action_plans(self, action_plans : str) -> list[str]: + if isinstance(action_plans, str): + return action_plans.split("\n") + elif isinstance(action_plans, list): + return action_plans + else: + return [] + + @classmethod + def filter_task(self, app_name : str, request_to_filt : str): + from ael.module.filter_flow import FilterFlow + try: + filter_flow = FilterFlow(app_name) + except Exception as e: + print(f"Error! ObjectMethodService#filter_task: {e}") + else: + try: + is_good, comment, type = filter_flow.get_filter_res( + request_to_filt + ) + return is_good, comment, type + except Exception as e: + print(f"Error! ObjectMethodService#filter_task: {e}") + + def create_cache_file(self, ori_path: str, doc_path: str, file_name: str = None) -> str: + """ + According to the original path, create a cache file. + :param ori_path: The original path of the file. + :param doc_path: The path of the cache file. + :return: The template path string as a new created file. + """ + + if not os.path.exists(doc_path): + os.makedirs(doc_path) + time_start = datetime.now() + current_path = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) + template_extension = self.task_json_object.app_object.file_extension + if not file_name: + seed = os.path.join(current_path, doc_path, + time_start.strftime('%Y-%m-%d-%H-%M-%S') + template_extension) + else: + seed = os.path.join(current_path, doc_path, file_name + template_extension) + with open(ori_path, 'rb') as f: + ori_content = f.read() + with open(seed, 'wb') as f: + f.write(ori_content) + return seed + + def get_choose_file(self, action_prefill_flow) -> str: + """ + Choose the most relative template file. + :param action_prefill_flow: The action prefill flow object. + :return: The most relative template file path string. + """ + templates_description_path = self.get_description_path() + templates_file_description = json.load(open(templates_description_path, "r")) + choose_file = action_prefill_flow.get_target_file(self.task_json_object.task, templates_file_description) + return choose_file + + def get_description_path(self) -> str: + return os.path.join(self.task_config_object.template_path, self.task_json_object.app_object.description.lower(), "description.json") + + def get_ori_path(self, action_prefill_flow) -> str: + choose_file = self.get_choose_file(action_prefill_flow) + return os.path.join(self.task_config_object.template_path, self.task_json_object.app_object.description.lower(), choose_file) + + def get_doc_path(self) -> str: + return os.path.join(self.task_config_object.tasks_hub, self.task_dir_name + '_files') + + def get_and_save_seed_path(self, action_prefill_flow) -> str: + seed = self.create_cache_file(self.get_ori_path(action_prefill_flow), self.get_doc_path(), self.task_path_object.task) + self.task_json_object.instantial_template_path = seed + return seed + + def get_instance_folder_path(self) -> tuple[str, str]: + """ + Get the new folder path for the good and bad instances without creating them. + :return: The folder path string where good / bad instances should be in. + """ + + new_folder_path = os.path.join(self.task_config_object.tasks_hub, self.task_dir_name + "_new") + new_folder_good_path = os.path.join(new_folder_path, "good_instances") + new_folder_bad_path = os.path.join(new_folder_path, "bad_instances") + return new_folder_good_path, new_folder_bad_path + + +class ProcessProducer(): + """ + Key process to instantialize the task. + Provide workflow to initialize and evaluate the task. + """ + + def __init__(self, task_dir_name : str, + task_config_object : TaskObject, + task_json_object : TaskObject, + task_path_object : TaskObject): + """ + :param task_dir_name: Folder name of the task, specific for one process. + :param task_config_object: Config object, which is singleton for one process. + :param task_json_object: Json object, which is the json file of the task. + :param task_path_object: Path object, which is related to the path of the task. + """ + + from instantiation.ael.module.action_prefill_flow import ActionPrefillFlow + from instantiation.ael.env.state_manager import WindowsAppEnv + + self.task_object = ObjectMethodService(task_dir_name, task_config_object, task_json_object, task_path_object) + self.app_env = WindowsAppEnv(task_json_object.app_object.root_name, task_json_object.app_object.description) + + self.action_prefill_flow = ActionPrefillFlow(task_json_object.app_object.description.lower(), self.app_env) + self.action_prefill_flow.init_flow(task_path_object.task) + + def get_instantiated_result(self) -> tuple[str, str]: + """ + Get the instantiated result of the task. + :return: The instantiated request and plan string. + """ + + seed = self.task_object.get_and_save_seed_path(self.action_prefill_flow) + try: + self.app_env.start(seed) + instantiated_request, instantiated_plan = self.action_prefill_flow.get_prefill_actions( + self.task_object.task_json_object.task, self.task_object.task_json_object.refined_steps, seed) + except Exception as e: + print(f"Error! ProcessProducer#get_instantiated_result: {e}") + finally: + self.app_env.close() + return instantiated_request, instantiated_plan + + + def get_task_filtered(self, task_to_filter : str) -> tuple[bool, str, str]: + """ + Evaluate the task by the filter. + :param task_to_filter: The task to be evaluated. + :return: The evaluation quality \ comment \ type of the task. + """ + + request_quality_is_good, request_comment, request_type = \ + ObjectMethodService.filter_task(self.task_object.task_json_object.app_object.description.lower(), task_to_filter) + + return request_quality_is_good, request_comment, request_type + + def get_task_instantiated_and_filted(self) -> None: + """ + Get the instantiated result and evaluate the task. + Save the task to the good / bad folder. + """ + + try: + instantiated_request, instantiated_plan = self.get_instantiated_result() + instantiated_plan = ObjectMethodService.format_action_plans(instantiated_plan) + self.task_object.task_json_object.set_attributes(instantiated_request = instantiated_request, instantiated_plan=instantiated_plan) + + request_quality_is_good, request_comment, request_type = self.get_task_filtered(instantiated_request) + self.task_object.task_json_object.set_attributes(request_quality_is_good=request_quality_is_good, request_comment=request_comment, request_type=request_type) + + self.action_prefill_flow.execute_logger.info(f"Task {self.task_object.task_path_object.task_file_base_name} has been processed successfully.") + except Exception as e: + print(f"Error! ProcessProducer#get_task_instantiated_and_filted: {e}") + self.action_prefill_flow.execute_logger.info(f"Error:{e}") + + def save_instantiated_task(self) -> None: + """ + Save the instantiated task to the good / bad folder. + """ + + new_folder_good_path, new_folder_bad_path = self.task_object.get_instance_folder_path() + task_json = self.task_object.task_json_object.to_json() + + if self.task_object.task_json_object.request_quality_is_good: + new_task_path = os.path.join(new_folder_good_path, self.task_object.task_path_object.task_file_base_name) + else: + new_task_path = os.path.join(new_folder_bad_path, self.task_object.task_path_object.task_file_base_name) + os.makedirs(os.path.dirname(new_task_path), exist_ok=True) + open(new_task_path,"w").write(json.dumps(task_json)) + +def main(): + """ + The main function to process the tasks. + """ + from instantiation.ael.config.config import Config + + config_path = os.path.normpath(os.path.join(current_dir, 'ael/config/')) + '\\' + configs : dict[str, str] = Config(config_path).get_instance().config_data + task_config_object : TaskObject = TaskConfigObject(configs) + + task_dir_name = parsed_args.task.lower() + all_task_file_path : str = os.path.join(task_config_object.tasks_hub, task_dir_name, "*") + all_task_files = glob.glob(all_task_file_path) + + try: + for index, task_file in enumerate(all_task_files, start = 1): + task_json_object = TaskJsonObject(task_file) + task_path_object = TaskPathObject(task_file) + + process = ProcessProducer(task_dir_name, task_config_object, task_json_object, task_path_object) + process.get_task_instantiated_and_filted() + process.save_instantiated_task() + + except Exception as e: + print(f"Error! main: {e}") + + +if __name__ == "__main__": + + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(current_dir, '..')) + + if project_root not in sys.path: + sys.path.append(project_root) + + os.environ["RUN_CONFIGS"] = "false" + + args = argparse.ArgumentParser() + args.add_argument("--task", help="The name of the task.", type=str, default="action_prefill") + parsed_args = args.parse_args() + + main() \ No newline at end of file diff --git a/instantiation/ael/agent/agent.py b/instantiation/ael/agent/agent.py new file mode 100644 index 00000000..7e521d59 --- /dev/null +++ b/instantiation/ael/agent/agent.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from ..prompter.agent_prompter import ActionPrefillPrompter, FilterPrompter +from ufo.agents.agent.basic import BasicAgent + +class FilterAgent(BasicAgent): + """ + The Agent to evaluate whether the task has been completed and whether the actions sequence has taken effects. + """ + def __init__(self, name: str, process_name: str, is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str): + """ + Initialize the FollowAgent. + :agent_type: The type of the agent. + :is_visual: The flag indicating whether the agent is visual or not. + """ + self._step = 0 + self._complete = False + self._name = name + self._status = None + self.prompter : FilterPrompter = self.get_prompter( + is_visual, main_prompt, example_prompt, api_prompt) + self._process_name = process_name + + def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: + """ + Get the prompt for the agent. + :return: The prompt. + """ + return FilterPrompter(is_visual, main_prompt, example_prompt, api_prompt) + + def message_constructor(self, dynamic_examples: str, + request: str, app:str) -> list: + """ + Construct the prompt message for the AppAgent. + :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. + :param dynamic_tips: The dynamic tips retrieved from the self-demonstration and human demonstration. + :param image_list: The list of screenshot images. + :param request_history: The request history. + :param action_history: The action history. + :param plan: The plan. + :param request: The request. + :return: The prompt message. + """ + filter_agent_prompt_system_message = self.prompter.system_prompt_construction( + dynamic_examples, app = app) + filter_agent_prompt_user_message = self.prompter.user_content_construction( + request) + filter_agent_prompt_message = self.prompter.prompt_construction( + filter_agent_prompt_system_message, filter_agent_prompt_user_message) + + return filter_agent_prompt_message + + def process_comfirmation(self) -> None: + """ + Confirm the process. + """ + pass + + +class ActionPrefillAgent(BasicAgent): + """ + The Agent for task instantialization and action sequence generation. + """ + + def __init__(self, name: str, process_name: str, is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str): + """ + Initialize the FollowAgent. + :agent_type: The type of the agent. + :is_visual: The flag indicating whether the agent is visual or not. + """ + self._step = 0 + self._complete = False + self._name = name + self._status = None + self.prompter:ActionPrefillPrompter = self.get_prompter( + is_visual, main_prompt, example_prompt, api_prompt) + self._process_name = process_name + + def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: + """ + Get the prompt for the agent. + :return: The prompt. + """ + return ActionPrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) + + def message_constructor(self, dynamic_examples: str, + given_task: str, + reference_steps:list, + doc_control_state: dict, + log_path : str) -> list: + """ + Construct the prompt message for the AppAgent. + :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. + :param dynamic_tips: The dynamic tips retrieved from the self-demonstration and human demonstration. + :param image_list: The list of screenshot images. + :param request_history: The request history. + :param action_history: The action history. + :param plan: The plan. + :param request: The request. + :return: The prompt message. + """ + action_prefill_agent_prompt_system_message = self.prompter.system_prompt_construction( + dynamic_examples) + action_prefill_agent_prompt_user_message = self.prompter.user_content_construction( + given_task,reference_steps, doc_control_state, log_path) + appagent_prompt_message = self.prompter.prompt_construction( + action_prefill_agent_prompt_system_message, action_prefill_agent_prompt_user_message) + + return appagent_prompt_message + + def process_comfirmation(self) -> None: + """ + Confirm the process. + """ + pass \ No newline at end of file diff --git a/instantiation/ael/automator/ui_control/control_filter.py b/instantiation/ael/automator/ui_control/control_filter.py new file mode 100644 index 00000000..ce27f9ef --- /dev/null +++ b/instantiation/ael/automator/ui_control/control_filter.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import re +import warnings +from ufo.automator.ui_control.control_filter import ControlFilterFactory +from ael.config.config import Config + +warnings.filterwarnings("ignore") +configs = Config.get_instance().config_data + +class ControlFilterFactory(ControlFilterFactory): + @staticmethod + def items_to_keywords(items:list) -> list: + """ + Gets keywords from the plan and request. + We only consider the words in the plan and request that are alphabetic or Chinese characters. + :param plan (str): The plan to be parsed. + :param request (str): The request to be parsed. + Returns: + list: A list of keywords extracted from the plan. + """ + keywords = [] + for item in items: + words = item.replace("\n", "").replace("'", "").replace("*","").strip(".").split() + words = [word for word in words if word.isalpha() or bool(re.fullmatch(r'[\u4e00-\u9fa5]+', word))] + keywords.extend(word for word in words if word not in keywords) + return keywords \ No newline at end of file diff --git a/instantiation/ael/automator/word/app_control.py b/instantiation/ael/automator/word/app_control.py new file mode 100644 index 00000000..890626dc --- /dev/null +++ b/instantiation/ael/automator/word/app_control.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import win32com.client as win32 +import time + +from ael.config.config import Config +configs = Config.get_instance().config_data + +BACKEND = configs["CONTROL_BACKEND"] + +class AppControl: + def __init__(self,app_root_name) -> None: + self.file_instance = None + self.app_root_name = app_root_name + # Start the specified application + self.app_instance = win32.gencache.EnsureDispatch(self.app_root_name) + self.app_window = None + self.file_path = None + + def quit(self): + """ + Quit the application. + """ + # close all dialog + try: + # close_word_dialogs() + if self.file_instance: + self.file_instance.Close() + self.file_instance = None + self.file_path = None + except Exception as e: + print("Error while closing dialogs:", e) + finally: + self.app_instance.Quit() + self.app_instance = None + time.sleep(configs["SLEEP_TIME"]) + + + def open_file_with_app(self,file_path): + """ + This function attempts to open a file using a specified application. + + :param file_path: The full path to the file you want to open. + :param app_name: The ProgID of the application you want to use to open the file. + """ + self.app_instance = win32.gencache.EnsureDispatch(self.app_root_name) + time.sleep(configs["SLEEP_TIME"]) + self.file_path = file_path + + try: + + # Make the application visible + self.app_instance.Visible = True + + # Close all previously opened documents if supported + if hasattr(self.app_instance, 'Documents'): + for doc in self.app_instance.Documents: + doc.Close(False) # Argument False indicates not to save changes + + # Different applications have different methods for opening files + if self.app_root_name == 'Word.Application': + self.file_instance = self.app_instance.Documents.Open(file_path) + self.app_instance.WindowState = win32.constants.wdWindowStateMaximize + elif self.app_root_name == 'Excel.Application': + self.file_instance = self.app_instance.Workbooks.Open(file_path) + # Add more cases here for different applications + else: + print(f"Application '{self.app_root_name}' is not supported by this function.") + self.quit(save=False) + + except Exception as e: + print(f"An error occurred: {e}") \ No newline at end of file diff --git a/instantiation/ael/config/config.py b/instantiation/ael/config/config.py new file mode 100644 index 00000000..b977de0b --- /dev/null +++ b/instantiation/ael/config/config.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from ufo.config.config import Config + +class Config(Config): + _instance = None + + def __init__(self, config_path = "instantiation/ael/config/"): + self.config_data = self.load_config(config_path) + + @staticmethod + def get_instance(): + """ + Get the instance of the Config class. + :return: The instance of the Config class. + """ + if Config._instance is None: + Config._instance = Config() + + return Config._instance + + def optimize_configs(self, configs): + self.update_api_base(configs, "ACTION_PREFILL_AGENT") + self.update_api_base(configs, "FILTER_AGENT") + + return configs \ No newline at end of file diff --git a/instantiation/ael/env/state_manager.py b/instantiation/ael/env/state_manager.py new file mode 100644 index 00000000..fc66d0eb --- /dev/null +++ b/instantiation/ael/env/state_manager.py @@ -0,0 +1,52 @@ +import gymnasium as gym + +from ael.config.config import Config +from ael.automator.word import app_control as control + +configs = Config.get_instance().config_data + +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] + + +class WindowsAppEnv(gym.Env): + """ + The Windows App Environment. + """ + + def __init__(self, app_root_name, process_name: str): + """ + Initialize the Windows App Environment. + :param app_root_name: The root name of the app. + :param process_name: The process name of the app. + """ + super(WindowsAppEnv, self).__init__() + # app window + self.app_window = None + # app root name like: 'Word.Application' + self._app_root_name = app_root_name + # process name like : 'Word' + self._process_name = process_name + # app control instance + self.app_control = control.AppControl(self._app_root_name) + + def start(self, seed): + """ + Start the Window env. + :param seed: The seed file to start the env. + """ + # close all the previous windows + self.app_control.open_file_with_app(seed) + + from ufo.automator.ui_control.inspector import ControlInspectorFacade + + desktop_windows = ControlInspectorFacade(BACKEND).get_desktop_windows() + self.app_window = desktop_windows[0] + self.app_window.set_focus() + + + def close(self): + """ + Close the Window env. + """ + self.app_control.quit() \ No newline at end of file diff --git a/instantiation/ael/module/action_prefill_flow.py b/instantiation/ael/module/action_prefill_flow.py new file mode 100644 index 00000000..92a8b392 --- /dev/null +++ b/instantiation/ael/module/action_prefill_flow.py @@ -0,0 +1,218 @@ +import json +import os +from langchain.storage import LocalFileStore +from langchain_community.vectorstores import FAISS +from langchain_community.embeddings import HuggingFaceEmbeddings +from langchain.embeddings import CacheBackedEmbeddings + +from ael.automator.ui_control.control_filter import ControlFilterFactory +from ael.env.state_manager import WindowsAppEnv +from ael.agent.agent import ActionPrefillAgent +from ael.config.config import Config + +from ufo.automator.ui_control.inspector import ControlInspectorFacade +from ufo.automator.ui_control.screenshot import PhotographerFacade +from ufo.agents.processors.app_agent_processor import AppAgentProcessor +from ufo.module.basic import BaseSession + + +configs = Config.get_instance().config_data +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] + +def load_embedding_model(model_name: str): + store = LocalFileStore(configs["CONTROL_EMBEDDING_CACHE_PATH"]) + if not model_name.startswith("sentence-transformers"): + model_name = "sentence-transformers/" + model_name + embedding_model = HuggingFaceEmbeddings(model_name=model_name) + cached_embedder = CacheBackedEmbeddings.from_bytes_store( + embedding_model, store, namespace=model_name + ) + return cached_embedder + + +class ActionPrefillFlow(AppAgentProcessor): + """ + The class to refine the plan steps and prefill the file. + """ + def __init__(self, app_name: str, environment: WindowsAppEnv = None, embedding_model: str = configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"]): + """ + Initialize the follow flow. + :param app_name: The name of the operated app. + :param app_root_name: The root name of the app. + :param environment: The environment of the app. + """ + self.app_env = environment + self.action_prefill_agent = ActionPrefillAgent('action_prefill', + app_name, is_visual=True, + main_prompt=configs["ACTION_PREFILL_PROMPT"], + example_prompt=configs["ACTION_PREFILL_EXAMPLE_PROMPT"], + api_prompt=configs["API_PROMPT"]) + # self._image_url = [] + # Control filter + self.file_path = "" + self.embedding_model = load_embedding_model(embedding_model) + + + def init_flow(self, task): + """ + Init the flow. + :param task: The label of the task. + """ + self.execute_step = 0 + self.canvas_state = None + self.control_inspector = ControlInspectorFacade(BACKEND) + self.photographer = PhotographerFacade() + + self.control_state = None + self.custom_doc = None + self.status = "" + self.file_path = "" + self.control_annotation = None + + self.log_path_configs = configs["PREFILL_LOG_PATH"].format(task=task) + os.makedirs(self.log_path_configs, exist_ok=True) + self.prefill_logger = BaseSession.initialize_logger(self.log_path_configs, f"prefill_agent.json",'w', configs) + self.execute_logger = BaseSession.initialize_logger(self.log_path_configs, f"execute.jsonl",'a', configs) + + + def update_state(self, file_path: str): + """ + Get current states of app with pywinauto、win32com + :param file_path: The file path of the app. + """ + print(f"updating the state of app file: {file_path}") + + control_list = self.control_inspector.find_control_elements_in_descendants( + self.app_env.app_window, + control_type_list=configs["CONTROL_LIST"], + class_name_list=configs["CONTROL_LIST"], + ) + self._annotation_dict = self.photographer.get_annotation_dict( + self.app_env.app_window, control_list, annotation_type="number" + ) + + # Attempt to filter out irrelevant control items based on the previous plan. + self.filtered_annotation_dict = self.get_filtered_annotation_dict( + self._annotation_dict, configs = configs + ) + + self._control_info = self.control_inspector.get_control_info_list_of_dict( + self._annotation_dict, + ["control_text", "control_type" if BACKEND == "uia" else "control_class"], + ) + self.filtered_control_info = ( + self.control_inspector.get_control_info_list_of_dict( + self.filtered_annotation_dict, + [ + "control_text", + "control_type" if BACKEND == "uia" else "control_class", + ], + ) + ) + + + def log_execute_info(self, messages: list[dict], agent_response: dict, error: str = ""): + """ + Record the execution information. + :param messages: The messages of the conversation. + :param agent_response: The response of the agent. + :param error: The error message. + """ + + messages = messages + history_json = { + "step": self.execute_step, + "messages": messages, + "agent_response": agent_response, + "error": error + } + # add not replace + self.prefill_logger.info(json.dumps(history_json)) + + + @ staticmethod + def filter_control_info(control_ui_tree, task): + """ + Get the filtered control information with the given task. + + Return: + The filtered control information. + """ + control_filter_type = configs["CONTROL_FILTER_TYPE"] + + if len(control_filter_type) == 0: + return control_ui_tree + + control_filter_type_lower = [control_filter_type_lower.lower( + ) for control_filter_type_lower in control_filter_type] + + filtered_control_info = [] + + keywords = ControlFilterFactory.items_to_keywords([task]) + if len(keywords) == 0: + return control_ui_tree + if 'text' in control_filter_type_lower: + model_text = ControlFilterFactory.create_control_filter( + 'text') + # filter the control information with bfs search + filtered_control_info = model_text.bfs_filter( + filtered_control_info, control_ui_tree, keywords) + # temp only support use one method + elif 'semantic' in control_filter_type_lower: + model_semantic = ControlFilterFactory.create_control_filter( + 'semantic', configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"]) + filtered_control_info = model_semantic.bfs_filter( + filtered_control_info, control_ui_tree, keywords) + return filtered_control_info + + def get_target_file(self, given_task: str, doc_files_description: dict): + """ + Get the target file based on the semantic similarity of given task and the template file decription. + """ + candidates = [doc_file_description for doc, + doc_file_description in doc_files_description.items()] + file_doc_descriptions = {doc_file_description: doc for doc, + doc_file_description in doc_files_description.items()} + # use FAISS to get the top k control items texts + db = FAISS.from_texts(candidates, self.embedding_model) + doc_descriptions = db.similarity_search(given_task, k=1) + doc_description = doc_descriptions[0].page_content + doc = file_doc_descriptions[doc_description] + return doc + + def get_prefill_actions(self, given_task, reference_steps, file_path): + """ + Call the PlanRefine Agent to select files + :return: The file to open + """ + error_msg = "" + # update the canvas state and control states + self.update_state(file_path) + # filter the controls + filter_control_state = self.filtered_control_info + # filter the apis + prompt_message = self.action_prefill_agent.message_constructor( + "", + given_task,reference_steps, filter_control_state, + self.log_path_configs) + try: + response_string, cost = self.action_prefill_agent.get_response( + prompt_message, "action_prefill", use_backup_engine=True, configs=configs) + response_json = self.action_prefill_agent.response_to_dict( + response_string) + new_task = response_json["new_task"] + action_plans = response_json["actions_plan"] + + except Exception as e: + self.status = "ERROR" + error_msg = str(e) + self.log_execute_info( + prompt_message, {"ActionPrefillAgent": response_json}, error_msg) + + return None, None + else: + self.log_execute_info( + prompt_message, {"ActionPrefillAgent": response_json}, error_msg) + + return new_task, action_plans \ No newline at end of file diff --git a/instantiation/ael/module/filter_flow.py b/instantiation/ael/module/filter_flow.py new file mode 100644 index 00000000..0d53ddd0 --- /dev/null +++ b/instantiation/ael/module/filter_flow.py @@ -0,0 +1,48 @@ +from ael.agent.agent import FilterAgent + +from ael.config.config import Config +configs = Config.get_instance().config_data + + +class FilterFlow: + """ + The class to refine the plan steps and prefill the file. + """ + + def __init__(self, app_name: str): + """ + Initialize the follow flow. + :param app_name: The name of the operated app. + :param app_root_name: The root name of the app. + :param environment: The environment of the app. + """ + self.app_name = app_name + self.filter_agent = FilterAgent('filter', app_name, is_visual=True, main_prompt=configs["FILTER_PROMPT"], example_prompt="", + api_prompt=configs["API_PROMPT"]) + self.execute_step = 0 + + def get_filter_res(self, request: str): + """ + Call the PlanRefine Agent to select files + :return: The file to open + + """ + + + prompt_message = self.filter_agent.message_constructor( + "", + request, self.app_name) + try: + response_string, cost = self.filter_agent.get_response( + prompt_message, "filter", use_backup_engine=True, configs=configs) + response_json = self.filter_agent.response_to_dict( + response_string) + judge = response_json["judge"] + thought = response_json["thought"] + type = response_json["type"] + return judge, thought, type + + except Exception as e: + self.status = "ERROR" + print(f"Error: {e}") + return None \ No newline at end of file diff --git a/instantiation/ael/prompter/agent_prompter.py b/instantiation/ael/prompter/agent_prompter.py new file mode 100644 index 00000000..80b2bba1 --- /dev/null +++ b/instantiation/ael/prompter/agent_prompter.py @@ -0,0 +1,264 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +import docx +import os + +from ufo.prompter.basic import BasicPrompter + +def custom_encoder(obj): + if isinstance(obj, docx.styles.style._TableStyle): + return obj.to_dict() + elif isinstance(obj, type): + return f"" + raise TypeError(f'Object of type {obj.__class__.__name__} ' + 'is not JSON serializable') + + +class FilterPrompter(BasicPrompter): + + def __init__(self, is_visual: bool, prompt_template: str, example_prompt_template: str, api_prompt_template: str): + super().__init__(is_visual, prompt_template, example_prompt_template) + self.api_prompt_template = self.load_prompt_template( + api_prompt_template, is_visual) + + def api_prompt_helper(self, apis: dict = {}, verbose: int = 1) -> str: + """ + Construct the prompt for APIs. + :param apis: The APIs. + :param verbose: The verbosity level. + return: The prompt for APIs. + """ + + # Construct the prompt for APIs + if len(apis) == 0: + api_list = ["- The action type are limited to {actions}.".format( + actions=list(self.api_prompt_template.keys()))] + + # Construct the prompt for each API + for key in self.api_prompt_template.keys(): + api = self.api_prompt_template[key] + if verbose > 0: + api_text = "{summary}\n{usage}".format( + summary=api["summary"], usage=api["usage"]) + else: + api_text = api["summary"] + + api_list.append(api_text) + + api_prompt = self.retrived_documents_prompt_helper( + "", "", api_list) + else: + api_list = [ + "- The action type are limited to {actions}.".format(actions=list(apis.keys()))] + + # Construct the prompt for each API + for key in apis.keys(): + api = apis[key] + api_text = "{description}\n{example}".format( + description=api["description"], example=api["example"]) + api_list.append(api_text) + + api_prompt = self.retrived_documents_prompt_helper( + "", "", api_list) + + return api_prompt + + def system_prompt_construction(self, additional_examples: list = [], app:str="") -> str: + try: + ans = self.prompt_template["system"] + ans = ans.format(app=app) + return ans + except Exception as e: + print(e) + + def user_prompt_construction(self, request: str) -> str: + prompt = self.prompt_template["user"].format( + request=request + ) + + return prompt + + + def user_content_construction(self, request: str) -> list[dict]: + """ + Construct the prompt for LLMs. + :param action_history: The action history. + :param control_item: The control item. + :param user_request: The user request. + :param retrieved_docs: The retrieved documents. + return: The prompt for LLMs. + """ + + user_content = [] + + user_content.append( + { + "type": "text", + "text": self.user_prompt_construction( + request + ) + } + ) + + + return user_content + + def examples_prompt_helper(self, header: str = "## Response Examples", separator: str = "Example", additional_examples: list[str] = []) -> str: + """ + Construct the prompt for examples. + :param examples: The examples. + :param header: The header of the prompt. + :param separator: The separator of the prompt. + return: The prompt for examples. + """ + + template = """ + [User Request]: + {request} + [Response]: + {response} + [Tip] + {tip} + """ + + example_list = [] + + for key in self.example_prompt_template.keys(): + if key.startswith("example"): + example = template.format(request=self.example_prompt_template[key].get("Request"), response=json.dumps( + self.example_prompt_template[key].get("Response")), tip=self.example_prompt_template[key].get("Tips", "")) + example_list.append(example) + + example_list += [json.dumps(example) + for example in additional_examples] + + return self.retrived_documents_prompt_helper(header, separator, example_list) + + +class ActionPrefillPrompter(BasicPrompter): + def __init__(self, is_visual: bool, prompt_template: str, example_prompt_template: str, api_prompt_template: str): + super().__init__(is_visual, prompt_template, example_prompt_template) + self.api_prompt_template = self.load_prompt_template( + api_prompt_template, is_visual) + + def api_prompt_helper(self, verbose: int = 1) -> str: + """ + Construct the prompt for APIs. + :param apis: The APIs. + :param verbose: The verbosity level. + return: The prompt for APIs. + """ + + # Construct the prompt for APIs + api_list = ["- The action type are limited to {actions}.".format( + actions=list(self.api_prompt_template.keys()))] + + # Construct the prompt for each API + for key in self.api_prompt_template.keys(): + api = self.api_prompt_template[key] + if verbose > 0: + api_text = "{summary}\n{usage}".format( + summary=api["summary"], usage=api["usage"]) + else: + api_text = api["summary"] + + api_list.append(api_text) + + api_prompt = self.retrived_documents_prompt_helper( + "", "", api_list) + + + return api_prompt + + def system_prompt_construction(self, additional_examples: list = []) -> str: + examples = self.examples_prompt_helper( + additional_examples=additional_examples) + apis = self.api_prompt_helper(verbose=0) + return self.prompt_template["system"].format(apis=apis, examples=examples) + + def user_prompt_construction(self, + given_task: str, + reference_steps:list, + doc_control_state: dict) -> str: + prompt = self.prompt_template["user"].format( + given_task=given_task, + reference_steps=json.dumps(reference_steps), + doc_control_state=json.dumps(doc_control_state), + ) + + return prompt + + def load_screenshots(self, log_path: str) -> str: + """ + Load the first and last screenshots from the log path. + :param log_path: The path of the log. + """ + from ufo.prompter.eva_prompter import EvaluationAgentPrompter + + init_image = os.path.join(log_path, "0.png") + init_image_url = EvaluationAgentPrompter.load_single_screenshot(init_image) + return init_image_url + + def user_content_construction( + self, given_task: str, + reference_steps:list, + doc_control_state: dict, + log_path: str + ) -> list[dict]: + """ + Construct the prompt for LLMs. + :param action_history: The action history. + :param control_item: The control item. + :param user_request: The user request. + :param retrieved_docs: The retrieved documents. + return: The prompt for LLMs. + """ + + user_content = [] + if self.is_visual: + screenshot = self.load_screenshots(log_path) + screenshot_text = "This is the screenshot of the current environment." + + user_content.append({"type": "text", "text": screenshot_text}) + user_content.append({"type": "image_url", "image_url": {"url": screenshot}}) + + + user_content.append({ + "type": "text", + "text": self.user_prompt_construction(given_task, reference_steps, doc_control_state) + }) + + return user_content + + def examples_prompt_helper(self, header: str = "## Response Examples", separator: str = "Example", additional_examples: list[str] = []) -> str: + """ + Construct the prompt for examples. + :param examples: The examples. + :param header: The header of the prompt. + :param separator: The separator of the prompt. + return: The prompt for examples. + """ + + template = """ + [User Request]: + {request} + [Response]: + {response} + [Tip] + {tip} + """ + + example_list = [] + + for key in self.example_prompt_template.keys(): + if key.startswith("example"): + example = template.format(request=self.example_prompt_template[key].get("Request"), response=json.dumps( + self.example_prompt_template[key].get("Response")), tip=self.example_prompt_template[key].get("Tips", "")) + example_list.append(example) + + example_list += [json.dumps(example) + for example in additional_examples] + + return self.retrived_documents_prompt_helper(header, separator, example_list) \ No newline at end of file diff --git a/ufo/agents/agent/basic.py b/ufo/agents/agent/basic.py index e790d324..7b1d3172 100644 --- a/ufo/agents/agent/basic.py +++ b/ufo/agents/agent/basic.py @@ -138,7 +138,7 @@ def message_constructor(self) -> List[Dict[str, Union[str, List[Dict[str, str]]] @classmethod def get_response( - cls, message: List[dict], namescope: str, use_backup_engine: bool + cls, message: List[dict], namescope: str, use_backup_engine: bool, configs = configs ) -> str: """ Get the response for the prompt. @@ -148,7 +148,7 @@ def get_response( :return: The response. """ response_string, cost = llm_call.get_completion( - message, namescope, use_backup_engine=use_backup_engine + message, namescope, use_backup_engine=use_backup_engine, configs = configs ) return response_string, cost @@ -236,7 +236,7 @@ def process_resume(self) -> None: if self.processor: self.processor.resume() - def process_asker(self) -> None: + def process_asker(self, configs = configs) -> None: """ Ask for the process. """ diff --git a/ufo/agents/processors/app_agent_processor.py b/ufo/agents/processors/app_agent_processor.py index d513c9ce..2488e0d1 100644 --- a/ufo/agents/processors/app_agent_processor.py +++ b/ufo/agents/processors/app_agent_processor.py @@ -21,7 +21,8 @@ from ufo.agents.agent.app_agent import AppAgent configs = Config.get_instance().config_data -BACKEND = configs["CONTROL_BACKEND"] +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] class AppAgentProcessor(BaseProcessor): @@ -491,7 +492,8 @@ def demonstration_prompt_helper(self) -> Tuple[List[str], List[str]]: return examples, tips def get_filtered_annotation_dict( - self, annotation_dict: Dict[str, UIAWrapper] + self, annotation_dict: Dict[str, UIAWrapper], + configs = configs ) -> Dict[str, UIAWrapper]: """ Get the filtered annotation dictionary. diff --git a/ufo/agents/processors/basic.py b/ufo/agents/processors/basic.py index 543e1821..0906fbe4 100644 --- a/ufo/agents/processors/basic.py +++ b/ufo/agents/processors/basic.py @@ -20,7 +20,8 @@ from ufo.module.context import Context, ContextNames configs = Config.get_instance().config_data -BACKEND = configs["CONTROL_BACKEND"] +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] class BaseProcessor(ABC): diff --git a/ufo/agents/processors/host_agent_processor.py b/ufo/agents/processors/host_agent_processor.py index ab4257eb..0a951019 100644 --- a/ufo/agents/processors/host_agent_processor.py +++ b/ufo/agents/processors/host_agent_processor.py @@ -13,7 +13,8 @@ from ufo.module.context import Context, ContextNames configs = Config.get_instance().config_data -BACKEND = configs["CONTROL_BACKEND"] +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] if TYPE_CHECKING: from ufo.agents.agent.host_agent import HostAgent diff --git a/ufo/automator/ui_control/controller.py b/ufo/automator/ui_control/controller.py index eae14c98..154e8c1c 100644 --- a/ufo/automator/ui_control/controller.py +++ b/ufo/automator/ui_control/controller.py @@ -18,7 +18,7 @@ configs = Config.get_instance().config_data -if configs.get("AFTER_CLICK_WAIT", None) is not None: +if configs is not None and configs.get("AFTER_CLICK_WAIT", None) is not None: pywinauto.timings.Timings.after_clickinput_wait = configs["AFTER_CLICK_WAIT"] pywinauto.timings.Timings.after_click_wait = configs["AFTER_CLICK_WAIT"] diff --git a/ufo/automator/ui_control/openfile.py b/ufo/automator/ui_control/openfile.py index 87a50305..c5268736 100644 --- a/ufo/automator/ui_control/openfile.py +++ b/ufo/automator/ui_control/openfile.py @@ -8,7 +8,8 @@ configs = Config.get_instance().config_data -BACKEND = configs["CONTROL_BACKEND"] +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] class FileController: diff --git a/ufo/automator/ui_control/screenshot.py b/ufo/automator/ui_control/screenshot.py index 5f992ed5..0f74c85a 100644 --- a/ufo/automator/ui_control/screenshot.py +++ b/ufo/automator/ui_control/screenshot.py @@ -17,7 +17,8 @@ configs = Config.get_instance().config_data -DEFAULT_PNG_COMPRESS_LEVEL = int(configs["DEFAULT_PNG_COMPRESS_LEVEL"]) +if configs is not None: + DEFAULT_PNG_COMPRESS_LEVEL = int(configs["DEFAULT_PNG_COMPRESS_LEVEL"]) class Photographer(ABC): diff --git a/ufo/config/config.py b/ufo/config/config.py index 7df96a31..99036f91 100644 --- a/ufo/config/config.py +++ b/ufo/config/config.py @@ -14,7 +14,10 @@ class Config: def __init__(self): # Load config here - self.config_data = self.load_config() + if os.getenv("RUN_CONFIGS", "true").lower() != "false": + self.config_data = self.load_config() + else: + self.config_data = None @staticmethod def get_instance(): @@ -26,7 +29,7 @@ def get_instance(): Config._instance = Config() return Config._instance - def load_config(self, config_path="ufo/config/") -> dict: + def load_config(self, config_path = "ufo/config/") -> dict: """ Load the configuration from a YAML file and environment variables. @@ -45,14 +48,13 @@ def load_config(self, config_path="ufo/config/") -> dict: # Update configs with YAML data if yaml_data: configs.update(yaml_data) - with open(path + "config_dev.yaml", "r") as file: - yaml_dev_data = yaml.safe_load(file) - with open(path + "config_prices.yaml", "r") as file: - yaml_prices_data = yaml.safe_load(file) - # Update configs with YAML data - if yaml_data: + if os.path.exists(path + "config_dev.yaml"): + with open(path + "config_dev.yaml", "r") as file: + yaml_dev_data = yaml.safe_load(file) configs.update(yaml_dev_data) - if yaml_prices_data: + if os.path.exists(path + "config_prices.yaml"): + with open(path + "config_prices.yaml", "r") as file: + yaml_prices_data = yaml.safe_load(file) configs.update(yaml_prices_data) except FileNotFoundError: print_with_color( diff --git a/ufo/llm/llm_call.py b/ufo/llm/llm_call.py index 55f27e12..bb749a3b 100644 --- a/ufo/llm/llm_call.py +++ b/ufo/llm/llm_call.py @@ -12,7 +12,7 @@ def get_completion( - messages, agent: str = "APP", use_backup_engine: bool = True + messages, agent: str = "APP", use_backup_engine: bool = True, configs = configs ) -> Tuple[str, float]: """ Get completion for the given messages. @@ -28,13 +28,14 @@ def get_completion( """ responses, cost = get_completions( - messages, agent=agent, use_backup_engine=use_backup_engine, n=1 + messages, agent=agent, use_backup_engine=use_backup_engine, n=1, configs = configs ) return responses[0], cost def get_completions( - messages, agent: str = "APP", use_backup_engine: bool = True, n: int = 1 + messages, agent: str = "APP", use_backup_engine: bool = True, n: int = 1, + configs = configs ) -> Tuple[list, float]: """ Get completions for the given messages. @@ -53,6 +54,10 @@ def get_completions( agent_type = "HOST_AGENT" elif agent.lower() in ["app", "appagent"]: agent_type = "APP_AGENT" + elif agent.lower() == "action_prefill": + agent_type = "ACTION_PREFILL_AGENT" + elif agent.lower() == "filter": + agent_type = "FILTER_AGENT" elif agent.lower() == "backup": agent_type = "BACKUP_AGENT" else: diff --git a/ufo/llm/openai.py b/ufo/llm/openai.py index dce02003..cc1c4b7f 100644 --- a/ufo/llm/openai.py +++ b/ufo/llm/openai.py @@ -29,7 +29,7 @@ def __init__(self, config, agent_type: str) -> None: self.config = config self.api_type = self.config_llm["API_TYPE"].lower() self.max_retry = self.config["MAX_RETRY"] - self.prices = self.config["PRICES"] + self.prices = self.config.get("PRICES", {}) assert self.api_type in ["openai", "aoai", "azure_ad"], "Invalid API type" self.client: OpenAI = OpenAIService.get_openai_client( diff --git a/ufo/module/basic.py b/ufo/module/basic.py index 97c36d10..07d560cb 100644 --- a/ufo/module/basic.py +++ b/ufo/module/basic.py @@ -643,7 +643,7 @@ def capture_last_snapshot(self) -> None: app_agent.Puppeteer.save_to_xml(xml_save_path) @staticmethod - def initialize_logger(log_path: str, log_filename: str) -> logging.Logger: + def initialize_logger(log_path: str, log_filename: str, mode='a', configs = configs) -> logging.Logger: """ Initialize logging. log_path: The path of the log file. @@ -658,7 +658,7 @@ def initialize_logger(log_path: str, log_filename: str) -> logging.Logger: logger.handlers = [] log_file_path = os.path.join(log_path, log_filename) - file_handler = logging.FileHandler(log_file_path, encoding="utf-8") + file_handler = logging.FileHandler(log_file_path, mode = mode, encoding="utf-8") formatter = logging.Formatter("%(message)s") file_handler.setFormatter(formatter) logger.addHandler(file_handler) From 68402cecd873a16c3bb1747956459472fcc65ce6 Mon Sep 17 00:00:00 2001 From: Shilin HE Date: Wed, 25 Sep 2024 15:03:24 +0800 Subject: [PATCH 02/30] Update README.md --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index 60cbad65..b60b3d90 100644 --- a/README.md +++ b/README.md @@ -238,23 +238,6 @@ You may use them to debug, replay, or analyze the agent output. * For other communications, please contact [ufo-agent@microsoft.com](mailto:ufo-agent@microsoft.com). --- -## 🎬 Demo Examples - -We present two demo videos that complete user request on Windows OS using UFO. For more case study, please consult our [technical report](https://arxiv.org/abs/2402.07939). - -#### 1️⃣🗑️ Example 1: Deleting all notes on a PowerPoint presentation. -In this example, we will demonstrate how to efficiently use UFO to delete all notes on a PowerPoint presentation with just a few simple steps. Explore this functionality to enhance your productivity and work smarter, not harder! - - -https://github.com/microsoft/UFO/assets/11352048/cf60c643-04f7-4180-9a55-5fb240627834 - - - -#### 2️⃣📧 Example 2: Composing an email using text from multiple sources. -In this example, we will demonstrate how to utilize UFO to extract text from Word documents, describe an image, compose an email, and send it seamlessly. Enjoy the versatility and efficiency of cross-application experiences with UFO! - - -https://github.com/microsoft/UFO/assets/11352048/aa41ad47-fae7-4334-8e0b-ba71c4fc32e0 From dd46a6acaf76716a68c7d3e792c935ece6778083 Mon Sep 17 00:00:00 2001 From: Shilin HE Date: Wed, 25 Sep 2024 15:04:35 +0800 Subject: [PATCH 03/30] Update index.md --- documents/docs/index.md | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/documents/docs/index.md b/documents/docs/index.md index bac79968..d5adb9e8 100644 --- a/documents/docs/index.md +++ b/documents/docs/index.md @@ -68,22 +68,6 @@ UFO sightings have garnered attention from various media outlets, including: * For other communications, please contact [ufo-agent@microsoft.com](mailto:ufo-agent@microsoft.com) --- -## 🎬 Demo Examples - -We present two demo videos that complete user request on Windows OS using UFO. For more case study, please consult our [technical report](https://arxiv.org/abs/2402.07939). - -#### 1️⃣🗑️ Example 1: Deleting all notes on a PowerPoint presentation. -In this example, we will demonstrate how to efficiently use UFO to delete all notes on a PowerPoint presentation with just a few simple steps. Explore this functionality to enhance your productivity and work smarter, not harder! - - - -  - -#### 2️⃣📧 Example 2: Composing an email using text from multiple sources. -In this example, we will demonstrate how to utilize UFO to extract text from Word documents, describe an image, compose an email, and send it seamlessly. Enjoy the versatility and efficiency of cross-application experiences with UFO! - - -   ## 📚 Citation Our technical report paper can be found [here](https://arxiv.org/abs/2402.07939). Note that previous AppAgent and ActAgent in the paper are renamed to HostAgent and AppAgent in the code base to better reflect their functions. @@ -109,4 +93,4 @@ You may also find [TaskWeaver](https://github.com/microsoft/TaskWeaver?tab=readm gtag('js', new Date()); gtag('config', 'G-FX17ZGJYGC'); - \ No newline at end of file + From b94b116d2c021b6148a63bcda6aeef7294219f9c Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Wed, 25 Sep 2024 15:08:32 +0800 Subject: [PATCH 04/30] add screenshot function --- instantiation/README.md | 44 +++++++++++++++++-- .../ael/module/action_prefill_flow.py | 41 +++-------------- instantiation/ael/prompter/agent_prompter.py | 2 +- ufo/automator/ui_control/screenshot.py | 2 + 4 files changed, 48 insertions(+), 41 deletions(-) diff --git a/instantiation/README.md b/instantiation/README.md index 7c0ea7eb..c6a207f9 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -1,4 +1,3 @@ - diff --git a/instantiation/ael/module/action_prefill_flow.py b/instantiation/ael/module/action_prefill_flow.py index 92a8b392..866dc8d8 100644 --- a/instantiation/ael/module/action_prefill_flow.py +++ b/instantiation/ael/module/action_prefill_flow.py @@ -130,42 +130,6 @@ def log_execute_info(self, messages: list[dict], agent_response: dict, error: st # add not replace self.prefill_logger.info(json.dumps(history_json)) - - @ staticmethod - def filter_control_info(control_ui_tree, task): - """ - Get the filtered control information with the given task. - - Return: - The filtered control information. - """ - control_filter_type = configs["CONTROL_FILTER_TYPE"] - - if len(control_filter_type) == 0: - return control_ui_tree - - control_filter_type_lower = [control_filter_type_lower.lower( - ) for control_filter_type_lower in control_filter_type] - - filtered_control_info = [] - - keywords = ControlFilterFactory.items_to_keywords([task]) - if len(keywords) == 0: - return control_ui_tree - if 'text' in control_filter_type_lower: - model_text = ControlFilterFactory.create_control_filter( - 'text') - # filter the control information with bfs search - filtered_control_info = model_text.bfs_filter( - filtered_control_info, control_ui_tree, keywords) - # temp only support use one method - elif 'semantic' in control_filter_type_lower: - model_semantic = ControlFilterFactory.create_control_filter( - 'semantic', configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"]) - filtered_control_info = model_semantic.bfs_filter( - filtered_control_info, control_ui_tree, keywords) - return filtered_control_info - def get_target_file(self, given_task: str, doc_files_description: dict): """ Get the target file based on the semantic similarity of given task and the template file decription. @@ -189,6 +153,11 @@ def get_prefill_actions(self, given_task, reference_steps, file_path): error_msg = "" # update the canvas state and control states self.update_state(file_path) + + screenshot_path = self.log_path_configs + "/screenshot.png" + self.photographer.capture_app_window_screenshot(self.app_env.app_window, screenshot_path) + + # filter the controls filter_control_state = self.filtered_control_info # filter the apis diff --git a/instantiation/ael/prompter/agent_prompter.py b/instantiation/ael/prompter/agent_prompter.py index 80b2bba1..483fa286 100644 --- a/instantiation/ael/prompter/agent_prompter.py +++ b/instantiation/ael/prompter/agent_prompter.py @@ -197,7 +197,7 @@ def load_screenshots(self, log_path: str) -> str: """ from ufo.prompter.eva_prompter import EvaluationAgentPrompter - init_image = os.path.join(log_path, "0.png") + init_image = os.path.join(log_path, "screenshot.png") init_image_url = EvaluationAgentPrompter.load_single_screenshot(init_image) return init_image_url diff --git a/ufo/automator/ui_control/screenshot.py b/ufo/automator/ui_control/screenshot.py index 0f74c85a..8779d084 100644 --- a/ufo/automator/ui_control/screenshot.py +++ b/ufo/automator/ui_control/screenshot.py @@ -19,6 +19,8 @@ if configs is not None: DEFAULT_PNG_COMPRESS_LEVEL = int(configs["DEFAULT_PNG_COMPRESS_LEVEL"]) +else: + DEFAULT_PNG_COMPRESS_LEVEL = 6 class Photographer(ABC): From 2f5f8c756f3673855530e5daf38157641bc2fe1b Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Wed, 25 Sep 2024 19:28:00 +0800 Subject: [PATCH 05/30] fix the bug that opened app doesn't show in the screen --- instantiation/action_prefill.py | 11 +-- .../ael/automator/word/app_control.py | 73 ------------------- instantiation/ael/env/state_manager.py | 25 +++---- .../ael/module/action_prefill_flow.py | 3 +- ufo/automator/ui_control/openfile.py | 6 +- 5 files changed, 20 insertions(+), 98 deletions(-) delete mode 100644 instantiation/ael/automator/word/app_control.py diff --git a/instantiation/action_prefill.py b/instantiation/action_prefill.py index 72a056dc..2a02421f 100644 --- a/instantiation/action_prefill.py +++ b/instantiation/action_prefill.py @@ -15,14 +15,15 @@ class AppEnum(Enum): Define the apps can be used in the instantiation. """ - WORD = 1, 'Word', '.docx' - EXCEL = 2, 'Excel', '.xlsx' - POWERPOINT = 3, 'PowerPoint', '.pptx' + WORD = 1, 'Word', '.docx', 'winword' + EXCEL = 2, 'Excel', '.xlsx', 'excel' + POWERPOINT = 3, 'PowerPoint', '.pptx', 'powerpnt' - def __init__(self, id, description, file_extension): + def __init__(self, id, description, file_extension, win_app): self.id = id self.description = description self.file_extension = file_extension + self.win_app = win_app self.root_name = description + '.Application' class TaskObject(ABC): @@ -218,7 +219,7 @@ def __init__(self, task_dir_name : str, from instantiation.ael.env.state_manager import WindowsAppEnv self.task_object = ObjectMethodService(task_dir_name, task_config_object, task_json_object, task_path_object) - self.app_env = WindowsAppEnv(task_json_object.app_object.root_name, task_json_object.app_object.description) + self.app_env = WindowsAppEnv(task_json_object) self.action_prefill_flow = ActionPrefillFlow(task_json_object.app_object.description.lower(), self.app_env) self.action_prefill_flow.init_flow(task_path_object.task) diff --git a/instantiation/ael/automator/word/app_control.py b/instantiation/ael/automator/word/app_control.py deleted file mode 100644 index 890626dc..00000000 --- a/instantiation/ael/automator/word/app_control.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import win32com.client as win32 -import time - -from ael.config.config import Config -configs = Config.get_instance().config_data - -BACKEND = configs["CONTROL_BACKEND"] - -class AppControl: - def __init__(self,app_root_name) -> None: - self.file_instance = None - self.app_root_name = app_root_name - # Start the specified application - self.app_instance = win32.gencache.EnsureDispatch(self.app_root_name) - self.app_window = None - self.file_path = None - - def quit(self): - """ - Quit the application. - """ - # close all dialog - try: - # close_word_dialogs() - if self.file_instance: - self.file_instance.Close() - self.file_instance = None - self.file_path = None - except Exception as e: - print("Error while closing dialogs:", e) - finally: - self.app_instance.Quit() - self.app_instance = None - time.sleep(configs["SLEEP_TIME"]) - - - def open_file_with_app(self,file_path): - """ - This function attempts to open a file using a specified application. - - :param file_path: The full path to the file you want to open. - :param app_name: The ProgID of the application you want to use to open the file. - """ - self.app_instance = win32.gencache.EnsureDispatch(self.app_root_name) - time.sleep(configs["SLEEP_TIME"]) - self.file_path = file_path - - try: - - # Make the application visible - self.app_instance.Visible = True - - # Close all previously opened documents if supported - if hasattr(self.app_instance, 'Documents'): - for doc in self.app_instance.Documents: - doc.Close(False) # Argument False indicates not to save changes - - # Different applications have different methods for opening files - if self.app_root_name == 'Word.Application': - self.file_instance = self.app_instance.Documents.Open(file_path) - self.app_instance.WindowState = win32.constants.wdWindowStateMaximize - elif self.app_root_name == 'Excel.Application': - self.file_instance = self.app_instance.Workbooks.Open(file_path) - # Add more cases here for different applications - else: - print(f"Application '{self.app_root_name}' is not supported by this function.") - self.quit(save=False) - - except Exception as e: - print(f"An error occurred: {e}") \ No newline at end of file diff --git a/instantiation/ael/env/state_manager.py b/instantiation/ael/env/state_manager.py index fc66d0eb..2c445efe 100644 --- a/instantiation/ael/env/state_manager.py +++ b/instantiation/ael/env/state_manager.py @@ -1,7 +1,7 @@ import gymnasium as gym from ael.config.config import Config -from ael.automator.word import app_control as control +import win32com.client as win32 configs = Config.get_instance().config_data @@ -14,21 +14,17 @@ class WindowsAppEnv(gym.Env): The Windows App Environment. """ - def __init__(self, app_root_name, process_name: str): + def __init__(self, task_json_object): """ Initialize the Windows App Environment. :param app_root_name: The root name of the app. :param process_name: The process name of the app. """ super(WindowsAppEnv, self).__init__() - # app window self.app_window = None - # app root name like: 'Word.Application' - self._app_root_name = app_root_name - # process name like : 'Word' - self._process_name = process_name - # app control instance - self.app_control = control.AppControl(self._app_root_name) + self.app_root_name = task_json_object.app_object.root_name + self.win_app = task_json_object.app_object.win_app + self.app_instance = win32.gencache.EnsureDispatch(self.app_root_name) def start(self, seed): """ @@ -36,17 +32,14 @@ def start(self, seed): :param seed: The seed file to start the env. """ # close all the previous windows - self.app_control.open_file_with_app(seed) - - from ufo.automator.ui_control.inspector import ControlInspectorFacade - desktop_windows = ControlInspectorFacade(BACKEND).get_desktop_windows() - self.app_window = desktop_windows[0] - self.app_window.set_focus() + from ufo.automator.ui_control import openfile + file_controller = openfile.FileController(BACKEND) + file_controller.execute_code({"APP": self.win_app, "file_path": seed}) def close(self): """ Close the Window env. """ - self.app_control.quit() \ No newline at end of file + self.app_instance.Quit() \ No newline at end of file diff --git a/instantiation/ael/module/action_prefill_flow.py b/instantiation/ael/module/action_prefill_flow.py index 866dc8d8..ebc7b547 100644 --- a/instantiation/ael/module/action_prefill_flow.py +++ b/instantiation/ael/module/action_prefill_flow.py @@ -155,8 +155,7 @@ def get_prefill_actions(self, given_task, reference_steps, file_path): self.update_state(file_path) screenshot_path = self.log_path_configs + "/screenshot.png" - self.photographer.capture_app_window_screenshot(self.app_env.app_window, screenshot_path) - + self.photographer.capture_desktop_screen_screenshot(save_path = screenshot_path) # filter the controls filter_control_state = self.filtered_control_info diff --git a/ufo/automator/ui_control/openfile.py b/ufo/automator/ui_control/openfile.py index c5268736..652db393 100644 --- a/ufo/automator/ui_control/openfile.py +++ b/ufo/automator/ui_control/openfile.py @@ -10,6 +10,8 @@ if configs is not None: BACKEND = configs["CONTROL_BACKEND"] +else: + BACKEND = "uia" class FileController: @@ -17,9 +19,9 @@ class FileController: Control block for open file / specific APP and proceed the operation. """ - def __init__(self): + def __init__(self, backend=BACKEND): - self.backend = BACKEND + self.backend = backend self.file_path = "" self.APP = "" self.apptype = "" From c4ca4b6fc26460e86a3d3af1819191701ee911f2 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Wed, 25 Sep 2024 21:17:08 +0800 Subject: [PATCH 06/30] fix the bug which will randomly happen in batch running --- instantiation/action_prefill.py | 4 +++- instantiation/ael/env/state_manager.py | 18 +++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/instantiation/action_prefill.py b/instantiation/action_prefill.py index 2a02421f..e8d1af77 100644 --- a/instantiation/action_prefill.py +++ b/instantiation/action_prefill.py @@ -24,7 +24,8 @@ def __init__(self, id, description, file_extension, win_app): self.description = description self.file_extension = file_extension self.win_app = win_app - self.root_name = description + '.Application' + self.app_root_name = win_app.upper() + '.EXE' + self.client_clsid = description + '.Application' class TaskObject(ABC): """ @@ -304,6 +305,7 @@ def main(): try: for index, task_file in enumerate(all_task_files, start = 1): + print(f"Task starts: {index} / {len(all_task_files)}") task_json_object = TaskJsonObject(task_file) task_path_object = TaskPathObject(task_file) diff --git a/instantiation/ael/env/state_manager.py b/instantiation/ael/env/state_manager.py index 2c445efe..139d2412 100644 --- a/instantiation/ael/env/state_manager.py +++ b/instantiation/ael/env/state_manager.py @@ -1,7 +1,8 @@ import gymnasium as gym +import time from ael.config.config import Config -import win32com.client as win32 +from ufo.automator.app_apis.word.wordclient import WordWinCOMReceiver configs = Config.get_instance().config_data @@ -22,24 +23,27 @@ def __init__(self, task_json_object): """ super(WindowsAppEnv, self).__init__() self.app_window = None - self.app_root_name = task_json_object.app_object.root_name + self.app_root_name = task_json_object.app_object.app_root_name + self.process_name = task_json_object.app_object.description.lower() self.win_app = task_json_object.app_object.win_app - self.app_instance = win32.gencache.EnsureDispatch(self.app_root_name) + self.client_clsid = task_json_object.app_object.client_clsid + self.win_com_receiver = WordWinCOMReceiver(self.app_root_name, self.process_name, self.client_clsid) def start(self, seed): """ Start the Window env. :param seed: The seed file to start the env. """ - # close all the previous windows - from ufo.automator.ui_control import openfile - file_controller = openfile.FileController(BACKEND) + file_controller = openfile.FileController(BACKEND) file_controller.execute_code({"APP": self.win_app, "file_path": seed}) def close(self): """ Close the Window env. """ - self.app_instance.Quit() \ No newline at end of file + com_object = self.win_com_receiver.get_object_from_process_name() + com_object.Close() + self.win_com_receiver.client.Quit() + time.sleep(1) \ No newline at end of file From a9f2c348c6fb799703689855ea11c8e940ef69c2 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Sat, 12 Oct 2024 00:10:15 +0800 Subject: [PATCH 07/30] modify the instantiation section according to the review comments and add some test samples. --- instantiation/.gitignore | 10 +- instantiation/README.md | 91 ++- instantiation/action_prefill.py | 519 +++++++++++------- instantiation/ael/agent/agent.py | 116 ---- .../automator/ui_control/control_filter.py | 28 - instantiation/ael/env/state_manager.py | 49 -- instantiation/ael/module/filter_flow.py | 48 -- instantiation/controller/agent/agent.py | 161 ++++++ .../{ael => controller}/config/config.py | 7 +- instantiation/controller/env/state_manager.py | 57 ++ .../module/action_prefill_flow.py | 155 +++--- .../controller/module/filter_flow.py | 50 ++ .../prompter/agent_prompter.py | 225 +++++--- .../tasks/action_prefill/bulleted.json | 9 + .../tasks/action_prefill/delete.json | 9 + instantiation/tasks/action_prefill/draw.json | 10 + instantiation/tasks/action_prefill/macro.json | 9 + .../tasks/action_prefill/totate.json | 10 + 18 files changed, 955 insertions(+), 608 deletions(-) delete mode 100644 instantiation/ael/agent/agent.py delete mode 100644 instantiation/ael/automator/ui_control/control_filter.py delete mode 100644 instantiation/ael/env/state_manager.py delete mode 100644 instantiation/ael/module/filter_flow.py create mode 100644 instantiation/controller/agent/agent.py rename instantiation/{ael => controller}/config/config.py (88%) create mode 100644 instantiation/controller/env/state_manager.py rename instantiation/{ael => controller}/module/action_prefill_flow.py (53%) create mode 100644 instantiation/controller/module/filter_flow.py rename instantiation/{ael => controller}/prompter/agent_prompter.py (52%) create mode 100644 instantiation/tasks/action_prefill/bulleted.json create mode 100644 instantiation/tasks/action_prefill/delete.json create mode 100644 instantiation/tasks/action_prefill/draw.json create mode 100644 instantiation/tasks/action_prefill/macro.json create mode 100644 instantiation/tasks/action_prefill/totate.json diff --git a/instantiation/.gitignore b/instantiation/.gitignore index db001397..b76f122c 100644 --- a/instantiation/.gitignore +++ b/instantiation/.gitignore @@ -2,10 +2,10 @@ cache/ controls_cache/ tasks/* +!tasks/action_prefill templates/word/* prefill_logs/* -requirements.txt -ael/utils/ -ael/config/* -!ael/config/config.py -ael/prompts/* \ No newline at end of file +controller/utils/ +controller/config/* +!controller/config/config.py +controller/prompts/* \ No newline at end of file diff --git a/instantiation/README.md b/instantiation/README.md index c6a207f9..dab695a7 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -1,19 +1,36 @@ - ## Introduction of Instantiation **The instantiation process aims to filter and modify instructions according to the current environment.** By using this process, we can obtain clearer and more specific instructions, making them more suitable for the execution of the UFO. + ## How to use -The tasks that need instantiation should exist as a folder of JSON files, with the default folder path set to `instantiation/tasks`. This path can be changed in the `.yaml` file. For example, a task stored in `instantiation/tasks/action_prefill/` may look like this: +#### 1. Install packages + +You should install relative packages in the UFO root folder: + +```python +pip install -r requirements.txt +``` + +#### 2. Prepare files + +There are files need preparing before running the task. + +##### 2.1. Tasks as JSON + +The tasks that need to be instantiated should exist as a folder of JSON files, with the default folder path set to `instantiation/tasks`. This path can be changed in the `instantiation/controller/config/config.yaml` file, or you can use command in terminal, which is claimed in **3. Start running**. For example, a task stored in `instantiation/tasks/action_prefill/` may look like this: ```json { + // The app you want to use "app": "word", - "task": "Type in hello and set font type as Arial", + // The unique id to distinguish different task "unique_id": "1", + // The task and steps to be instantiated + "task": "Type in hello and set font type as Arial", "refined_steps": [ "type in 'hello'", "Set the font to Arial" @@ -21,18 +38,76 @@ The tasks that need instantiation should exist as a folder of JSON files, with t } ``` -You can instantiate the tasks by running the following command in the terminal: +##### 2.2. Templates and the description + +You should place a app file as the reference for the instantiation. You should place them in the folder named by the app. + +For example, if you have `template1.docx` for Word, it can be existed in `instantiation/templates/word/template1.docx`. + +What's more, for each app folder, a `instantiation/templates/word/description.json` file should be contained, and you can describe each template files in details. It may look like: + +```json +{ + "template1.docx":"A doc with a rectangle shape", + "template2.docx":"A doc with a line of text", + "template3.docx":"A doc with a chart" +} +``` + +##### 2.3. Final structure + +Check the mentioned files: + +* [X] JSON files to be instantiated +* [X] Templates as references for instantiations +* [X] Description file in JSON format + +Then, the structure of files can be: + +```bash +instantiation/ +| +├── tasks/ +│ ├── action_prefill/ +│ | ├── task1.json +│ | ├── task2.json +| | └── task3.json +| └── ... +| +├── templates/ +│ ├── word/ +│ | ├── template1.docx +│ | ├── template2.docx +| | ├── template3.docx +| | └── description.json +| └── ... +└──... +``` + + +#### 3. Start running + +Run the `instantiation/action_prefill.py` file. You can do it by typing the following command in the terminal: ``` python instantiation/action_prefill.py ``` -After the process is completed, a new folder will be created alongside the original one, named `action_prefill_new`, containing the instantiated task, which will look like: +You can use `--task` to set the task folder you want to use, the default is `action_prefill`: + +```bash +python instantiation/action_prefill.py --task your_task_folder_name +``` + +After the process is completed, a new folder will be created alongside the original one, named `action_prefill_instantiated`, containing the instantiated task, which will look like: ```json { + // The unique id to distinguish different task "unique_id": "1", + // The chosen template path "instantial_template_path": "cached template file path", + // The instantiated task and steps "instantiated_request": "Type 'hello' and set the font type to Arial in the Word document.", "instantiated_plan": [ { @@ -83,10 +158,14 @@ After the process is completed, a new folder will be created alongside the origi } } ], + // The comment for the instantiated task "request_comment": "The task involves typing a specific string 'hello' and setting the font type to Arial, which can be executed locally within Word." } ``` +And there will also be `action_prefill_templates` folder, which stores the copied chosen templates for each tasks. + + ## Workflow There are three key steps in the instantiation process: @@ -109,4 +188,4 @@ The screenshot will be sent to the action prefill agent, which will provide a mo ##### 3. Filter task -The completed task will be evaluated by a filter agent, which will assess it and return feedback. If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_new/good_instances`; otherwise, it will follow the same process for poor instances. +The completed task will be evaluated by a filter agent, which will assess it and return feedback. If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass`; otherwise, it will follow the same process for poor instances. diff --git a/instantiation/action_prefill.py b/instantiation/action_prefill.py index e8d1af77..0444b760 100644 --- a/instantiation/action_prefill.py +++ b/instantiation/action_prefill.py @@ -1,13 +1,17 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import os -from datetime import datetime -import json,glob import argparse +import glob +import json +import os import sys -from abc import ABC, abstractmethod +from abc import ABC +from datetime import datetime from enum import Enum +from typing import Dict + +from langchain_community.vectorstores import FAISS class AppEnum(Enum): @@ -15,63 +19,105 @@ class AppEnum(Enum): Define the apps can be used in the instantiation. """ - WORD = 1, 'Word', '.docx', 'winword' - EXCEL = 2, 'Excel', '.xlsx', 'excel' - POWERPOINT = 3, 'PowerPoint', '.pptx', 'powerpnt' + WORD = 1, "Word", ".docx", "winword" + EXCEL = 2, "Excel", ".xlsx", "excel" + POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" def __init__(self, id, description, file_extension, win_app): + """ + :param id: The unique id of the app. + :param description: The description of the app. Example: Word, Excel, PowerPoint. + :param file_extension: The file extension of the app. Example: .docx, .xlsx, .pptx. + :param win_app: The windows app name of the app. Example: winword, excel, powerpnt. + """ + self.id = id self.description = description self.file_extension = file_extension self.win_app = win_app - self.app_root_name = win_app.upper() + '.EXE' - self.client_clsid = description + '.Application' + # The root name of the app to be used in opening and closing app window. + self.app_root_name = win_app.upper() + ".EXE" + class TaskObject(ABC): """ Abstract class for the task object. + All the task objects should be extended from this class. """ - @abstractmethod def __init__(self): pass - def set_attributes(self, **kwargs) -> None: - for key, value in kwargs.items(): - setattr(self, key, value) - class TaskJsonObject(TaskObject): - def __init__(self, json_path): + """ + The task object from the json file. + """ + + def __init__(self, json_path: str) -> None: """ Initialize the task object from the json file. :param json_path: The json file path. - :return: The created json object. """ + + # Load the json file and get the app object. task_json_file = json.load(open(json_path, "r")) self.app_object = self.get_app_from_json(task_json_file) for key, value in task_json_file.items(): setattr(self, key.lower().replace(" ", "_"), value) - def get_app_from_json(self, task_json_file): + # The fields to be saved in the json file. + self.json_fields = [ + "unique_id", + "instantiated_request", + "instantiated_plan", + "instantial_template_path", + "request_comment", + ] + + def get_app_from_json(self, task_json_file: str) -> AppEnum: + """ + Generate an app object by traversing AppEnum based on the app specified in the JSON. + :param task_json_file: The JSON file of the task. + :return: The app object. + """ + for app in AppEnum: app_name = app.description.lower() json_app_name = task_json_file["app"].lower() if app_name == json_app_name: return app - raise ValueError('Not a correct App') - - def to_json(self): - fields = ['unique_id', 'instantiated_request', 'instantiated_plan', 'instantial_template_path', 'request_comment'] - data = {} + raise ValueError("Not a correct App") + + def to_json(self) -> dict: + """ + Convert the object to a JSON object. + :return: The JSON object. + """ + + json_data = {} for key, value in self.__dict__.items(): - if key in fields: + if key in self.json_fields: if hasattr(self, key): - data[key] = value - return data + json_data[key] = value + return json_data + + def set_attributes(self, **kwargs) -> None: + """ + Add all input fields as attributes. + :param kwargs: The fields to be added. + """ + + for key, value in kwargs.items(): + setattr(self, key, value) + class TaskPathObject(TaskObject): - def __init__(self, task_file): + """ + The path object according to the task file path. + """ + + def __init__(self, task_file: str): """ Initialize the task object from the task file path. :param task_file: The task file path. @@ -79,256 +125,329 @@ def __init__(self, task_file): """ self.task_file = task_file + # The folder name of the task, specific for one process. Example: action_prefill. self.task_file_dir_name = os.path.dirname(os.path.dirname(task_file)) + # The base name of the task file. Example: task_1.json. self.task_file_base_name = os.path.basename(task_file) - self.task=self.task_file_base_name.split('.')[0] + # The task name of the task file without extension. Example: task_1. + self.task = self.task_file_base_name.split(".")[0] -class TaskConfigObject(TaskObject): - def __init__(self, configs): - """ - Initialize the task object from the config dictionary. - :param configs: The config dictionary. - :return: The created config object. - """ - for key, value in configs.items(): - setattr(self, key.lower().replace(" ", "_"), value) - -class ObjectMethodService(): +class ObjectMethodService: """ The object method service. Provide methods related to the object, which is extended from TaskObject. """ - def __init__(self, task_dir_name : str, task_config_object : TaskObject, task_json_object : TaskObject, task_path_object : TaskObject) -> None: + + def __init__( + self, + task_dir_name: str, + task_json_object: TaskObject, + task_path_object: TaskObject, + ): """ :param task_dir_name: Folder name of the task, specific for one process. - :param task_config_object: Config object, which is singleton for one process. :param task_json_object: Json object, which is the json file of the task. :param task_path_object: Path object, which is related to the path of the task. """ - self.task_dir_name : str = task_dir_name - self.task_config_object : TaskObject = task_config_object - self.task_json_object : TaskObject = task_json_object - self.task_path_object : TaskObject = task_path_object - - @classmethod - def format_action_plans(self, action_plans : str) -> list[str]: - if isinstance(action_plans, str): - return action_plans.split("\n") - elif isinstance(action_plans, list): - return action_plans + self.task_dir_name = task_dir_name + self.task_json_object = task_json_object + self.task_path_object = task_path_object + self.filter_flow_app = dict() + + from instantiation.controller.env.state_manager import WindowsAppEnv + from instantiation.controller.module.action_prefill_flow import \ + ActionPrefillFlow + + self.app_env = WindowsAppEnv(task_json_object.app_object) + self.action_prefill_flow = ActionPrefillFlow( + task_json_object.app_object.description.lower(), + task_path_object.task, + self.app_env, + ) + + def filter_task( + self, app_name: str, request_to_filter: str + ) -> tuple[bool, str, str]: + """ + Filter the task by the filter flow. + :param app_name: The name of the app. Example: "Word". + :param request_to_filt: The request to be filtered. + :return: The evaluation quality \ comment \ type of the task. + """ + + if app_name not in self.filter_flow_app: + from controller.module.filter_flow import FilterFlow + + filter_flow = FilterFlow(app_name) + self.filter_flow_app[app_name] = filter_flow else: - return [] - - @classmethod - def filter_task(self, app_name : str, request_to_filt : str): - from ael.module.filter_flow import FilterFlow + filter_flow = self.filter_flow_app[app_name] try: - filter_flow = FilterFlow(app_name) + is_good, comment, type = filter_flow.get_filter_res(request_to_filter) + return is_good, comment, type except Exception as e: - print(f"Error! ObjectMethodService#filter_task: {e}") - else: - try: - is_good, comment, type = filter_flow.get_filter_res( - request_to_filt - ) - return is_good, comment, type - except Exception as e: - print(f"Error! ObjectMethodService#filter_task: {e}") - - def create_cache_file(self, ori_path: str, doc_path: str, file_name: str = None) -> str: + print(f"Error! ObjectMethodService#request_to_filter: {e}") + + def create_cache_file( + self, copy_from_path: str, copy_to_folder_path: str, file_name: str = None + ) -> str: """ According to the original path, create a cache file. - :param ori_path: The original path of the file. - :param doc_path: The path of the cache file. + :param copy_from_path: The original path of the file. + :param copy_to_folder_path: The path of the cache file. + :param file_name: The name of the task file. :return: The template path string as a new created file. """ - if not os.path.exists(doc_path): - os.makedirs(doc_path) + # Create the folder if not exists. + if not os.path.exists(copy_to_folder_path): + os.makedirs(copy_to_folder_path) time_start = datetime.now() current_path = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) template_extension = self.task_json_object.app_object.file_extension if not file_name: - seed = os.path.join(current_path, doc_path, - time_start.strftime('%Y-%m-%d-%H-%M-%S') + template_extension) + cached_template_path = os.path.join( + current_path, + copy_to_folder_path, + time_start.strftime("%Y-%m-%d-%H-%M-%S") + template_extension, + ) else: - seed = os.path.join(current_path, doc_path, file_name + template_extension) - with open(ori_path, 'rb') as f: + cached_template_path = os.path.join( + current_path, copy_to_folder_path, file_name + template_extension + ) + with open(copy_from_path, "rb") as f: ori_content = f.read() - with open(seed, 'wb') as f: + with open(cached_template_path, "wb") as f: f.write(ori_content) - return seed - - def get_choose_file(self, action_prefill_flow) -> str: + return cached_template_path + + def get_chosen_file_path(self) -> str: """ Choose the most relative template file. - :param action_prefill_flow: The action prefill flow object. :return: The most relative template file path string. """ - templates_description_path = self.get_description_path() + + # get the description of the templates + templates_description_path = os.path.join( + configs["TEMPLATE_PATH"], + self.task_json_object.app_object.description.lower(), + "description.json", + ) templates_file_description = json.load(open(templates_description_path, "r")) - choose_file = action_prefill_flow.get_target_file(self.task_json_object.task, templates_file_description) - return choose_file - - def get_description_path(self) -> str: - return os.path.join(self.task_config_object.template_path, self.task_json_object.app_object.description.lower(), "description.json") - - def get_ori_path(self, action_prefill_flow) -> str: - choose_file = self.get_choose_file(action_prefill_flow) - return os.path.join(self.task_config_object.template_path, self.task_json_object.app_object.description.lower(), choose_file) - - def get_doc_path(self) -> str: - return os.path.join(self.task_config_object.tasks_hub, self.task_dir_name + '_files') - - def get_and_save_seed_path(self, action_prefill_flow) -> str: - seed = self.create_cache_file(self.get_ori_path(action_prefill_flow), self.get_doc_path(), self.task_path_object.task) - self.task_json_object.instantial_template_path = seed - return seed - - def get_instance_folder_path(self) -> tuple[str, str]: + # get the chosen file path + chosen_file_path = self.get_target_template_file( + self.task_json_object.task, templates_file_description + ) + return chosen_file_path + + def chose_template_and_copy(self) -> str: """ - Get the new folder path for the good and bad instances without creating them. - :return: The folder path string where good / bad instances should be in. + Choose the template and copy it to the cache folder. """ - new_folder_path = os.path.join(self.task_config_object.tasks_hub, self.task_dir_name + "_new") - new_folder_good_path = os.path.join(new_folder_path, "good_instances") - new_folder_bad_path = os.path.join(new_folder_path, "bad_instances") - return new_folder_good_path, new_folder_bad_path - - -class ProcessProducer(): - """ - Key process to instantialize the task. - Provide workflow to initialize and evaluate the task. - """ - - def __init__(self, task_dir_name : str, - task_config_object : TaskObject, - task_json_object : TaskObject, - task_path_object : TaskObject): + # Get the chosen template file path. + chosen_template_file_path = self.get_chosen_file_path() + chosen_template_full_path = os.path.join( + configs["TEMPLATE_PATH"], + self.task_json_object.app_object.description.lower(), + chosen_template_file_path, + ) + + # Get the target template folder path. + target_template_folder_path = os.path.join( + configs["TASKS_HUB"], self.task_dir_name + "_templates" + ) + + # Copy the template to the cache folder. + template_cached_path = self.create_cache_file( + chosen_template_full_path, + target_template_folder_path, + self.task_path_object.task, + ) + self.task_json_object.instantial_template_path = template_cached_path + + return template_cached_path + + def get_target_template_file( + self, given_task: str, doc_files_description: Dict[str, str] + ): """ - :param task_dir_name: Folder name of the task, specific for one process. - :param task_config_object: Config object, which is singleton for one process. - :param task_json_object: Json object, which is the json file of the task. - :param task_path_object: Path object, which is related to the path of the task. + Get the target file based on the semantic similarity of given task and the template file decription. + :param given_task: The given task. + :param doc_files_description: The description of the template files. + :return: The target file path. """ - from instantiation.ael.module.action_prefill_flow import ActionPrefillFlow - from instantiation.ael.env.state_manager import WindowsAppEnv + candidates = [ + doc_file_description + for doc, doc_file_description in doc_files_description.items() + ] + file_doc_descriptions = { + doc_file_description: doc + for doc, doc_file_description in doc_files_description.items() + } + # use FAISS to get the top k control items texts + db = FAISS.from_texts(candidates, self.action_prefill_flow.embedding_model) + doc_descriptions = db.similarity_search(given_task, k=1) + doc_description = doc_descriptions[0].page_content + doc = file_doc_descriptions[doc_description] + return doc - self.task_object = ObjectMethodService(task_dir_name, task_config_object, task_json_object, task_path_object) - self.app_env = WindowsAppEnv(task_json_object) - - self.action_prefill_flow = ActionPrefillFlow(task_json_object.app_object.description.lower(), self.app_env) - self.action_prefill_flow.init_flow(task_path_object.task) - - def get_instantiated_result(self) -> tuple[str, str]: + def get_instance_folder_path(self) -> tuple[str, str]: """ - Get the instantiated result of the task. - :return: The instantiated request and plan string. + Get the new folder path for the passed / failed instances without creating them. + :return: The folder path string where passed / failed instances should be in. """ - seed = self.task_object.get_and_save_seed_path(self.action_prefill_flow) - try: - self.app_env.start(seed) - instantiated_request, instantiated_plan = self.action_prefill_flow.get_prefill_actions( - self.task_object.task_json_object.task, self.task_object.task_json_object.refined_steps, seed) - except Exception as e: - print(f"Error! ProcessProducer#get_instantiated_result: {e}") - finally: - self.app_env.close() - return instantiated_request, instantiated_plan - - - def get_task_filtered(self, task_to_filter : str) -> tuple[bool, str, str]: + new_folder_path = os.path.join( + configs["TASKS_HUB"], self.task_dir_name + "_instantiated" + ) + new_folder_pass_path = os.path.join(new_folder_path, "instances_pass") + new_folder_fail_path = os.path.join(new_folder_path, "instances_fail") + return new_folder_pass_path, new_folder_fail_path + + def get_task_filtered(self) -> None: """ Evaluate the task by the filter. :param task_to_filter: The task to be evaluated. :return: The evaluation quality \ comment \ type of the task. """ - request_quality_is_good, request_comment, request_type = \ - ObjectMethodService.filter_task(self.task_object.task_json_object.app_object.description.lower(), task_to_filter) - - return request_quality_is_good, request_comment, request_type - - def get_task_instantiated_and_filted(self) -> None: + request_quality_is_good, request_comment, request_type = self.filter_task( + self.task_json_object.app_object.description.lower(), + self.task_json_object.instantiated_request, + ) + self.task_json_object.set_attributes( + request_quality_is_good=request_quality_is_good, + request_comment=request_comment, + request_type=request_type, + ) + + def get_task_instantiated(self) -> None: """ Get the instantiated result and evaluate the task. - Save the task to the good / bad folder. + Save the task to the pass / fail folder. """ + # Get the instantiated result. + template_cached_path = self.chose_template_and_copy() + self.app_env.start(template_cached_path) try: - instantiated_request, instantiated_plan = self.get_instantiated_result() - instantiated_plan = ObjectMethodService.format_action_plans(instantiated_plan) - self.task_object.task_json_object.set_attributes(instantiated_request = instantiated_request, instantiated_plan=instantiated_plan) - - request_quality_is_good, request_comment, request_type = self.get_task_filtered(instantiated_request) - self.task_object.task_json_object.set_attributes(request_quality_is_good=request_quality_is_good, request_comment=request_comment, request_type=request_type) - - self.action_prefill_flow.execute_logger.info(f"Task {self.task_object.task_path_object.task_file_base_name} has been processed successfully.") + instantiated_request, instantiated_plan = ( + self.action_prefill_flow.get_prefill_actions( + self.task_json_object.task, + self.task_json_object.refined_steps, + template_cached_path, + ) + ) except Exception as e: - print(f"Error! ProcessProducer#get_task_instantiated_and_filted: {e}") - self.action_prefill_flow.execute_logger.info(f"Error:{e}") - + print(f"Error! get_instantiated_result: {e}") + finally: + self.app_env.close() + + self.task_json_object.set_attributes( + instantiated_request=instantiated_request, + instantiated_plan=instantiated_plan, + ) + + self.action_prefill_flow.prefill_logger.info( + f"Task {self.task_path_object.task_file_base_name} has been processed successfully." + ) + def save_instantiated_task(self) -> None: """ - Save the instantiated task to the good / bad folder. + Save the instantiated task to the pass / fail folder. """ - - new_folder_good_path, new_folder_bad_path = self.task_object.get_instance_folder_path() - task_json = self.task_object.task_json_object.to_json() - if self.task_object.task_json_object.request_quality_is_good: - new_task_path = os.path.join(new_folder_good_path, self.task_object.task_path_object.task_file_base_name) + # Get the folder path for classified instances. + new_folder_pass_path, new_folder_fail_path = self.get_instance_folder_path() + # Generate the json object of the task. + task_json = self.task_json_object.to_json() + + # Save the task to the pass / fail folder. + if self.task_json_object.request_quality_is_good: + new_task_path = os.path.join( + new_folder_pass_path, self.task_path_object.task_file_base_name + ) else: - new_task_path = os.path.join(new_folder_bad_path, self.task_object.task_path_object.task_file_base_name) + new_task_path = os.path.join( + new_folder_fail_path, self.task_path_object.task_file_base_name + ) os.makedirs(os.path.dirname(new_task_path), exist_ok=True) - open(new_task_path,"w").write(json.dumps(task_json)) + open(new_task_path, "w").write(json.dumps(task_json)) -def main(): + +class ServiceController: """ - The main function to process the tasks. + Key process to instantialize the task. + Control the process of the task. """ - from instantiation.ael.config.config import Config - config_path = os.path.normpath(os.path.join(current_dir, 'ael/config/')) + '\\' - configs : dict[str, str] = Config(config_path).get_instance().config_data - task_config_object : TaskObject = TaskConfigObject(configs) - - task_dir_name = parsed_args.task.lower() - all_task_file_path : str = os.path.join(task_config_object.tasks_hub, task_dir_name, "*") - all_task_files = glob.glob(all_task_file_path) + def execute(task_service: ObjectMethodService) -> None: + """ + Execute the process for one task. + :param task_service: The task service object. + The execution includes getting the instantiated result, evaluating the task and saving the instantiated task. + """ - try: - for index, task_file in enumerate(all_task_files, start = 1): - print(f"Task starts: {index} / {len(all_task_files)}") - task_json_object = TaskJsonObject(task_file) - task_path_object = TaskPathObject(task_file) - - process = ProcessProducer(task_dir_name, task_config_object, task_json_object, task_path_object) - process.get_task_instantiated_and_filted() - process.save_instantiated_task() - - except Exception as e: - print(f"Error! main: {e}") + task_service.get_task_instantiated() + task_service.get_task_filtered() + task_service.save_instantiated_task() -if __name__ == "__main__": +def main(): + """ + The main function to process the tasks. + """ + # Add the project root to the system path. current_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.abspath(os.path.join(current_dir, '..')) - + project_root = os.path.abspath(os.path.join(current_dir, "..")) + if project_root not in sys.path: sys.path.append(project_root) + # Set the environment variable. os.environ["RUN_CONFIGS"] = "false" + # Parse the arguments. args = argparse.ArgumentParser() - args.add_argument("--task", help="The name of the task.", type=str, default="action_prefill") + args.add_argument( + "--task", help="The name of the task.", type=str, default="action_prefill" + ) parsed_args = args.parse_args() - main() \ No newline at end of file + # Load the configs. + from instantiation.controller.config.config import Config + + config_path = ( + os.path.normpath(os.path.join(current_dir, "controller/config/")) + "\\" + ) + global configs + configs = Config(config_path).get_instance().config_data + + # Get and process all the task files. + task_dir_name = parsed_args.task.lower() + all_task_file_path: str = os.path.join(configs["TASKS_HUB"], task_dir_name, "*") + all_task_files = glob.glob(all_task_file_path) + + for index, task_file in enumerate(all_task_files, start=1): + print(f"Task starts: {index} / {len(all_task_files)}") + try: + task_json_object = TaskJsonObject(task_file) + task_path_object = TaskPathObject(task_file) + + task_service = ObjectMethodService( + task_dir_name, task_json_object, task_path_object + ) + ServiceController.execute(task_service) + except Exception as e: + print(f"Error in task {index} with file {task_file}: {e}") + + print("All tasks have been processed.") + + +if __name__ == "__main__": + main() diff --git a/instantiation/ael/agent/agent.py b/instantiation/ael/agent/agent.py deleted file mode 100644 index 7e521d59..00000000 --- a/instantiation/ael/agent/agent.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from ..prompter.agent_prompter import ActionPrefillPrompter, FilterPrompter -from ufo.agents.agent.basic import BasicAgent - -class FilterAgent(BasicAgent): - """ - The Agent to evaluate whether the task has been completed and whether the actions sequence has taken effects. - """ - def __init__(self, name: str, process_name: str, is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str): - """ - Initialize the FollowAgent. - :agent_type: The type of the agent. - :is_visual: The flag indicating whether the agent is visual or not. - """ - self._step = 0 - self._complete = False - self._name = name - self._status = None - self.prompter : FilterPrompter = self.get_prompter( - is_visual, main_prompt, example_prompt, api_prompt) - self._process_name = process_name - - def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: - """ - Get the prompt for the agent. - :return: The prompt. - """ - return FilterPrompter(is_visual, main_prompt, example_prompt, api_prompt) - - def message_constructor(self, dynamic_examples: str, - request: str, app:str) -> list: - """ - Construct the prompt message for the AppAgent. - :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. - :param dynamic_tips: The dynamic tips retrieved from the self-demonstration and human demonstration. - :param image_list: The list of screenshot images. - :param request_history: The request history. - :param action_history: The action history. - :param plan: The plan. - :param request: The request. - :return: The prompt message. - """ - filter_agent_prompt_system_message = self.prompter.system_prompt_construction( - dynamic_examples, app = app) - filter_agent_prompt_user_message = self.prompter.user_content_construction( - request) - filter_agent_prompt_message = self.prompter.prompt_construction( - filter_agent_prompt_system_message, filter_agent_prompt_user_message) - - return filter_agent_prompt_message - - def process_comfirmation(self) -> None: - """ - Confirm the process. - """ - pass - - -class ActionPrefillAgent(BasicAgent): - """ - The Agent for task instantialization and action sequence generation. - """ - - def __init__(self, name: str, process_name: str, is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str): - """ - Initialize the FollowAgent. - :agent_type: The type of the agent. - :is_visual: The flag indicating whether the agent is visual or not. - """ - self._step = 0 - self._complete = False - self._name = name - self._status = None - self.prompter:ActionPrefillPrompter = self.get_prompter( - is_visual, main_prompt, example_prompt, api_prompt) - self._process_name = process_name - - def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: - """ - Get the prompt for the agent. - :return: The prompt. - """ - return ActionPrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) - - def message_constructor(self, dynamic_examples: str, - given_task: str, - reference_steps:list, - doc_control_state: dict, - log_path : str) -> list: - """ - Construct the prompt message for the AppAgent. - :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. - :param dynamic_tips: The dynamic tips retrieved from the self-demonstration and human demonstration. - :param image_list: The list of screenshot images. - :param request_history: The request history. - :param action_history: The action history. - :param plan: The plan. - :param request: The request. - :return: The prompt message. - """ - action_prefill_agent_prompt_system_message = self.prompter.system_prompt_construction( - dynamic_examples) - action_prefill_agent_prompt_user_message = self.prompter.user_content_construction( - given_task,reference_steps, doc_control_state, log_path) - appagent_prompt_message = self.prompter.prompt_construction( - action_prefill_agent_prompt_system_message, action_prefill_agent_prompt_user_message) - - return appagent_prompt_message - - def process_comfirmation(self) -> None: - """ - Confirm the process. - """ - pass \ No newline at end of file diff --git a/instantiation/ael/automator/ui_control/control_filter.py b/instantiation/ael/automator/ui_control/control_filter.py deleted file mode 100644 index ce27f9ef..00000000 --- a/instantiation/ael/automator/ui_control/control_filter.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import re -import warnings -from ufo.automator.ui_control.control_filter import ControlFilterFactory -from ael.config.config import Config - -warnings.filterwarnings("ignore") -configs = Config.get_instance().config_data - -class ControlFilterFactory(ControlFilterFactory): - @staticmethod - def items_to_keywords(items:list) -> list: - """ - Gets keywords from the plan and request. - We only consider the words in the plan and request that are alphabetic or Chinese characters. - :param plan (str): The plan to be parsed. - :param request (str): The request to be parsed. - Returns: - list: A list of keywords extracted from the plan. - """ - keywords = [] - for item in items: - words = item.replace("\n", "").replace("'", "").replace("*","").strip(".").split() - words = [word for word in words if word.isalpha() or bool(re.fullmatch(r'[\u4e00-\u9fa5]+', word))] - keywords.extend(word for word in words if word not in keywords) - return keywords \ No newline at end of file diff --git a/instantiation/ael/env/state_manager.py b/instantiation/ael/env/state_manager.py deleted file mode 100644 index 139d2412..00000000 --- a/instantiation/ael/env/state_manager.py +++ /dev/null @@ -1,49 +0,0 @@ -import gymnasium as gym -import time - -from ael.config.config import Config -from ufo.automator.app_apis.word.wordclient import WordWinCOMReceiver - -configs = Config.get_instance().config_data - -if configs is not None: - BACKEND = configs["CONTROL_BACKEND"] - - -class WindowsAppEnv(gym.Env): - """ - The Windows App Environment. - """ - - def __init__(self, task_json_object): - """ - Initialize the Windows App Environment. - :param app_root_name: The root name of the app. - :param process_name: The process name of the app. - """ - super(WindowsAppEnv, self).__init__() - self.app_window = None - self.app_root_name = task_json_object.app_object.app_root_name - self.process_name = task_json_object.app_object.description.lower() - self.win_app = task_json_object.app_object.win_app - self.client_clsid = task_json_object.app_object.client_clsid - self.win_com_receiver = WordWinCOMReceiver(self.app_root_name, self.process_name, self.client_clsid) - - def start(self, seed): - """ - Start the Window env. - :param seed: The seed file to start the env. - """ - from ufo.automator.ui_control import openfile - - file_controller = openfile.FileController(BACKEND) - file_controller.execute_code({"APP": self.win_app, "file_path": seed}) - - def close(self): - """ - Close the Window env. - """ - com_object = self.win_com_receiver.get_object_from_process_name() - com_object.Close() - self.win_com_receiver.client.Quit() - time.sleep(1) \ No newline at end of file diff --git a/instantiation/ael/module/filter_flow.py b/instantiation/ael/module/filter_flow.py deleted file mode 100644 index 0d53ddd0..00000000 --- a/instantiation/ael/module/filter_flow.py +++ /dev/null @@ -1,48 +0,0 @@ -from ael.agent.agent import FilterAgent - -from ael.config.config import Config -configs = Config.get_instance().config_data - - -class FilterFlow: - """ - The class to refine the plan steps and prefill the file. - """ - - def __init__(self, app_name: str): - """ - Initialize the follow flow. - :param app_name: The name of the operated app. - :param app_root_name: The root name of the app. - :param environment: The environment of the app. - """ - self.app_name = app_name - self.filter_agent = FilterAgent('filter', app_name, is_visual=True, main_prompt=configs["FILTER_PROMPT"], example_prompt="", - api_prompt=configs["API_PROMPT"]) - self.execute_step = 0 - - def get_filter_res(self, request: str): - """ - Call the PlanRefine Agent to select files - :return: The file to open - - """ - - - prompt_message = self.filter_agent.message_constructor( - "", - request, self.app_name) - try: - response_string, cost = self.filter_agent.get_response( - prompt_message, "filter", use_backup_engine=True, configs=configs) - response_json = self.filter_agent.response_to_dict( - response_string) - judge = response_json["judge"] - thought = response_json["thought"] - type = response_json["type"] - return judge, thought, type - - except Exception as e: - self.status = "ERROR" - print(f"Error: {e}") - return None \ No newline at end of file diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py new file mode 100644 index 00000000..7f6977d5 --- /dev/null +++ b/instantiation/controller/agent/agent.py @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Dict, List + +from instantiation.controller.prompter.agent_prompter import ( + ActionPrefillPrompter, FilterPrompter) +from ufo.agents.agent.basic import BasicAgent + + +class FilterAgent(BasicAgent): + """ + The Agent to evaluate the instantiated task is correct or not. + """ + + def __init__( + self, + name: str, + process_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the FilterAgent. + :param name: The name of the agent. + :param process_name: The name of the process. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + self._step = 0 + self._complete = False + self._name = name + self._status = None + self.prompter: FilterPrompter = self.get_prompter( + is_visual, main_prompt, example_prompt, api_prompt + ) + self._process_name = process_name + + def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: + """ + Get the prompt for the agent. + :return: The prompt. + """ + + return FilterPrompter(is_visual, main_prompt, example_prompt, api_prompt) + + def message_constructor(self, request: str, app: str) -> List[str]: + """ + Construct the prompt message for the FilterAgent. + :param request: The request sentence. + :param app: The name of the operated app. + :return: The prompt message. + """ + + filter_agent_prompt_system_message = self.prompter.system_prompt_construction( + app=app + ) + filter_agent_prompt_user_message = self.prompter.user_content_construction( + request + ) + filter_agent_prompt_message = self.prompter.prompt_construction( + filter_agent_prompt_system_message, filter_agent_prompt_user_message + ) + + return filter_agent_prompt_message + + def process_comfirmation(self) -> None: + """ + Confirm the process. + """ + pass + + +class ActionPrefillAgent(BasicAgent): + """ + The Agent for task instantialization and action sequence generation. + """ + + def __init__( + self, + name: str, + process_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the ActionPrefillAgent. + :param name: The name of the agent. + :param process_name: The name of the process. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + self._step = 0 + self._complete = False + self._name = name + self._status = None + self.prompter: ActionPrefillPrompter = self.get_prompter( + is_visual, main_prompt, example_prompt, api_prompt + ) + self._process_name = process_name + + def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: + """ + Get the prompt for the agent. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + :return: The prompt. + """ + + return ActionPrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) + + def message_constructor( + self, + dynamic_examples: str, + given_task: str, + reference_steps: List[str], + doc_control_state: Dict[str, str], + log_path: str, + ) -> List[str]: + """ + Construct the prompt message for the ActionPrefillAgent. + :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. + :param given_task: The given task. + :param reference_steps: The reference steps. + :param doc_control_state: The document control state. + :param log_path: The path of the log. + :return: The prompt message. + """ + + action_prefill_agent_prompt_system_message = ( + self.prompter.system_prompt_construction(dynamic_examples) + ) + action_prefill_agent_prompt_user_message = ( + self.prompter.user_content_construction( + given_task, reference_steps, doc_control_state, log_path + ) + ) + appagent_prompt_message = self.prompter.prompt_construction( + action_prefill_agent_prompt_system_message, + action_prefill_agent_prompt_user_message, + ) + + return appagent_prompt_message + + def process_comfirmation(self) -> None: + """ + Confirm the process. + """ + pass diff --git a/instantiation/ael/config/config.py b/instantiation/controller/config/config.py similarity index 88% rename from instantiation/ael/config/config.py rename to instantiation/controller/config/config.py index b977de0b..be55111d 100644 --- a/instantiation/ael/config/config.py +++ b/instantiation/controller/config/config.py @@ -3,10 +3,11 @@ from ufo.config.config import Config + class Config(Config): _instance = None - def __init__(self, config_path = "instantiation/ael/config/"): + def __init__(self, config_path="instantiation/controller/config/"): self.config_data = self.load_config(config_path) @staticmethod @@ -17,11 +18,11 @@ def get_instance(): """ if Config._instance is None: Config._instance = Config() - + return Config._instance def optimize_configs(self, configs): self.update_api_base(configs, "ACTION_PREFILL_AGENT") self.update_api_base(configs, "FILTER_AGENT") - + return configs \ No newline at end of file diff --git a/instantiation/controller/env/state_manager.py b/instantiation/controller/env/state_manager.py new file mode 100644 index 00000000..f792724d --- /dev/null +++ b/instantiation/controller/env/state_manager.py @@ -0,0 +1,57 @@ +import time + +from instantiation.controller.config.config import Config +from ufo.automator.puppeteer import ReceiverManager + +configs = Config.get_instance().config_data + +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] + + +class WindowsAppEnv: + """ + The Windows App Environment. + """ + + def __init__(self, app_object): + """ + Initialize the Windows App Environment. + :param app_object: The app object containing the app information. + """ + + super(WindowsAppEnv, self).__init__() + self.app_window = None + self.app_root_name = app_object.app_root_name + self.process_name = app_object.description.lower() + self.win_app = app_object.win_app + + self.receive_factory = ReceiverManager._receiver_factory_registry["COM"][ + "factory" + ] + self.win_com_receiver = self.receive_factory.create_receiver( + self.app_root_name, self.process_name + ) + + def start(self, cached_template_path): + """ + Start the Window env. + :param cached_template_path: The file path to start the env. + """ + + from ufo.automator.ui_control import openfile + + file_controller = openfile.FileController(BACKEND) + file_controller.execute_code( + {"APP": self.win_app, "file_path": cached_template_path} + ) + + def close(self): + """ + Close the Window env. + """ + + com_object = self.win_com_receiver.get_object_from_process_name() + com_object.Close() + self.win_com_receiver.client.Quit() + time.sleep(1) diff --git a/instantiation/ael/module/action_prefill_flow.py b/instantiation/controller/module/action_prefill_flow.py similarity index 53% rename from instantiation/ael/module/action_prefill_flow.py rename to instantiation/controller/module/action_prefill_flow.py index ebc7b547..e51ba5ce 100644 --- a/instantiation/ael/module/action_prefill_flow.py +++ b/instantiation/controller/module/action_prefill_flow.py @@ -1,66 +1,61 @@ import json import os +from typing import Dict, List + +from langchain.embeddings import CacheBackedEmbeddings from langchain.storage import LocalFileStore -from langchain_community.vectorstores import FAISS from langchain_community.embeddings import HuggingFaceEmbeddings -from langchain.embeddings import CacheBackedEmbeddings - -from ael.automator.ui_control.control_filter import ControlFilterFactory -from ael.env.state_manager import WindowsAppEnv -from ael.agent.agent import ActionPrefillAgent -from ael.config.config import Config +from instantiation.controller.agent.agent import ActionPrefillAgent +from instantiation.controller.config.config import Config +from instantiation.controller.env.state_manager import WindowsAppEnv +from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.automator.ui_control.inspector import ControlInspectorFacade from ufo.automator.ui_control.screenshot import PhotographerFacade -from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.module.basic import BaseSession - configs = Config.get_instance().config_data if configs is not None: BACKEND = configs["CONTROL_BACKEND"] -def load_embedding_model(model_name: str): - store = LocalFileStore(configs["CONTROL_EMBEDDING_CACHE_PATH"]) - if not model_name.startswith("sentence-transformers"): - model_name = "sentence-transformers/" + model_name - embedding_model = HuggingFaceEmbeddings(model_name=model_name) - cached_embedder = CacheBackedEmbeddings.from_bytes_store( - embedding_model, store, namespace=model_name - ) - return cached_embedder - class ActionPrefillFlow(AppAgentProcessor): """ The class to refine the plan steps and prefill the file. """ - def __init__(self, app_name: str, environment: WindowsAppEnv = None, embedding_model: str = configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"]): + + def __init__( + self, + app_name: str, + task: str, + environment: WindowsAppEnv = None, + embedding_model: str = configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"], + ): """ Initialize the follow flow. :param app_name: The name of the operated app. :param app_root_name: The root name of the app. :param environment: The environment of the app. + :param task: The label of the task. """ + self.app_env = environment - self.action_prefill_agent = ActionPrefillAgent('action_prefill', - app_name, is_visual=True, - main_prompt=configs["ACTION_PREFILL_PROMPT"], - example_prompt=configs["ACTION_PREFILL_EXAMPLE_PROMPT"], - api_prompt=configs["API_PROMPT"]) - # self._image_url = [] - # Control filter - self.file_path = "" - self.embedding_model = load_embedding_model(embedding_model) + # Create the action prefill agent + self.action_prefill_agent = ActionPrefillAgent( + "action_prefill", + app_name, + is_visual=True, + main_prompt=configs["ACTION_PREFILL_PROMPT"], + example_prompt=configs["ACTION_PREFILL_EXAMPLE_PROMPT"], + api_prompt=configs["API_PROMPT"], + ) + # + self.file_path = "" + self.embedding_model = ActionPrefillFlow.load_embedding_model(embedding_model) - def init_flow(self, task): - """ - Init the flow. - :param task: The label of the task. - """ self.execute_step = 0 - self.canvas_state = None + # self.canvas_state = None self.control_inspector = ControlInspectorFacade(BACKEND) self.photographer = PhotographerFacade() @@ -72,9 +67,9 @@ def init_flow(self, task): self.log_path_configs = configs["PREFILL_LOG_PATH"].format(task=task) os.makedirs(self.log_path_configs, exist_ok=True) - self.prefill_logger = BaseSession.initialize_logger(self.log_path_configs, f"prefill_agent.json",'w', configs) - self.execute_logger = BaseSession.initialize_logger(self.log_path_configs, f"execute.jsonl",'a', configs) - + self.prefill_logger = BaseSession.initialize_logger( + self.log_path_configs, f"prefill_agent.json", "w", configs + ) def update_state(self, file_path: str): """ @@ -84,9 +79,9 @@ def update_state(self, file_path: str): print(f"updating the state of app file: {file_path}") control_list = self.control_inspector.find_control_elements_in_descendants( - self.app_env.app_window, - control_type_list=configs["CONTROL_LIST"], - class_name_list=configs["CONTROL_LIST"], + self.app_env.app_window, + control_type_list=configs["CONTROL_LIST"], + class_name_list=configs["CONTROL_LIST"], ) self._annotation_dict = self.photographer.get_annotation_dict( self.app_env.app_window, control_list, annotation_type="number" @@ -94,7 +89,7 @@ def update_state(self, file_path: str): # Attempt to filter out irrelevant control items based on the previous plan. self.filtered_annotation_dict = self.get_filtered_annotation_dict( - self._annotation_dict, configs = configs + self._annotation_dict, configs=configs ) self._control_info = self.control_inspector.get_control_info_list_of_dict( @@ -110,11 +105,22 @@ def update_state(self, file_path: str): ], ) ) - - def log_execute_info(self, messages: list[dict], agent_response: dict, error: str = ""): + def load_embedding_model(model_name: str): + store = LocalFileStore(configs["CONTROL_EMBEDDING_CACHE_PATH"]) + if not model_name.startswith("sentence-transformers"): + model_name = "sentence-transformers/" + model_name + embedding_model = HuggingFaceEmbeddings(model_name=model_name) + cached_embedder = CacheBackedEmbeddings.from_bytes_store( + embedding_model, store, namespace=model_name + ) + return cached_embedder + + def log_prefill_agent_info( + self, messages: List[Dict], agent_response: Dict, error: str = "" + ): """ - Record the execution information. + Record the prefill information. :param messages: The messages of the conversation. :param agent_response: The response of the agent. :param error: The error message. @@ -125,62 +131,57 @@ def log_execute_info(self, messages: list[dict], agent_response: dict, error: st "step": self.execute_step, "messages": messages, "agent_response": agent_response, - "error": error + "error": error, } # add not replace self.prefill_logger.info(json.dumps(history_json)) - def get_target_file(self, given_task: str, doc_files_description: dict): - """ - Get the target file based on the semantic similarity of given task and the template file decription. - """ - candidates = [doc_file_description for doc, - doc_file_description in doc_files_description.items()] - file_doc_descriptions = {doc_file_description: doc for doc, - doc_file_description in doc_files_description.items()} - # use FAISS to get the top k control items texts - db = FAISS.from_texts(candidates, self.embedding_model) - doc_descriptions = db.similarity_search(given_task, k=1) - doc_description = doc_descriptions[0].page_content - doc = file_doc_descriptions[doc_description] - return doc - - def get_prefill_actions(self, given_task, reference_steps, file_path): + def get_prefill_actions( + self, given_task, reference_steps, file_path + ) -> tuple[str, List]: """ Call the PlanRefine Agent to select files - :return: The file to open + :param given_task: The given task. + :param reference_steps: The reference steps. + :param file_path: The file path. + :return: The prefilled task and the action plans. """ + error_msg = "" - # update the canvas state and control states + # update the control states self.update_state(file_path) screenshot_path = self.log_path_configs + "/screenshot.png" - self.photographer.capture_desktop_screen_screenshot(save_path = screenshot_path) + self.photographer.capture_desktop_screen_screenshot(save_path=screenshot_path) # filter the controls filter_control_state = self.filtered_control_info # filter the apis prompt_message = self.action_prefill_agent.message_constructor( - "", - given_task,reference_steps, filter_control_state, - self.log_path_configs) + "", given_task, reference_steps, filter_control_state, self.log_path_configs + ) try: response_string, cost = self.action_prefill_agent.get_response( - prompt_message, "action_prefill", use_backup_engine=True, configs=configs) - response_json = self.action_prefill_agent.response_to_dict( - response_string) + prompt_message, + "action_prefill", + use_backup_engine=True, + configs=configs, + ) + response_json = self.action_prefill_agent.response_to_dict(response_string) new_task = response_json["new_task"] action_plans = response_json["actions_plan"] except Exception as e: self.status = "ERROR" error_msg = str(e) - self.log_execute_info( - prompt_message, {"ActionPrefillAgent": response_json}, error_msg) - + self.log_prefill_agent_info( + prompt_message, {"ActionPrefillAgent": response_json}, error_msg + ) + return None, None else: - self.log_execute_info( - prompt_message, {"ActionPrefillAgent": response_json}, error_msg) + self.log_prefill_agent_info( + prompt_message, {"ActionPrefillAgent": response_json}, error_msg + ) - return new_task, action_plans \ No newline at end of file + return new_task, action_plans diff --git a/instantiation/controller/module/filter_flow.py b/instantiation/controller/module/filter_flow.py new file mode 100644 index 00000000..6652da5c --- /dev/null +++ b/instantiation/controller/module/filter_flow.py @@ -0,0 +1,50 @@ +from instantiation.controller.agent.agent import FilterAgent +from instantiation.controller.config.config import Config + +configs = Config.get_instance().config_data + + +class FilterFlow: + """ + The class to refine the plan steps and prefill the file. + """ + + def __init__(self, app_name: str) -> None: + """ + Initialize the follow flow. + :param app_name: The name of the operated app. Example: "Word" + """ + + self.app_name = app_name + self.filter_agent = FilterAgent( + "filter", + app_name, + is_visual=True, + main_prompt=configs["FILTER_PROMPT"], + example_prompt="", + api_prompt=configs["API_PROMPT"], + ) + self.execute_step = 0 + + def get_filter_res(self, request: str) -> tuple[bool, str, str]: + """ + Call the PlanRefine Agent to select files + :param request: The request message + :return: The results from the filter agent + """ + + prompt_message = self.filter_agent.message_constructor(request, self.app_name) + try: + response_string, cost = self.filter_agent.get_response( + prompt_message, "filter", use_backup_engine=True, configs=configs + ) + response_json = self.filter_agent.response_to_dict(response_string) + judge = response_json["judge"] + thought = response_json["thought"] + type = response_json["type"] + return judge, thought, type + + except Exception as e: + self.status = "ERROR" + print(f"Error: {e}") + return None diff --git a/instantiation/ael/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py similarity index 52% rename from instantiation/ael/prompter/agent_prompter.py rename to instantiation/controller/prompter/agent_prompter.py index 483fa286..0262444e 100644 --- a/instantiation/ael/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -2,28 +2,38 @@ # Licensed under the MIT License. import json -import docx import os +from typing import Dict, List from ufo.prompter.basic import BasicPrompter -def custom_encoder(obj): - if isinstance(obj, docx.styles.style._TableStyle): - return obj.to_dict() - elif isinstance(obj, type): - return f"" - raise TypeError(f'Object of type {obj.__class__.__name__} ' - 'is not JSON serializable') - class FilterPrompter(BasicPrompter): + """ + Load the prompt for the FilterAgent. + """ + + def __init__( + self, + is_visual: bool, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + ): + """ + Initialize the FilterPrompter. + :param is_visual: The flag indicating whether the prompter is visual or not. + :param prompt_template: The prompt template. + :param example_prompt_template: The example prompt template. + :param api_prompt_template: The API prompt template. + """ - def __init__(self, is_visual: bool, prompt_template: str, example_prompt_template: str, api_prompt_template: str): super().__init__(is_visual, prompt_template, example_prompt_template) self.api_prompt_template = self.load_prompt_template( - api_prompt_template, is_visual) + api_prompt_template, is_visual + ) - def api_prompt_helper(self, apis: dict = {}, verbose: int = 1) -> str: + def api_prompt_helper(self, apis: Dict = {}, verbose: int = 1) -> str: """ Construct the prompt for APIs. :param apis: The APIs. @@ -33,39 +43,51 @@ def api_prompt_helper(self, apis: dict = {}, verbose: int = 1) -> str: # Construct the prompt for APIs if len(apis) == 0: - api_list = ["- The action type are limited to {actions}.".format( - actions=list(self.api_prompt_template.keys()))] + api_list = [ + "- The action type are limited to {actions}.".format( + actions=list(self.api_prompt_template.keys()) + ) + ] # Construct the prompt for each API for key in self.api_prompt_template.keys(): api = self.api_prompt_template[key] if verbose > 0: api_text = "{summary}\n{usage}".format( - summary=api["summary"], usage=api["usage"]) + summary=api["summary"], usage=api["usage"] + ) else: api_text = api["summary"] api_list.append(api_text) - api_prompt = self.retrived_documents_prompt_helper( - "", "", api_list) + api_prompt = self.retrived_documents_prompt_helper("", "", api_list) else: api_list = [ - "- The action type are limited to {actions}.".format(actions=list(apis.keys()))] + "- The action type are limited to {actions}.".format( + actions=list(apis.keys()) + ) + ] # Construct the prompt for each API for key in apis.keys(): api = apis[key] api_text = "{description}\n{example}".format( - description=api["description"], example=api["example"]) + description=api["description"], example=api["example"] + ) api_list.append(api_text) - api_prompt = self.retrived_documents_prompt_helper( - "", "", api_list) + api_prompt = self.retrived_documents_prompt_helper("", "", api_list) return api_prompt - def system_prompt_construction(self, additional_examples: list = [], app:str="") -> str: + def system_prompt_construction(self, app: str = "") -> str: + """ + Construct the prompt for the system. + :param app: The app name. + return: The prompt for the system. + """ + try: ans = self.prompt_template["system"] ans = ans.format(app=app) @@ -74,14 +96,15 @@ def system_prompt_construction(self, additional_examples: list = [], app:str="") print(e) def user_prompt_construction(self, request: str) -> str: - prompt = self.prompt_template["user"].format( - request=request - ) - + """ + Construct the prompt for the user. + :param request: The user request. + return: The prompt for the user. + """ + prompt = self.prompt_template["user"].format(request=request) return prompt - - def user_content_construction(self, request: str) -> list[dict]: + def user_content_construction(self, request: str) -> List[Dict]: """ Construct the prompt for LLMs. :param action_history: The action history. @@ -92,20 +115,19 @@ def user_content_construction(self, request: str) -> list[dict]: """ user_content = [] - + user_content.append( - { - "type": "text", - "text": self.user_prompt_construction( - request - ) - } - ) - + {"type": "text", "text": self.user_prompt_construction(request)} + ) return user_content - def examples_prompt_helper(self, header: str = "## Response Examples", separator: str = "Example", additional_examples: list[str] = []) -> str: + def examples_prompt_helper( + self, + header: str = "## Response Examples", + separator: str = "Example", + additional_examples: List[str] = [], + ) -> str: """ Construct the prompt for examples. :param examples: The examples. @@ -127,21 +149,44 @@ def examples_prompt_helper(self, header: str = "## Response Examples", separator for key in self.example_prompt_template.keys(): if key.startswith("example"): - example = template.format(request=self.example_prompt_template[key].get("Request"), response=json.dumps( - self.example_prompt_template[key].get("Response")), tip=self.example_prompt_template[key].get("Tips", "")) + example = template.format( + request=self.example_prompt_template[key].get("Request"), + response=json.dumps( + self.example_prompt_template[key].get("Response") + ), + tip=self.example_prompt_template[key].get("Tips", ""), + ) example_list.append(example) - example_list += [json.dumps(example) - for example in additional_examples] + example_list += [json.dumps(example) for example in additional_examples] return self.retrived_documents_prompt_helper(header, separator, example_list) class ActionPrefillPrompter(BasicPrompter): - def __init__(self, is_visual: bool, prompt_template: str, example_prompt_template: str, api_prompt_template: str): + """ + Load the prompt for the ActionPrefillAgent. + """ + + def __init__( + self, + is_visual: bool, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + ): + """ + Initialize the ActionPrefillPrompter. + :param is_visual: The flag indicating whether the prompter is visual or not. + :param prompt_template: The prompt template. + :param example_prompt_template: The example prompt template. + :param api_prompt_template: The API prompt template. + """ + super().__init__(is_visual, prompt_template, example_prompt_template) self.api_prompt_template = self.load_prompt_template( - api_prompt_template, is_visual) + api_prompt_template, is_visual + ) def api_prompt_helper(self, verbose: int = 1) -> str: """ @@ -152,36 +197,50 @@ def api_prompt_helper(self, verbose: int = 1) -> str: """ # Construct the prompt for APIs - api_list = ["- The action type are limited to {actions}.".format( - actions=list(self.api_prompt_template.keys()))] + api_list = [ + "- The action type are limited to {actions}.".format( + actions=list(self.api_prompt_template.keys()) + ) + ] # Construct the prompt for each API for key in self.api_prompt_template.keys(): api = self.api_prompt_template[key] if verbose > 0: api_text = "{summary}\n{usage}".format( - summary=api["summary"], usage=api["usage"]) + summary=api["summary"], usage=api["usage"] + ) else: api_text = api["summary"] api_list.append(api_text) - api_prompt = self.retrived_documents_prompt_helper( - "", "", api_list) - + api_prompt = self.retrived_documents_prompt_helper("", "", api_list) return api_prompt - def system_prompt_construction(self, additional_examples: list = []) -> str: - examples = self.examples_prompt_helper( - additional_examples=additional_examples) + def system_prompt_construction(self, additional_examples: List = []) -> str: + """ + Construct the prompt for the system. + :param additional_examples: The additional examples. + return: The prompt for the system. + """ + + examples = self.examples_prompt_helper(additional_examples=additional_examples) apis = self.api_prompt_helper(verbose=0) return self.prompt_template["system"].format(apis=apis, examples=examples) - def user_prompt_construction(self, - given_task: str, - reference_steps:list, - doc_control_state: dict) -> str: + def user_prompt_construction( + self, given_task: str, reference_steps: List, doc_control_state: Dict + ) -> str: + """ + Construct the prompt for the user. + :param given_task: The given task. + :param reference_steps: The reference steps. + :param doc_control_state: The document control state. + return: The prompt for the user. + """ + prompt = self.prompt_template["user"].format( given_task=given_task, reference_steps=json.dumps(reference_steps), @@ -189,7 +248,7 @@ def user_prompt_construction(self, ) return prompt - + def load_screenshots(self, log_path: str) -> str: """ Load the first and last screenshots from the log path. @@ -202,11 +261,12 @@ def load_screenshots(self, log_path: str) -> str: return init_image_url def user_content_construction( - self, given_task: str, - reference_steps:list, - doc_control_state: dict, - log_path: str - ) -> list[dict]: + self, + given_task: str, + reference_steps: List, + doc_control_state: Dict, + log_path: str, + ) -> List[Dict]: """ Construct the prompt for LLMs. :param action_history: The action history. @@ -219,26 +279,35 @@ def user_content_construction( user_content = [] if self.is_visual: screenshot = self.load_screenshots(log_path) - screenshot_text = "This is the screenshot of the current environment." + screenshot_text = """You are a action prefill agent, responsible to prefill the given task. + This is the screenshot of the current environment, please check it and give prefilled task accodingly.""" user_content.append({"type": "text", "text": screenshot_text}) user_content.append({"type": "image_url", "image_url": {"url": screenshot}}) - - user_content.append({ - "type": "text", - "text": self.user_prompt_construction(given_task, reference_steps, doc_control_state) - }) + user_content.append( + { + "type": "text", + "text": self.user_prompt_construction( + given_task, reference_steps, doc_control_state + ), + } + ) return user_content - def examples_prompt_helper(self, header: str = "## Response Examples", separator: str = "Example", additional_examples: list[str] = []) -> str: + def examples_prompt_helper( + self, + header: str = "## Response Examples", + separator: str = "Example", + additional_examples: List[str] = [], + ) -> str: """ Construct the prompt for examples. :param examples: The examples. :param header: The header of the prompt. :param separator: The separator of the prompt. - return: The prompt for examples. + return: The prompt for examples """ template = """ @@ -254,11 +323,15 @@ def examples_prompt_helper(self, header: str = "## Response Examples", separator for key in self.example_prompt_template.keys(): if key.startswith("example"): - example = template.format(request=self.example_prompt_template[key].get("Request"), response=json.dumps( - self.example_prompt_template[key].get("Response")), tip=self.example_prompt_template[key].get("Tips", "")) + example = template.format( + request=self.example_prompt_template[key].get("Request"), + response=json.dumps( + self.example_prompt_template[key].get("Response") + ), + tip=self.example_prompt_template[key].get("Tips", ""), + ) example_list.append(example) - example_list += [json.dumps(example) - for example in additional_examples] + example_list += [json.dumps(example) for example in additional_examples] - return self.retrived_documents_prompt_helper(header, separator, example_list) \ No newline at end of file + return self.retrived_documents_prompt_helper(header, separator, example_list) diff --git a/instantiation/tasks/action_prefill/bulleted.json b/instantiation/tasks/action_prefill/bulleted.json new file mode 100644 index 00000000..237b68eb --- /dev/null +++ b/instantiation/tasks/action_prefill/bulleted.json @@ -0,0 +1,9 @@ +{ + "app": "word", + "unique_id": "5", + "task": "Turning lines of text into a bulleted list in Word", + "refined_steps": [ + "1. Place the cursor at the beginning of the line of text you want to turn into a bulleted list", + "2. Click the Bullets button in the Paragraph group on the Home tab and choose a bullet style" + ] +} \ No newline at end of file diff --git a/instantiation/tasks/action_prefill/delete.json b/instantiation/tasks/action_prefill/delete.json new file mode 100644 index 00000000..73f29eb8 --- /dev/null +++ b/instantiation/tasks/action_prefill/delete.json @@ -0,0 +1,9 @@ +{ + "app": "word", + "unique_id": "3", + "task": "Deleting undwanted recovered Word files", + "refined_steps": [ + "1. Open the Word document containing the items you wish to delete", + "2. Select and delete the selected text" + ] +} \ No newline at end of file diff --git a/instantiation/tasks/action_prefill/draw.json b/instantiation/tasks/action_prefill/draw.json new file mode 100644 index 00000000..2401260b --- /dev/null +++ b/instantiation/tasks/action_prefill/draw.json @@ -0,0 +1,10 @@ +{ + "app": "word", + "unique_id": "1", + "task": "Draw or write your signature in the Word desktop app", + "refined_steps": [ + "1. Select tool", + "2. Draw or write a signature in the Word desktop app", + "3. Use your mouse, pen, or touch screen to draw or write your signature" + ] +} \ No newline at end of file diff --git a/instantiation/tasks/action_prefill/macro.json b/instantiation/tasks/action_prefill/macro.json new file mode 100644 index 00000000..a9f18a53 --- /dev/null +++ b/instantiation/tasks/action_prefill/macro.json @@ -0,0 +1,9 @@ +{ + "app": "word", + "unique_id": "2", + "task": "Run a macro in Word", + "refined_steps": [ + "1. In the Macrio name box that appears, type the name of the macro you want to run", + "2. Click the Run button to execute the selected macro" + ] +} \ No newline at end of file diff --git a/instantiation/tasks/action_prefill/totate.json b/instantiation/tasks/action_prefill/totate.json new file mode 100644 index 00000000..2caa5f0b --- /dev/null +++ b/instantiation/tasks/action_prefill/totate.json @@ -0,0 +1,10 @@ +{ + "app": "word", + "unique_id": "4", + "task": "Rotate text in a SmartArt graphic in Word", + "refined_steps": [ + "1. Click the SmartArt graphic to select it", + "2. To rotate the text, click the Rotate button in the Arrange group on the Format tab", + "3. To rotate the text, select the desired rotation option from the drop-down menu" + ] +} \ No newline at end of file From 01077338881a4e9b0f2d51e711c6be195798b4d9 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Wed, 16 Oct 2024 21:21:12 +0800 Subject: [PATCH 08/30] modify the instantiation section according to the review --- SUPPORT.md | 16 - instantiation/.gitignore | 7 +- instantiation/README.md | 127 ++--- instantiation/__main__.py | 7 + instantiation/action_prefill.py | 453 ------------------ .../{controller => }/config/config.py | 6 +- instantiation/config/config.yaml.template | 43 ++ instantiation/config/config_dev.yaml | 30 ++ instantiation/controller/agent/agent.py | 28 +- instantiation/controller/env/env_manager.py | 99 ++++ instantiation/controller/env/state_manager.py | 57 --- .../controller/module/action_prefill_flow.py | 187 -------- .../controller/module/filter_flow.py | 50 -- .../controller/prompter/agent_prompter.py | 6 +- .../workflow/choose_template_flow.py | 192 ++++++++ .../controller/workflow/filter_flow.py | 153 ++++++ .../controller/workflow/prefill_flow.py | 230 +++++++++ instantiation/instantiation.py | 186 +++++++ .../{action_prefill => prefill}/bulleted.json | 0 .../{action_prefill => prefill}/delete.json | 0 .../{action_prefill => prefill}/draw.json | 0 .../{action_prefill => prefill}/macro.json | 0 .../{action_prefill => prefill}/totate.json | 0 ufo/llm/llm_call.py | 4 +- 24 files changed, 1034 insertions(+), 847 deletions(-) delete mode 100644 SUPPORT.md create mode 100644 instantiation/__main__.py delete mode 100644 instantiation/action_prefill.py rename instantiation/{controller => }/config/config.py (78%) create mode 100644 instantiation/config/config.yaml.template create mode 100644 instantiation/config/config_dev.yaml create mode 100644 instantiation/controller/env/env_manager.py delete mode 100644 instantiation/controller/env/state_manager.py delete mode 100644 instantiation/controller/module/action_prefill_flow.py delete mode 100644 instantiation/controller/module/filter_flow.py create mode 100644 instantiation/controller/workflow/choose_template_flow.py create mode 100644 instantiation/controller/workflow/filter_flow.py create mode 100644 instantiation/controller/workflow/prefill_flow.py create mode 100644 instantiation/instantiation.py rename instantiation/tasks/{action_prefill => prefill}/bulleted.json (100%) rename instantiation/tasks/{action_prefill => prefill}/delete.json (100%) rename instantiation/tasks/{action_prefill => prefill}/draw.json (100%) rename instantiation/tasks/{action_prefill => prefill}/macro.json (100%) rename instantiation/tasks/{action_prefill => prefill}/totate.json (100%) diff --git a/SUPPORT.md b/SUPPORT.md deleted file mode 100644 index 7e722035..00000000 --- a/SUPPORT.md +++ /dev/null @@ -1,16 +0,0 @@ -# Support - -## How to file issues and get help - -This project uses GitHub Issues to track bugs and feature requests. Please search the existing -issues before filing new issues to avoid duplicates. For new issues, file your bug or -feature request as a new Issue. - -You may use [GitHub Issues](https://github.com/microsoft/UFO/issues) to raise questions, bug reports, and feature requests. - -For help and questions about using this project, please please contact [ufo-agent@microsoft.com](mailto:ufo-agent@microsoft.com). - - -## Microsoft Support Policy - -Support for this **PROJECT or PRODUCT** is limited to the resources listed above. diff --git a/instantiation/.gitignore b/instantiation/.gitignore index b76f122c..c67e0167 100644 --- a/instantiation/.gitignore +++ b/instantiation/.gitignore @@ -2,10 +2,9 @@ cache/ controls_cache/ tasks/* -!tasks/action_prefill +!tasks/prefill templates/word/* -prefill_logs/* +logs/* controller/utils/ -controller/config/* -!controller/config/config.py +config/config.yaml controller/prompts/* \ No newline at end of file diff --git a/instantiation/README.md b/instantiation/README.md index dab695a7..021d0eb0 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -4,106 +4,120 @@ By using this process, we can obtain clearer and more specific instructions, making them more suitable for the execution of the UFO. +## How to Use -## How to use +#### 1. Install Packages -#### 1. Install packages +You should install the necessary packages in the UFO root folder: -You should install relative packages in the UFO root folder: - -```python +```bash pip install -r requirements.txt ``` -#### 2. Prepare files +#### 2. Configure the LLMs + +Before using the instantiation section, you need to provide your LLM configurations in `config.yaml` and `config_dev.yaml` located in the `instantiation/config` folder. + +`config_dev.yaml` specifies the paths of relevant files and contains default settings. + +`config.yaml` stores the agent information. You should copy the `config.yaml.template` file and fill it out according to the provided hints. + +You will configure the prefill agent and the filter agent individually. The prefill agent is used to prepare the task, while the filter agent evaluates the quality of the prefilled task. You can choose different LLMs for each. + +**BE CAREFUL!** If you are using GitHub or other open-source tools, do not expose your `config.yaml` online, as it contains your private keys. -There are files need preparing before running the task. +Once you have filled out the template, rename it to `config.yaml` to complete the LLM configuration. -##### 2.1. Tasks as JSON +#### 3. Prepare Files -The tasks that need to be instantiated should exist as a folder of JSON files, with the default folder path set to `instantiation/tasks`. This path can be changed in the `instantiation/controller/config/config.yaml` file, or you can use command in terminal, which is claimed in **3. Start running**. For example, a task stored in `instantiation/tasks/action_prefill/` may look like this: +Certain files need to be prepared before running the task. + +##### 3.1. Tasks as JSON + +The tasks that need to be instantiated should be organized in a folder of JSON files, with the default folder path set to `instantiation/tasks`. This path can be changed in the `instantiation/config/config.yaml` file, or you can specify it in the terminal, as mentioned in **3. Start Running**. For example, a task stored in `instantiation/tasks/prefill/` may look like this: ```json { // The app you want to use "app": "word", - // The unique id to distinguish different task + // A unique ID to distinguish different tasks "unique_id": "1", // The task and steps to be instantiated - "task": "Type in hello and set font type as Arial", + "task": "Type 'hello' and set the font type to Arial", "refined_steps": [ - "type in 'hello'", + "Type 'hello'", "Set the font to Arial" ] } ``` -##### 2.2. Templates and the description +##### 3.2. Templates and Descriptions -You should place a app file as the reference for the instantiation. You should place them in the folder named by the app. +You should place an app file as a reference for instantiation in a folder named after the app. -For example, if you have `template1.docx` for Word, it can be existed in `instantiation/templates/word/template1.docx`. +For example, if you have `template1.docx` for Word, it should be located at `instantiation/templates/word/template1.docx`. -What's more, for each app folder, a `instantiation/templates/word/description.json` file should be contained, and you can describe each template files in details. It may look like: +Additionally, for each app folder, there should be a `description.json` file located at `instantiation/templates/word/description.json`, which describes each template file in detail. It may look like this: ```json { - "template1.docx":"A doc with a rectangle shape", - "template2.docx":"A doc with a line of text", - "template3.docx":"A doc with a chart" + "template1.docx": "A document with a rectangle shape", + "template2.docx": "A document with a line of text", + "template3.docx": "A document with a chart" } ``` -##### 2.3. Final structure +If a `description.json` file is not present, one template file will be selected at random. -Check the mentioned files: +##### 3.3. Final Structure + +Ensure the following files are in place: * [X] JSON files to be instantiated -* [X] Templates as references for instantiations +* [X] Templates as references for instantiation * [X] Description file in JSON format -Then, the structure of files can be: +The structure of the files can be: ```bash instantiation/ | ├── tasks/ │ ├── action_prefill/ -│ | ├── task1.json -│ | ├── task2.json -| | └── task3.json -| └── ... +│ │ ├── task1.json +│ │ ├── task2.json +│ │ └── task3.json +│ └── ... | ├── templates/ │ ├── word/ -│ | ├── template1.docx -│ | ├── template2.docx -| | ├── template3.docx -| | └── description.json -| └── ... -└──... +│ │ ├── template1.docx +│ │ ├── template2.docx +│ │ ├── template3.docx +│ │ └── description.json +│ └── ... +└── ... ``` +#### 4. Start Running -#### 3. Start running - -Run the `instantiation/action_prefill.py` file. You can do it by typing the following command in the terminal: +Run the `instantiation/action_prefill.py` file in module mode. You can do this by typing the following command in the terminal: -``` -python instantiation/action_prefill.py +```bash +python -m instantiation ``` -You can use `--task` to set the task folder you want to use, the default is `action_prefill`: +You can use `--task` to specify the task folder you want to use; the default is `action_prefill`: ```bash -python instantiation/action_prefill.py --task your_task_folder_name +python -m instantiation --task your_task_folder_name ``` -After the process is completed, a new folder will be created alongside the original one, named `action_prefill_instantiated`, containing the instantiated task, which will look like: +After the process is completed, a new folder named `prefill_instantiated` will be created alongside the original one. This folder will contain the instantiated task, which will look like: ```json { - // The unique id to distinguish different task + // A unique ID to distinguish different tasks "unique_id": "1", // The chosen template path "instantial_template_path": "cached template file path", @@ -111,7 +125,7 @@ After the process is completed, a new folder will be created alongside the origi "instantiated_request": "Type 'hello' and set the font type to Arial in the Word document.", "instantiated_plan": [ { - "step 1": "select the target text 'text to edit'", + "step 1": "Select the target text 'text to edit'", "controlLabel": "", "controlText": "", "function": "select_text", @@ -120,7 +134,7 @@ After the process is completed, a new folder will be created alongside the origi } }, { - "step 2": "type in 'hello'", + "step 2": "Type 'hello'", "controlLabel": "101", "controlText": "Edit", "function": "type_keys", @@ -129,7 +143,7 @@ After the process is completed, a new folder will be created alongside the origi } }, { - "step 3": "select the typed text 'hello'", + "step 3": "Select the typed text 'hello'", "controlLabel": "", "controlText": "", "function": "select_text", @@ -138,7 +152,7 @@ After the process is completed, a new folder will be created alongside the origi } }, { - "step 4": "click the font dropdown", + "step 4": "Click the font dropdown", "controlLabel": "", "controlText": "Consolas", "function": "click_input", @@ -148,7 +162,7 @@ After the process is completed, a new folder will be created alongside the origi } }, { - "step 5": "set the font to Arial", + "step 5": "Set the font to Arial", "controlLabel": "", "controlText": "Arial", "function": "click_input", @@ -159,12 +173,11 @@ After the process is completed, a new folder will be created alongside the origi } ], // The comment for the instantiated task - "request_comment": "The task involves typing a specific string 'hello' and setting the font type to Arial, which can be executed locally within Word." + "request_comment": "The task involves typing the specific string 'hello' and setting the font type to Arial, which can be executed locally within Word." } ``` -And there will also be `action_prefill_templates` folder, which stores the copied chosen templates for each tasks. - +Additionally, a `prefill_templates` folder will be created, which stores the copied chosen templates for each task. ## Workflow @@ -174,18 +187,18 @@ There are three key steps in the instantiation process: 2. Prefill the task using the current screenshot. 3. Filter the established task. -##### 1. Choose template file +##### 1. Choose Template File -Templates for your app must be defined and described in `instantiation/templates/app`. For example, if you want to instantiate tasks for the Word application, place the relevant `.docx` files in `instantiation/templates/word`, along with a `description.json` file. +Templates for your app must be defined and described in `instantiation/templates/app`. For instance, if you want to instantiate tasks for the Word application, place the relevant `.docx` files in `instantiation/templates/word`, along with a `description.json` file. The appropriate template will be selected based on how well its description matches the instruction. -##### 2. Prefill the task +##### 2. Prefill the Task -After selecting the template file, it will be opened, and a snapshot will be taken. If the template file is currently in use, errors may occur. +After selecting the template file, it will be opened, and a screenshot will be taken. If the template file is currently in use, errors may occur. -The screenshot will be sent to the action prefill agent, which will provide a modified task in return. +The screenshot will be sent to the action prefill agent, which will return a modified task. -##### 3. Filter task +##### 3. Filter Task -The completed task will be evaluated by a filter agent, which will assess it and return feedback. If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass`; otherwise, it will follow the same process for poor instances. +The completed task will be evaluated by a filter agent, which will assess it and provide feedback. If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass`; otherwise, it will follow the same process for poor instances. \ No newline at end of file diff --git a/instantiation/__main__.py b/instantiation/__main__.py new file mode 100644 index 00000000..b0f9849d --- /dev/null +++ b/instantiation/__main__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +from instantiation import instantiation + +if __name__ == "__main__": + # Execute the main script + instantiation.main() diff --git a/instantiation/action_prefill.py b/instantiation/action_prefill.py deleted file mode 100644 index 0444b760..00000000 --- a/instantiation/action_prefill.py +++ /dev/null @@ -1,453 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import argparse -import glob -import json -import os -import sys -from abc import ABC -from datetime import datetime -from enum import Enum -from typing import Dict - -from langchain_community.vectorstores import FAISS - - -class AppEnum(Enum): - """ - Define the apps can be used in the instantiation. - """ - - WORD = 1, "Word", ".docx", "winword" - EXCEL = 2, "Excel", ".xlsx", "excel" - POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" - - def __init__(self, id, description, file_extension, win_app): - """ - :param id: The unique id of the app. - :param description: The description of the app. Example: Word, Excel, PowerPoint. - :param file_extension: The file extension of the app. Example: .docx, .xlsx, .pptx. - :param win_app: The windows app name of the app. Example: winword, excel, powerpnt. - """ - - self.id = id - self.description = description - self.file_extension = file_extension - self.win_app = win_app - # The root name of the app to be used in opening and closing app window. - self.app_root_name = win_app.upper() + ".EXE" - - -class TaskObject(ABC): - """ - Abstract class for the task object. - All the task objects should be extended from this class. - """ - - def __init__(self): - pass - - -class TaskJsonObject(TaskObject): - """ - The task object from the json file. - """ - - def __init__(self, json_path: str) -> None: - """ - Initialize the task object from the json file. - :param json_path: The json file path. - """ - - # Load the json file and get the app object. - task_json_file = json.load(open(json_path, "r")) - self.app_object = self.get_app_from_json(task_json_file) - for key, value in task_json_file.items(): - setattr(self, key.lower().replace(" ", "_"), value) - - # The fields to be saved in the json file. - self.json_fields = [ - "unique_id", - "instantiated_request", - "instantiated_plan", - "instantial_template_path", - "request_comment", - ] - - def get_app_from_json(self, task_json_file: str) -> AppEnum: - """ - Generate an app object by traversing AppEnum based on the app specified in the JSON. - :param task_json_file: The JSON file of the task. - :return: The app object. - """ - - for app in AppEnum: - app_name = app.description.lower() - json_app_name = task_json_file["app"].lower() - if app_name == json_app_name: - return app - raise ValueError("Not a correct App") - - def to_json(self) -> dict: - """ - Convert the object to a JSON object. - :return: The JSON object. - """ - - json_data = {} - for key, value in self.__dict__.items(): - if key in self.json_fields: - if hasattr(self, key): - json_data[key] = value - return json_data - - def set_attributes(self, **kwargs) -> None: - """ - Add all input fields as attributes. - :param kwargs: The fields to be added. - """ - - for key, value in kwargs.items(): - setattr(self, key, value) - - -class TaskPathObject(TaskObject): - """ - The path object according to the task file path. - """ - - def __init__(self, task_file: str): - """ - Initialize the task object from the task file path. - :param task_file: The task file path. - :return: The created path object. - """ - - self.task_file = task_file - # The folder name of the task, specific for one process. Example: action_prefill. - self.task_file_dir_name = os.path.dirname(os.path.dirname(task_file)) - # The base name of the task file. Example: task_1.json. - self.task_file_base_name = os.path.basename(task_file) - # The task name of the task file without extension. Example: task_1. - self.task = self.task_file_base_name.split(".")[0] - - -class ObjectMethodService: - """ - The object method service. - Provide methods related to the object, which is extended from TaskObject. - """ - - def __init__( - self, - task_dir_name: str, - task_json_object: TaskObject, - task_path_object: TaskObject, - ): - """ - :param task_dir_name: Folder name of the task, specific for one process. - :param task_json_object: Json object, which is the json file of the task. - :param task_path_object: Path object, which is related to the path of the task. - """ - - self.task_dir_name = task_dir_name - self.task_json_object = task_json_object - self.task_path_object = task_path_object - self.filter_flow_app = dict() - - from instantiation.controller.env.state_manager import WindowsAppEnv - from instantiation.controller.module.action_prefill_flow import \ - ActionPrefillFlow - - self.app_env = WindowsAppEnv(task_json_object.app_object) - self.action_prefill_flow = ActionPrefillFlow( - task_json_object.app_object.description.lower(), - task_path_object.task, - self.app_env, - ) - - def filter_task( - self, app_name: str, request_to_filter: str - ) -> tuple[bool, str, str]: - """ - Filter the task by the filter flow. - :param app_name: The name of the app. Example: "Word". - :param request_to_filt: The request to be filtered. - :return: The evaluation quality \ comment \ type of the task. - """ - - if app_name not in self.filter_flow_app: - from controller.module.filter_flow import FilterFlow - - filter_flow = FilterFlow(app_name) - self.filter_flow_app[app_name] = filter_flow - else: - filter_flow = self.filter_flow_app[app_name] - try: - is_good, comment, type = filter_flow.get_filter_res(request_to_filter) - return is_good, comment, type - except Exception as e: - print(f"Error! ObjectMethodService#request_to_filter: {e}") - - def create_cache_file( - self, copy_from_path: str, copy_to_folder_path: str, file_name: str = None - ) -> str: - """ - According to the original path, create a cache file. - :param copy_from_path: The original path of the file. - :param copy_to_folder_path: The path of the cache file. - :param file_name: The name of the task file. - :return: The template path string as a new created file. - """ - - # Create the folder if not exists. - if not os.path.exists(copy_to_folder_path): - os.makedirs(copy_to_folder_path) - time_start = datetime.now() - current_path = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) - template_extension = self.task_json_object.app_object.file_extension - if not file_name: - cached_template_path = os.path.join( - current_path, - copy_to_folder_path, - time_start.strftime("%Y-%m-%d-%H-%M-%S") + template_extension, - ) - else: - cached_template_path = os.path.join( - current_path, copy_to_folder_path, file_name + template_extension - ) - with open(copy_from_path, "rb") as f: - ori_content = f.read() - with open(cached_template_path, "wb") as f: - f.write(ori_content) - return cached_template_path - - def get_chosen_file_path(self) -> str: - """ - Choose the most relative template file. - :return: The most relative template file path string. - """ - - # get the description of the templates - templates_description_path = os.path.join( - configs["TEMPLATE_PATH"], - self.task_json_object.app_object.description.lower(), - "description.json", - ) - templates_file_description = json.load(open(templates_description_path, "r")) - # get the chosen file path - chosen_file_path = self.get_target_template_file( - self.task_json_object.task, templates_file_description - ) - return chosen_file_path - - def chose_template_and_copy(self) -> str: - """ - Choose the template and copy it to the cache folder. - """ - - # Get the chosen template file path. - chosen_template_file_path = self.get_chosen_file_path() - chosen_template_full_path = os.path.join( - configs["TEMPLATE_PATH"], - self.task_json_object.app_object.description.lower(), - chosen_template_file_path, - ) - - # Get the target template folder path. - target_template_folder_path = os.path.join( - configs["TASKS_HUB"], self.task_dir_name + "_templates" - ) - - # Copy the template to the cache folder. - template_cached_path = self.create_cache_file( - chosen_template_full_path, - target_template_folder_path, - self.task_path_object.task, - ) - self.task_json_object.instantial_template_path = template_cached_path - - return template_cached_path - - def get_target_template_file( - self, given_task: str, doc_files_description: Dict[str, str] - ): - """ - Get the target file based on the semantic similarity of given task and the template file decription. - :param given_task: The given task. - :param doc_files_description: The description of the template files. - :return: The target file path. - """ - - candidates = [ - doc_file_description - for doc, doc_file_description in doc_files_description.items() - ] - file_doc_descriptions = { - doc_file_description: doc - for doc, doc_file_description in doc_files_description.items() - } - # use FAISS to get the top k control items texts - db = FAISS.from_texts(candidates, self.action_prefill_flow.embedding_model) - doc_descriptions = db.similarity_search(given_task, k=1) - doc_description = doc_descriptions[0].page_content - doc = file_doc_descriptions[doc_description] - return doc - - def get_instance_folder_path(self) -> tuple[str, str]: - """ - Get the new folder path for the passed / failed instances without creating them. - :return: The folder path string where passed / failed instances should be in. - """ - - new_folder_path = os.path.join( - configs["TASKS_HUB"], self.task_dir_name + "_instantiated" - ) - new_folder_pass_path = os.path.join(new_folder_path, "instances_pass") - new_folder_fail_path = os.path.join(new_folder_path, "instances_fail") - return new_folder_pass_path, new_folder_fail_path - - def get_task_filtered(self) -> None: - """ - Evaluate the task by the filter. - :param task_to_filter: The task to be evaluated. - :return: The evaluation quality \ comment \ type of the task. - """ - - request_quality_is_good, request_comment, request_type = self.filter_task( - self.task_json_object.app_object.description.lower(), - self.task_json_object.instantiated_request, - ) - self.task_json_object.set_attributes( - request_quality_is_good=request_quality_is_good, - request_comment=request_comment, - request_type=request_type, - ) - - def get_task_instantiated(self) -> None: - """ - Get the instantiated result and evaluate the task. - Save the task to the pass / fail folder. - """ - - # Get the instantiated result. - template_cached_path = self.chose_template_and_copy() - self.app_env.start(template_cached_path) - try: - instantiated_request, instantiated_plan = ( - self.action_prefill_flow.get_prefill_actions( - self.task_json_object.task, - self.task_json_object.refined_steps, - template_cached_path, - ) - ) - except Exception as e: - print(f"Error! get_instantiated_result: {e}") - finally: - self.app_env.close() - - self.task_json_object.set_attributes( - instantiated_request=instantiated_request, - instantiated_plan=instantiated_plan, - ) - - self.action_prefill_flow.prefill_logger.info( - f"Task {self.task_path_object.task_file_base_name} has been processed successfully." - ) - - def save_instantiated_task(self) -> None: - """ - Save the instantiated task to the pass / fail folder. - """ - - # Get the folder path for classified instances. - new_folder_pass_path, new_folder_fail_path = self.get_instance_folder_path() - # Generate the json object of the task. - task_json = self.task_json_object.to_json() - - # Save the task to the pass / fail folder. - if self.task_json_object.request_quality_is_good: - new_task_path = os.path.join( - new_folder_pass_path, self.task_path_object.task_file_base_name - ) - else: - new_task_path = os.path.join( - new_folder_fail_path, self.task_path_object.task_file_base_name - ) - os.makedirs(os.path.dirname(new_task_path), exist_ok=True) - open(new_task_path, "w").write(json.dumps(task_json)) - - -class ServiceController: - """ - Key process to instantialize the task. - Control the process of the task. - """ - - def execute(task_service: ObjectMethodService) -> None: - """ - Execute the process for one task. - :param task_service: The task service object. - The execution includes getting the instantiated result, evaluating the task and saving the instantiated task. - """ - - task_service.get_task_instantiated() - task_service.get_task_filtered() - task_service.save_instantiated_task() - - -def main(): - """ - The main function to process the tasks. - """ - - # Add the project root to the system path. - current_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.abspath(os.path.join(current_dir, "..")) - - if project_root not in sys.path: - sys.path.append(project_root) - - # Set the environment variable. - os.environ["RUN_CONFIGS"] = "false" - - # Parse the arguments. - args = argparse.ArgumentParser() - args.add_argument( - "--task", help="The name of the task.", type=str, default="action_prefill" - ) - parsed_args = args.parse_args() - - # Load the configs. - from instantiation.controller.config.config import Config - - config_path = ( - os.path.normpath(os.path.join(current_dir, "controller/config/")) + "\\" - ) - global configs - configs = Config(config_path).get_instance().config_data - - # Get and process all the task files. - task_dir_name = parsed_args.task.lower() - all_task_file_path: str = os.path.join(configs["TASKS_HUB"], task_dir_name, "*") - all_task_files = glob.glob(all_task_file_path) - - for index, task_file in enumerate(all_task_files, start=1): - print(f"Task starts: {index} / {len(all_task_files)}") - try: - task_json_object = TaskJsonObject(task_file) - task_path_object = TaskPathObject(task_file) - - task_service = ObjectMethodService( - task_dir_name, task_json_object, task_path_object - ) - ServiceController.execute(task_service) - except Exception as e: - print(f"Error in task {index} with file {task_file}: {e}") - - print("All tasks have been processed.") - - -if __name__ == "__main__": - main() diff --git a/instantiation/controller/config/config.py b/instantiation/config/config.py similarity index 78% rename from instantiation/controller/config/config.py rename to instantiation/config/config.py index be55111d..3dcdfa24 100644 --- a/instantiation/controller/config/config.py +++ b/instantiation/config/config.py @@ -7,7 +7,7 @@ class Config(Config): _instance = None - def __init__(self, config_path="instantiation/controller/config/"): + def __init__(self, config_path="instantiation/config/"): self.config_data = self.load_config(config_path) @staticmethod @@ -22,7 +22,7 @@ def get_instance(): return Config._instance def optimize_configs(self, configs): - self.update_api_base(configs, "ACTION_PREFILL_AGENT") + self.update_api_base(configs, "PREFILL_AGENT") self.update_api_base(configs, "FILTER_AGENT") - return configs \ No newline at end of file + return configs diff --git a/instantiation/config/config.yaml.template b/instantiation/config/config.yaml.template new file mode 100644 index 00000000..ecbac7e1 --- /dev/null +++ b/instantiation/config/config.yaml.template @@ -0,0 +1,43 @@ +# You will configure for the prefill agent and filter agent individualy. +# Prefill agent is used to prefill the task. +# Filter agent is to evaluate the prefill quality. + +PREFILL_AGENT: { + VISUAL_MODE: True, # Whether to use the visual mode + + API_TYPE: "azure_ad" , # The API type, "openai" for the OpenAI API, "aoai" for the AOAI API, 'azure_ad' for the ad authority of the AOAI API. + API_BASE: "https://cloudgpt-openai.azure-api.net/", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. As for the AAD, it should be your endpoints. + API_KEY: "YOUR_API_KEY", # The OpenAI API key + API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default + API_MODEL: "gpt-4o-20240513", # The only OpenAI model by now that accepts visual input + + ###For the AOAI + API_DEPLOYMENT_ID: "gpt-4-0125-preview", # The deployment id for the AOAI API + ### For Azure_AD + AAD_TENANT_ID: "YOUR_AAD_ID", # Set the value to your tenant id for the llm model + AAD_API_SCOPE: "openai", # Set the value to your scope for the llm model + AAD_API_SCOPE_BASE: "YOUR_AAD_API_SCOPE_BASE" # Set the value to your scope base for the llm model, whose format is API://YOUR_SCOPE_BASE, and the only need is the YOUR_SCOPE_BASE +} + +FILTER_AGENT: { + VISUAL_MODE: False, # Whether to use the visual mode + + API_TYPE: "azure_ad" , # The API type, "openai" for the OpenAI API, "aoai" for the Azure OpenAI. + API_BASE: "https://cloudgpt-openai.azure-api.net/", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. As for the aoai, it should be https://{your-resource-name}.openai.azure.com + API_KEY: "YOUR_API_KEY", # The aoai API key + API_VERSION: "2024-04-01-preview", # "2024-02-15-preview" by default + API_MODEL: "gpt-4o-20240513", # The only OpenAI model by now that accepts visual input + API_DEPLOYMENT_ID: "gpt-4o-20240513-preview", # The deployment id for the AOAI API + + ### For Azure_AD + AAD_TENANT_ID: "YOUR_AAD_ID", + AAD_API_SCOPE: "openai", #"openai" + AAD_API_SCOPE_BASE: "YOUR_AAD_API_SCOPE_BASE", #API://YOUR_SCOPE_BASE +} + +# For parameters +MAX_TOKENS: 2000 # The max token limit for the response completion +MAX_RETRY: 3 # The max retry limit for the response completion +TEMPERATURE: 0.0 # The temperature of the model: the lower the value, the more consistent the output of the model +TOP_P: 0.0 # The top_p of the model: the lower the value, the more conservative the output of the model +TIMEOUT: 60 # The call timeout(s), default is 10 minss \ No newline at end of file diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml new file mode 100644 index 00000000..231a06d8 --- /dev/null +++ b/instantiation/config/config_dev.yaml @@ -0,0 +1,30 @@ +version: 0.1 + +AOAI_DEPLOYMENT: "gpt-4-visual-preview" # Your AOAI deployment if apply +API_VERSION: "2024-02-15-preview" # "2024-02-15-preview" by default. +OPENAI_API_MODEL: "gpt-4-0125-preview" # The only OpenAI model by now that accepts visual input + +CONTROL_BACKEND: "uia" # The backend for control action +CONTROL_LIST: ["Button", "Edit", "TabItem", "Document", "ListItem", "MenuItem", "ScrollBar", "TreeItem", "Hyperlink", "ComboBox", "RadioButton", "DataItem", "Spinner"] +PRINT_LOG: False # Whether to print the log +LOG_LEVEL: "DEBUG" # The log level + +PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill +FILTER_PROMPT: "instantiation/controller/prompts/{mode}/filter.yaml" # The prompt for the filter +PREFILL_EXAMPLE_PROMPT: "instantiation/controller/prompts/{mode}/prefill_example.yaml" # The prompt for the action prefill example +API_PROMPT: "instantiation/controller/prompts/{mode}/api.yaml" # The prompt for the API + +# Exploration Configuration +TASKS_HUB: "instantiation/tasks" # The tasks hub for the exploration +TEMPLATE_PATH: "instantiation/templates" # The template path for the exploration + +# For control filtering +CONTROL_FILTER_TYPE: [] # The list of control filter type, support 'TEXT', 'SEMANTIC', 'ICON' +CONTROL_FILTER_MODEL_SEMANTIC_NAME: "all-MiniLM-L6-v2" # The control filter model name of semantic similarity +CONTROL_EMBEDDING_CACHE_PATH: "instantiation/cache/" # The cache path for the control filter +CONTROL_FILTER_TOP_K_PLAN: 2 # The control filter effect on top k plans from UFO, default is 2 + +# log path +LOG_PATH: "instantiation/logs/{task}" +PREFILL_LOG_PATH: "instantiation/logs/{task}/prefill/" +FILTER_LOG_PATH: "instantiation/logs/{task}/filter/" \ No newline at end of file diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index 7f6977d5..ad698ff1 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -3,8 +3,8 @@ from typing import Dict, List -from instantiation.controller.prompter.agent_prompter import ( - ActionPrefillPrompter, FilterPrompter) +from instantiation.controller.prompter.agent_prompter import (FilterPrompter, + PrefillPrompter) from ufo.agents.agent.basic import BasicAgent @@ -76,7 +76,7 @@ def process_comfirmation(self) -> None: pass -class ActionPrefillAgent(BasicAgent): +class PrefillAgent(BasicAgent): """ The Agent for task instantialization and action sequence generation. """ @@ -91,7 +91,7 @@ def __init__( api_prompt: str, ): """ - Initialize the ActionPrefillAgent. + Initialize the PrefillAgent. :param name: The name of the agent. :param process_name: The name of the process. :param is_visual: The flag indicating whether the agent is visual or not. @@ -104,7 +104,7 @@ def __init__( self._complete = False self._name = name self._status = None - self.prompter: ActionPrefillPrompter = self.get_prompter( + self.prompter: PrefillPrompter = self.get_prompter( is_visual, main_prompt, example_prompt, api_prompt ) self._process_name = process_name @@ -119,7 +119,7 @@ def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> st :return: The prompt. """ - return ActionPrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) + return PrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) def message_constructor( self, @@ -130,7 +130,7 @@ def message_constructor( log_path: str, ) -> List[str]: """ - Construct the prompt message for the ActionPrefillAgent. + Construct the prompt message for the PrefillAgent. :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. :param given_task: The given task. :param reference_steps: The reference steps. @@ -139,17 +139,15 @@ def message_constructor( :return: The prompt message. """ - action_prefill_agent_prompt_system_message = ( - self.prompter.system_prompt_construction(dynamic_examples) + prefill_agent_prompt_system_message = self.prompter.system_prompt_construction( + dynamic_examples ) - action_prefill_agent_prompt_user_message = ( - self.prompter.user_content_construction( - given_task, reference_steps, doc_control_state, log_path - ) + prefill_agent_prompt_user_message = self.prompter.user_content_construction( + given_task, reference_steps, doc_control_state, log_path ) appagent_prompt_message = self.prompter.prompt_construction( - action_prefill_agent_prompt_system_message, - action_prefill_agent_prompt_user_message, + prefill_agent_prompt_system_message, + prefill_agent_prompt_user_message, ) return appagent_prompt_message diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py new file mode 100644 index 00000000..cf2bf86d --- /dev/null +++ b/instantiation/controller/env/env_manager.py @@ -0,0 +1,99 @@ +import time + +from instantiation.config.config import Config +from ufo.automator.puppeteer import ReceiverManager + +from PIL import ImageGrab +from pywinauto import Desktop + +configs = Config.get_instance().config_data +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] + + +class WindowsAppEnv: + """ + The Windows App Environment. + """ + + def __init__(self, app_object: object) -> None: + """ + Initialize the Windows App Environment. + :param app_object: The app object containing the app information. + """ + super().__init__() + + self.app_window = None + self.app_root_name = app_object.app_root_name # App executable name + self.process_name = ( + app_object.description.lower() + ) # Process name (e.g., 'word') + self.win_app = app_object.win_app # App Windows process name (e.g., 'winword') + + # Setup COM receiver for Windows application + self.receive_factory = ReceiverManager._receiver_factory_registry["COM"][ + "factory" + ] + self.win_com_receiver = self.receive_factory.create_receiver( + self.app_root_name, self.process_name + ) + + def start(self, cached_template_path: str) -> None: + """ + Start the Window env. + :param cached_template_path: The file path to start the env. + """ + + from ufo.automator.ui_control import openfile + + file_controller = openfile.FileController(BACKEND) + file_controller.execute_code( + {"APP": self.win_app, "file_path": cached_template_path} + ) + + def close(self): + """ + Close the Window env. + """ + + com_object = self.win_com_receiver.get_object_from_process_name() + com_object.Close() + self.win_com_receiver.client.Quit() + time.sleep(1) + + def save_screenshot(self, save_path: str) -> None: + """ + Capture the screenshot of the current window or full screen if the window is not found. + :param save_path: The path where the screenshot will be saved. + :return: None + """ + # Create a Desktop object + desktop = Desktop(backend=BACKEND) + + # Get a list of all windows, including those that are empty + windows_list = desktop.windows() + + matched_window = None + for window in windows_list: + window_title = window.element_info.name.lower() + if ( + self.process_name in window_title + ): # Match window name with app_root_name + matched_window = window + break + + if matched_window: + # If the window is found, bring it to the foreground and capture a screenshot + matched_window.set_focus() + rect = matched_window.rectangle() + screenshot = ImageGrab.grab( + bbox=(rect.left, rect.top, rect.right, rect.bottom) + ) + else: + # If no window is found, take a full-screen screenshot + print("Window not found, taking a full-screen screenshot.") + screenshot = ImageGrab.grab() + + # Save the screenshot to the specified path + screenshot.save(save_path) + print(f"Screenshot saved to {save_path}") \ No newline at end of file diff --git a/instantiation/controller/env/state_manager.py b/instantiation/controller/env/state_manager.py deleted file mode 100644 index f792724d..00000000 --- a/instantiation/controller/env/state_manager.py +++ /dev/null @@ -1,57 +0,0 @@ -import time - -from instantiation.controller.config.config import Config -from ufo.automator.puppeteer import ReceiverManager - -configs = Config.get_instance().config_data - -if configs is not None: - BACKEND = configs["CONTROL_BACKEND"] - - -class WindowsAppEnv: - """ - The Windows App Environment. - """ - - def __init__(self, app_object): - """ - Initialize the Windows App Environment. - :param app_object: The app object containing the app information. - """ - - super(WindowsAppEnv, self).__init__() - self.app_window = None - self.app_root_name = app_object.app_root_name - self.process_name = app_object.description.lower() - self.win_app = app_object.win_app - - self.receive_factory = ReceiverManager._receiver_factory_registry["COM"][ - "factory" - ] - self.win_com_receiver = self.receive_factory.create_receiver( - self.app_root_name, self.process_name - ) - - def start(self, cached_template_path): - """ - Start the Window env. - :param cached_template_path: The file path to start the env. - """ - - from ufo.automator.ui_control import openfile - - file_controller = openfile.FileController(BACKEND) - file_controller.execute_code( - {"APP": self.win_app, "file_path": cached_template_path} - ) - - def close(self): - """ - Close the Window env. - """ - - com_object = self.win_com_receiver.get_object_from_process_name() - com_object.Close() - self.win_com_receiver.client.Quit() - time.sleep(1) diff --git a/instantiation/controller/module/action_prefill_flow.py b/instantiation/controller/module/action_prefill_flow.py deleted file mode 100644 index e51ba5ce..00000000 --- a/instantiation/controller/module/action_prefill_flow.py +++ /dev/null @@ -1,187 +0,0 @@ -import json -import os -from typing import Dict, List - -from langchain.embeddings import CacheBackedEmbeddings -from langchain.storage import LocalFileStore -from langchain_community.embeddings import HuggingFaceEmbeddings - -from instantiation.controller.agent.agent import ActionPrefillAgent -from instantiation.controller.config.config import Config -from instantiation.controller.env.state_manager import WindowsAppEnv -from ufo.agents.processors.app_agent_processor import AppAgentProcessor -from ufo.automator.ui_control.inspector import ControlInspectorFacade -from ufo.automator.ui_control.screenshot import PhotographerFacade -from ufo.module.basic import BaseSession - -configs = Config.get_instance().config_data -if configs is not None: - BACKEND = configs["CONTROL_BACKEND"] - - -class ActionPrefillFlow(AppAgentProcessor): - """ - The class to refine the plan steps and prefill the file. - """ - - def __init__( - self, - app_name: str, - task: str, - environment: WindowsAppEnv = None, - embedding_model: str = configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"], - ): - """ - Initialize the follow flow. - :param app_name: The name of the operated app. - :param app_root_name: The root name of the app. - :param environment: The environment of the app. - :param task: The label of the task. - """ - - self.app_env = environment - # Create the action prefill agent - self.action_prefill_agent = ActionPrefillAgent( - "action_prefill", - app_name, - is_visual=True, - main_prompt=configs["ACTION_PREFILL_PROMPT"], - example_prompt=configs["ACTION_PREFILL_EXAMPLE_PROMPT"], - api_prompt=configs["API_PROMPT"], - ) - - # - self.file_path = "" - self.embedding_model = ActionPrefillFlow.load_embedding_model(embedding_model) - - self.execute_step = 0 - # self.canvas_state = None - self.control_inspector = ControlInspectorFacade(BACKEND) - self.photographer = PhotographerFacade() - - self.control_state = None - self.custom_doc = None - self.status = "" - self.file_path = "" - self.control_annotation = None - - self.log_path_configs = configs["PREFILL_LOG_PATH"].format(task=task) - os.makedirs(self.log_path_configs, exist_ok=True) - self.prefill_logger = BaseSession.initialize_logger( - self.log_path_configs, f"prefill_agent.json", "w", configs - ) - - def update_state(self, file_path: str): - """ - Get current states of app with pywinauto、win32com - :param file_path: The file path of the app. - """ - print(f"updating the state of app file: {file_path}") - - control_list = self.control_inspector.find_control_elements_in_descendants( - self.app_env.app_window, - control_type_list=configs["CONTROL_LIST"], - class_name_list=configs["CONTROL_LIST"], - ) - self._annotation_dict = self.photographer.get_annotation_dict( - self.app_env.app_window, control_list, annotation_type="number" - ) - - # Attempt to filter out irrelevant control items based on the previous plan. - self.filtered_annotation_dict = self.get_filtered_annotation_dict( - self._annotation_dict, configs=configs - ) - - self._control_info = self.control_inspector.get_control_info_list_of_dict( - self._annotation_dict, - ["control_text", "control_type" if BACKEND == "uia" else "control_class"], - ) - self.filtered_control_info = ( - self.control_inspector.get_control_info_list_of_dict( - self.filtered_annotation_dict, - [ - "control_text", - "control_type" if BACKEND == "uia" else "control_class", - ], - ) - ) - - def load_embedding_model(model_name: str): - store = LocalFileStore(configs["CONTROL_EMBEDDING_CACHE_PATH"]) - if not model_name.startswith("sentence-transformers"): - model_name = "sentence-transformers/" + model_name - embedding_model = HuggingFaceEmbeddings(model_name=model_name) - cached_embedder = CacheBackedEmbeddings.from_bytes_store( - embedding_model, store, namespace=model_name - ) - return cached_embedder - - def log_prefill_agent_info( - self, messages: List[Dict], agent_response: Dict, error: str = "" - ): - """ - Record the prefill information. - :param messages: The messages of the conversation. - :param agent_response: The response of the agent. - :param error: The error message. - """ - - messages = messages - history_json = { - "step": self.execute_step, - "messages": messages, - "agent_response": agent_response, - "error": error, - } - # add not replace - self.prefill_logger.info(json.dumps(history_json)) - - def get_prefill_actions( - self, given_task, reference_steps, file_path - ) -> tuple[str, List]: - """ - Call the PlanRefine Agent to select files - :param given_task: The given task. - :param reference_steps: The reference steps. - :param file_path: The file path. - :return: The prefilled task and the action plans. - """ - - error_msg = "" - # update the control states - self.update_state(file_path) - - screenshot_path = self.log_path_configs + "/screenshot.png" - self.photographer.capture_desktop_screen_screenshot(save_path=screenshot_path) - - # filter the controls - filter_control_state = self.filtered_control_info - # filter the apis - prompt_message = self.action_prefill_agent.message_constructor( - "", given_task, reference_steps, filter_control_state, self.log_path_configs - ) - try: - response_string, cost = self.action_prefill_agent.get_response( - prompt_message, - "action_prefill", - use_backup_engine=True, - configs=configs, - ) - response_json = self.action_prefill_agent.response_to_dict(response_string) - new_task = response_json["new_task"] - action_plans = response_json["actions_plan"] - - except Exception as e: - self.status = "ERROR" - error_msg = str(e) - self.log_prefill_agent_info( - prompt_message, {"ActionPrefillAgent": response_json}, error_msg - ) - - return None, None - else: - self.log_prefill_agent_info( - prompt_message, {"ActionPrefillAgent": response_json}, error_msg - ) - - return new_task, action_plans diff --git a/instantiation/controller/module/filter_flow.py b/instantiation/controller/module/filter_flow.py deleted file mode 100644 index 6652da5c..00000000 --- a/instantiation/controller/module/filter_flow.py +++ /dev/null @@ -1,50 +0,0 @@ -from instantiation.controller.agent.agent import FilterAgent -from instantiation.controller.config.config import Config - -configs = Config.get_instance().config_data - - -class FilterFlow: - """ - The class to refine the plan steps and prefill the file. - """ - - def __init__(self, app_name: str) -> None: - """ - Initialize the follow flow. - :param app_name: The name of the operated app. Example: "Word" - """ - - self.app_name = app_name - self.filter_agent = FilterAgent( - "filter", - app_name, - is_visual=True, - main_prompt=configs["FILTER_PROMPT"], - example_prompt="", - api_prompt=configs["API_PROMPT"], - ) - self.execute_step = 0 - - def get_filter_res(self, request: str) -> tuple[bool, str, str]: - """ - Call the PlanRefine Agent to select files - :param request: The request message - :return: The results from the filter agent - """ - - prompt_message = self.filter_agent.message_constructor(request, self.app_name) - try: - response_string, cost = self.filter_agent.get_response( - prompt_message, "filter", use_backup_engine=True, configs=configs - ) - response_json = self.filter_agent.response_to_dict(response_string) - judge = response_json["judge"] - thought = response_json["thought"] - type = response_json["type"] - return judge, thought, type - - except Exception as e: - self.status = "ERROR" - print(f"Error: {e}") - return None diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index 0262444e..28e519ee 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -163,9 +163,9 @@ def examples_prompt_helper( return self.retrived_documents_prompt_helper(header, separator, example_list) -class ActionPrefillPrompter(BasicPrompter): +class PrefillPrompter(BasicPrompter): """ - Load the prompt for the ActionPrefillAgent. + Load the prompt for the PrefillAgent. """ def __init__( @@ -176,7 +176,7 @@ def __init__( api_prompt_template: str, ): """ - Initialize the ActionPrefillPrompter. + Initialize the PrefillPrompter. :param is_visual: The flag indicating whether the prompter is visual or not. :param prompt_template: The prompt template. :param example_prompt_template: The example prompt template. diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py new file mode 100644 index 00000000..d6742d3e --- /dev/null +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -0,0 +1,192 @@ +# from instantiation.controller.agent.agent import FilterAgent +import json +import os +from datetime import datetime +from typing import Dict + +from langchain.embeddings import CacheBackedEmbeddings +from langchain.storage import LocalFileStore +from langchain_community.embeddings import HuggingFaceEmbeddings +from langchain_community.vectorstores import FAISS + +from instantiation.config.config import Config +from instantiation.instantiation import TaskObject + +configs = Config.get_instance().config_data + + +class ChooseTemplateFlow: + def __init__(self, task_object: TaskObject): + """ + :param task_dir_name: Folder name of the task, specific for one process. + :param task_json_object: Json object, which is the json file of the task. + :param task_path_object: Path object, which is related to the path of the task. + """ + + self.task_object = task_object + self.embedding_model = ChooseTemplateFlow.load_embedding_model( + model_name=configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"] + ) + + def execute(self): + """ + Execute the flow. + """ + + self.chose_template_and_copy() + + def create_cache_file( + self, copy_from_path: str, copy_to_folder_path: str, file_name: str = None + ) -> str: + """ + According to the original path, create a cache file. + :param copy_from_path: The original path of the file. + :param copy_to_folder_path: The path of the cache file. + :param file_name: The name of the task file. + :return: The template path string as a new created file. + """ + + # Create the folder if not exists. + if not os.path.exists(copy_to_folder_path): + os.makedirs(copy_to_folder_path) + time_start = datetime.now() + template_extension = self.task_object.app_object.file_extension + if not file_name: + cached_template_path = os.path.join( + # current_path, + copy_to_folder_path, + time_start.strftime("%Y-%m-%d-%H-%M-%S") + template_extension, + ) + else: + cached_template_path = os.path.join( + copy_to_folder_path, file_name + template_extension + ) + with open(copy_from_path, "rb") as f: + ori_content = f.read() + with open(cached_template_path, "wb") as f: + f.write(ori_content) + return cached_template_path + + def get_chosen_file_path(self) -> str: + """ + Choose the most relative template file. + :return: The most relative template file path string. + """ + + # get the description of the templates + templates_description_path = os.path.join( + configs["TEMPLATE_PATH"], + self.task_object.app_object.description.lower(), + "description.json", + ) + + # Check if the description.json file exists + try: + with open(templates_description_path, "r") as f: + templates_file_description = json.load(f) + except FileNotFoundError: + print( + f"Warning: {templates_description_path} does not exist. Choosing a random template." + ) + + # List all available template files + template_files = [ + f + for f in os.listdir( + os.path.join( + configs["TEMPLATE_PATH"], + self.task_object.app_object.description.lower(), + ) + ) + if os.path.isfile( + os.path.join( + configs["TEMPLATE_PATH"], + self.task_object.app_object.description.lower(), + f, + ) + ) + ] + + # If no templates are found, raise an exception + if not template_files: + raise Exception("No template files found in the specified directory.") + + # Randomly select one of the available template files + chosen_template_file = random.choice(template_files) + print(f"Randomly selected template: {chosen_template_file}") + return chosen_template_file + + templates_file_description = json.load(open(templates_description_path, "r")) + + # get the chosen file path + chosen_file_path = self.chose_target_template_file( + self.task_object.task, templates_file_description + ) + + # Print the chosen template for the user + print(f"Chosen template file: {chosen_file_path}") + return chosen_file_path + + def chose_template_and_copy(self) -> str: + """ + Choose the template and copy it to the cache folder. + """ + + # Get the chosen template file path. + chosen_template_file_path = self.get_chosen_file_path() + chosen_template_full_path = os.path.join( + configs["TEMPLATE_PATH"], + self.task_object.app_object.description.lower(), + chosen_template_file_path, + ) + + # Get the target template folder path. + target_template_folder_path = os.path.join( + configs["TASKS_HUB"], self.task_object.task_dir_name + "_templates" + ) + + # Copy the template to the cache folder. + template_cached_path = self.create_cache_file( + chosen_template_full_path, + target_template_folder_path, + self.task_object.task_file_name, + ) + self.task_object.instantial_template_path = template_cached_path + + return template_cached_path + + def chose_target_template_file( + self, given_task: str, doc_files_description: Dict[str, str] + ): + """ + Get the target file based on the semantic similarity of given task and the template file decription. + :param given_task: The given task. + :param doc_files_description: The description of the template files. + :return: The target file path. + """ + + candidates = [ + doc_file_description + for doc, doc_file_description in doc_files_description.items() + ] + file_doc_descriptions = { + doc_file_description: doc + for doc, doc_file_description in doc_files_description.items() + } + # use FAISS to get the top k control items texts + db = FAISS.from_texts(candidates, self.embedding_model) + doc_descriptions = db.similarity_search(given_task, k=1) + doc_description = doc_descriptions[0].page_content + doc = file_doc_descriptions[doc_description] + return doc + + @staticmethod + def load_embedding_model(model_name: str): + store = LocalFileStore(configs["CONTROL_EMBEDDING_CACHE_PATH"]) + if not model_name.startswith("sentence-transformers"): + model_name = "sentence-transformers/" + model_name + embedding_model = HuggingFaceEmbeddings(model_name=model_name) + cached_embedder = CacheBackedEmbeddings.from_bytes_store( + embedding_model, store, namespace=model_name + ) + return cached_embedder diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py new file mode 100644 index 00000000..3f9eb62f --- /dev/null +++ b/instantiation/controller/workflow/filter_flow.py @@ -0,0 +1,153 @@ +import json +import os +from typing import Dict, Tuple + +from instantiation.config.config import Config +from instantiation.controller.agent.agent import FilterAgent +from ufo.module.basic import BaseSession + +configs = Config.get_instance().config_data + + +class FilterFlow: + """ + The class to refine the plan steps and prefill the file. + """ + + # A dictionary to store filter agents for each app. + app_filter_agent_dict: Dict[str, FilterAgent] = dict() + + def __init__(self, task_object: object) -> None: + """ + Initialize the filter flow for a task. + :param task_object: The task object containing task details. + """ + self.task_object = task_object + self.app_name = self.task_object.app_object.description.lower() + + # If no filter agent exists for the app, create one and store it in the dictionary. + if FilterFlow.app_filter_agent_dict.get(self.app_name) is None: + FilterFlow.app_filter_agent_dict[self.app_name] = FilterAgent( + "filter", + self.app_name, + is_visual=True, + main_prompt=configs["FILTER_PROMPT"], + example_prompt="", + api_prompt=configs["API_PROMPT"], + ) + self.filter_agent = FilterFlow.app_filter_agent_dict[self.app_name] + + # Set up log paths and create directories if necessary. + self.log_path_configs = configs["FILTER_LOG_PATH"].format( + task=self.task_object.task_file_name + ) + os.makedirs(self.log_path_configs, exist_ok=True) + + # Initialize loggers for request messages and responses. + self.filter_message_logger = BaseSession.initialize_logger( + self.log_path_configs, "filter_messages.json", "w", configs + ) + self.filter_response_logger = BaseSession.initialize_logger( + self.log_path_configs, "filter_responses.json", "w", configs + ) + + def execute(self) -> None: + """ + Execute the filter flow: Filter the task and save the result. + """ + + self.get_task_filtered() + self.save_instantiated_task() + + def get_filter_res(self) -> Tuple[bool, str, str]: + """ + Get the filtered result from the filter agent. + :return: A tuple containing whether the request is good, the request comment, and the request type. + """ + # app_name = self.task_object.app_object.app_name + prompt_message = self.filter_agent.message_constructor( + self.task_object.instantiated_request, + self.task_object.app_object.description.lower(), + ) + try: + # Log the prompt message + self.filter_message_logger.info(prompt_message) + + response_string, cost = self.filter_agent.get_response( + prompt_message, "filter", use_backup_engine=True, configs=configs + ) + # Log the response string + self.filter_response_logger.info(response_string) + + # Convert the response to a dictionary and extract information. + response_json = self.filter_agent.response_to_dict(response_string) + request_quality_is_good = response_json["judge"] + request_comment = response_json["thought"] + request_type = response_json["type"] + + print("Comment for the instantiation: ", request_comment) + return request_quality_is_good, request_comment, request_type + + except Exception as e: + self.status = "ERROR" + print(f"Error: {e}") + return None + + def filter_task(self) -> Tuple[bool, str, str]: + """ + Filter the task by sending the request to the filter agent. + :return: A tuple containing whether the request is good, the request comment, and the request type. + """ + + try: + return self.get_filter_res() + except Exception as e: + print(f"Error in filter_task: {e}") + return False, "", "" + + def get_instance_folder_path(self) -> Tuple[str, str]: + """ + Get the folder paths for passing and failing instances. + :return: A tuple containing the pass and fail folder paths. + """ + + new_folder_path = os.path.join( + configs["TASKS_HUB"], self.task_object.task_dir_name + "_instantiated" + ) + new_folder_pass_path = os.path.join(new_folder_path, "instances_pass") + new_folder_fail_path = os.path.join(new_folder_path, "instances_fail") + return new_folder_pass_path, new_folder_fail_path + + def get_task_filtered(self) -> None: + """ + Filter the task and set the corresponding attributes in the task object. + """ + + request_quality_is_good, request_comment, request_type = self.filter_task() + self.task_object.set_attributes( + request_quality_is_good=request_quality_is_good, + request_comment=request_comment, + request_type=request_type, + ) + + def save_instantiated_task(self) -> None: + """ + Save the instantiated task to the pass / fail folder. + """ + + # Get the folder path for classified instances. + new_folder_pass_path, new_folder_fail_path = self.get_instance_folder_path() + # Generate the json object of the task. + task_json = self.task_object.to_json() + + # Save the task to the pass / fail folder. + if self.task_object.request_quality_is_good: + new_task_path = os.path.join( + new_folder_pass_path, self.task_object.task_file_base_name + ) + else: + new_task_path = os.path.join( + new_folder_fail_path, self.task_object.task_file_base_name + ) + os.makedirs(os.path.dirname(new_task_path), exist_ok=True) + open(new_task_path, "w").write(json.dumps(task_json)) diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py new file mode 100644 index 00000000..c9740261 --- /dev/null +++ b/instantiation/controller/workflow/prefill_flow.py @@ -0,0 +1,230 @@ +import json +import os +from typing import Any, Dict, List, Tuple + +from instantiation.config.config import Config +from instantiation.controller.agent.agent import PrefillAgent +from instantiation.controller.env.env_manager import WindowsAppEnv +from ufo.agents.processors.app_agent_processor import AppAgentProcessor +from ufo.automator.ui_control.inspector import ControlInspectorFacade +from ufo.automator.ui_control.screenshot import PhotographerFacade +from ufo.module.basic import BaseSession + +configs = Config.get_instance().config_data +if configs is not None: + BACKEND = configs["CONTROL_BACKEND"] + + +class PrefillFlow(AppAgentProcessor): + """ + The class to refine the plan steps and prefill the file. + """ + + app_prefill_agent_dict: Dict = dict() + + def __init__(self, task_object, environment: WindowsAppEnv = None) -> None: + """ + Initialize the follow flow. + :param task_object: The object containing task details (should have app_object and task_file_name). + :param environment: The environment of the app (optional). + """ + + self.task_object = task_object + self.app_name = task_object.app_object.description.lower() + self.task_file_name = task_object.task_file_name + + if environment is None: + from instantiation.controller.env.env_manager import WindowsAppEnv + + self.app_env = WindowsAppEnv(task_object.app_object) + else: + self.app_env = environment + + # Create the action prefill agent + if self.app_name not in PrefillFlow.app_prefill_agent_dict: + self.app_prefill_agent_dict[self.app_name] = PrefillAgent( + "prefill", + self.app_name, + is_visual=True, + main_prompt=configs["PREFILL_PROMPT"], + example_prompt=configs["PREFILL_EXAMPLE_PROMPT"], + api_prompt=configs["API_PROMPT"], + ) + self.prefill_agent = PrefillFlow.app_prefill_agent_dict[self.app_name] + + self.file_path = "" + + self.execute_step = 0 + self.control_inspector = ControlInspectorFacade(BACKEND) + self.photographer = PhotographerFacade() + + self.control_state = None + self.custom_doc = None + self.status = "" + self.file_path = "" + self.control_annotation = None + + # Initialize loggers for messages and responses + self.log_path_configs = configs["PREFILL_LOG_PATH"].format( + task=self.task_file_name + ) + os.makedirs(self.log_path_configs, exist_ok=True) + + self.message_logger = BaseSession.initialize_logger( + self.log_path_configs, "prefill_messages.json", "w", configs + ) + self.response_logger = BaseSession.initialize_logger( + self.log_path_configs, "prefill_responses.json", "w", configs + ) + + def execute(self) -> None: + """ + Execute the prefill flow by retrieving the instantiated result. + """ + + self.get_instantiated_result() + + def get_instantiated_result(self) -> None: + """ + Get the instantiated result for the task. + + This method interacts with the PrefillAgent to get the refined task and action plans. + """ + template_cached_path = self.task_object.instantial_template_path + self.app_env.start(template_cached_path) + try: + instantiated_request, instantiated_plan = self.get_prefill_actions( + self.task_object.task, + self.task_object.refined_steps, + template_cached_path, + ) + + print(f"Original Task: {self.task_object.task}") + print(f"Prefilled Task: {instantiated_request}") + self.task_object.set_attributes( + instantiated_request=instantiated_request, + instantiated_plan=instantiated_plan, + ) + + except Exception as e: + print(f"Error! get_instantiated_result: {e}") + finally: + self.app_env.close() + + def update_state(self, file_path: str) -> None: + """ + Get current states of app with pywinauto、win32com + + :param file_path: The file path of the app. + """ + print(f"updating the state of app file: {file_path}") + + control_list = self.control_inspector.find_control_elements_in_descendants( + self.app_env.app_window, + control_type_list=configs["CONTROL_LIST"], + class_name_list=configs["CONTROL_LIST"], + ) + self._annotation_dict = self.photographer.get_annotation_dict( + self.app_env.app_window, control_list, annotation_type="number" + ) + + # Filter out irrelevant control items based on the previous plan. + self.filtered_annotation_dict = self.get_filtered_annotation_dict( + self._annotation_dict, configs=configs + ) + + self._control_info = self.control_inspector.get_control_info_list_of_dict( + self._annotation_dict, + ["control_text", "control_type" if BACKEND == "uia" else "control_class"], + ) + self.filtered_control_info = ( + self.control_inspector.get_control_info_list_of_dict( + self.filtered_annotation_dict, + [ + "control_text", + "control_type" if BACKEND == "uia" else "control_class", + ], + ) + ) + + + def log_prefill_agent_info( + self, + messages: List[Dict[str, Any]], + agent_response: Dict[str, Any], + error: str = "", + ) -> None: + """ + Record the prefill information. + + :param messages: The messages of the conversation. + :param agent_response: The response of the agent. + :param error: The error message. + """ + + # Log message + messages_log_entry = { + "step": self.execute_step, + "messages": messages, + "error": error, + } + self.message_logger.info(json.dumps(messages_log_entry)) + + # Log response + response_log_entry = { + "step": self.execute_step, + "agent_response": agent_response, + "error": error, + } + self.response_logger.info(json.dumps(response_log_entry)) + + def get_prefill_actions( + self, given_task: str, reference_steps: List[str], file_path: str + ) -> Tuple[str, List[str]]: + """ + Call the PlanRefine Agent to select files + + :param given_task: The given task. + :param reference_steps: The reference steps. + :param file_path: The file path. + :return: The prefilled task and the action plans. + """ + + error_msg = "" + self.update_state(file_path) + + # Save the screenshot + screenshot_path = self.log_path_configs + "screenshot.png" + self.app_env.save_screenshot(screenshot_path) + + # filter the controls + filter_control_state = self.filtered_control_info + # filter the apis + prompt_message = self.prefill_agent.message_constructor( + "", given_task, reference_steps, filter_control_state, self.log_path_configs + ) + try: + response_string, cost = self.prefill_agent.get_response( + prompt_message, + "prefill", + use_backup_engine=True, + configs=configs, + ) + response_json = self.prefill_agent.response_to_dict(response_string) + new_task = response_json["new_task"] + action_plans = response_json["actions_plan"] + + except Exception as e: + self.status = "ERROR" + error_msg = str(e) + self.log_prefill_agent_info( + prompt_message, {"PrefillAgent": response_json}, error_msg + ) + + return None, None + else: + self.log_prefill_agent_info( + prompt_message, {"PrefillAgent": response_json}, error_msg + ) + + return new_task, action_plans diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py new file mode 100644 index 00000000..952eea25 --- /dev/null +++ b/instantiation/instantiation.py @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import argparse +import glob +import json +import os +import sys +from enum import Enum + +# Add the project root to the system path. +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.abspath(os.path.join(current_dir, "..")) + +if project_root not in sys.path: + sys.path.append(project_root) + +# Set the environment variable. +os.environ["RUN_CONFIGS"] = "false" + +# Parse the arguments. +args = argparse.ArgumentParser() +args.add_argument( + "--task", help="The name of the task.", type=str, default="prefill" +) +parsed_args = args.parse_args() + +class AppEnum(Enum): + """ + Define the apps can be used in the instantiation. + """ + + WORD = 1, "Word", ".docx", "winword" + EXCEL = 2, "Excel", ".xlsx", "excel" + POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" + + def __init__(self, id : int, description : str, file_extension : str, win_app : str): + """ + :param id: The unique id of the app. + :param description: The description of the app. Example: Word, Excel, PowerPoint. + :param file_extension: The file extension of the app. Example: .docx, .xlsx, .pptx. + :param win_app: The windows app name of the app. Example: winword, excel, powerpnt. + """ + + self.id = id + self.description = description + self.file_extension = file_extension + self.win_app = win_app + # The root name of the app to be used in opening and closing app window. + self.app_root_name = win_app.upper() + ".EXE" + + +class TaskObject(): + """ + The task object from the json file. + """ + + def __init__(self, task_dir_name : str, task_file: str) -> None: + """ + Initialize the task object from the json file. + :param task_file: The task file to load from. + """ + + self.task_dir_name = task_dir_name + self.task_file = task_file + # The folder name of the task, specific for one process. Example: prefill. + self.task_file_dir_name = os.path.dirname(os.path.dirname(task_file)) + # The base name of the task file. Example: task_1.json. + self.task_file_base_name = os.path.basename(task_file) + # The task name of the task file without extension. Example: task_1. + self.task_file_name = self.task_file_base_name.split(".")[0] + + # Load the json file and get the app object. + with open(task_file, "r") as f: + task_json_file = json.load(f) + self.app_object = self.choose_app_from_json(task_json_file) + + for key, value in task_json_file.items(): + setattr(self, key.lower().replace(" ", "_"), value) + + # The fields to be saved in the json file. + self.json_fields = [ + "unique_id", + "instantiated_request", + "instantiated_plan", + "instantial_template_path", + "request_comment", + ] + + def choose_app_from_json(self, task_json_file: dict) -> AppEnum: + """ + Generate an app object by traversing AppEnum based on the app specified in the JSON. + :param task_json_file: The JSON file of the task. + :return: The app object. + """ + + for app in AppEnum: + app_name = app.description.lower() + json_app_name = task_json_file["app"].lower() + if app_name == json_app_name: + return app + raise ValueError("Not a correct App") + + + def to_json(self) -> dict: + """ + Convert the object to a JSON object. + :return: The JSON object. + """ + json_data = {} + for key in self.json_fields: + if hasattr(self, key): + json_data[key] = getattr(self, key) + return json_data + + def set_attributes(self, **kwargs) -> None: + """ + Add all input fields as attributes. + :param kwargs: The fields to be added. + """ + + for key, value in kwargs.items(): + setattr(self, key, value) + + +class InstantiationProcess: + """ + Key process to instantialize the task. + Control the process of the task. + """ + + def instantiate_files(self, task_dir_name: str) -> None: + """ + """ + + all_task_file_path: str = os.path.join(configs["TASKS_HUB"], task_dir_name, "*") + all_task_files = glob.glob(all_task_file_path) + + for index, task_file in enumerate(all_task_files, start=1): + print(f"Task starts: {index} / {len(all_task_files)}") + try: + + task_object = TaskObject(task_dir_name, task_file) + self.instantiate_single_file(task_object) + except Exception as e: + print(f"Error in task {index} with file {task_file}: {e}") + + print("All tasks have been processed.") + + def instantiate_single_file(self, task_object : TaskObject) -> None: + """ + Execute the process for one task. + :param task_object: The TaskObject containing task details. + The execution includes getting the instantiated result, evaluating the task and saving the instantiated task. + """ + + from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow + from instantiation.controller.workflow.prefill_flow import PrefillFlow + from instantiation.controller.workflow.filter_flow import FilterFlow + + ChooseTemplateFlow(task_object).execute() + PrefillFlow(task_object).execute() + FilterFlow(task_object).execute() + + + +def main(): + """ + The main function to process the tasks. + """ + + # Load the configs. + from instantiation.config.config import Config + + config_path = ( + os.path.normpath(os.path.join(current_dir, "config/")) + "\\" + ) + global configs + configs = Config(config_path).get_instance().config_data + + task_dir_name = parsed_args.task.lower() + + InstantiationProcess().instantiate_files(task_dir_name) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/instantiation/tasks/action_prefill/bulleted.json b/instantiation/tasks/prefill/bulleted.json similarity index 100% rename from instantiation/tasks/action_prefill/bulleted.json rename to instantiation/tasks/prefill/bulleted.json diff --git a/instantiation/tasks/action_prefill/delete.json b/instantiation/tasks/prefill/delete.json similarity index 100% rename from instantiation/tasks/action_prefill/delete.json rename to instantiation/tasks/prefill/delete.json diff --git a/instantiation/tasks/action_prefill/draw.json b/instantiation/tasks/prefill/draw.json similarity index 100% rename from instantiation/tasks/action_prefill/draw.json rename to instantiation/tasks/prefill/draw.json diff --git a/instantiation/tasks/action_prefill/macro.json b/instantiation/tasks/prefill/macro.json similarity index 100% rename from instantiation/tasks/action_prefill/macro.json rename to instantiation/tasks/prefill/macro.json diff --git a/instantiation/tasks/action_prefill/totate.json b/instantiation/tasks/prefill/totate.json similarity index 100% rename from instantiation/tasks/action_prefill/totate.json rename to instantiation/tasks/prefill/totate.json diff --git a/ufo/llm/llm_call.py b/ufo/llm/llm_call.py index bb749a3b..b06913fa 100644 --- a/ufo/llm/llm_call.py +++ b/ufo/llm/llm_call.py @@ -54,8 +54,8 @@ def get_completions( agent_type = "HOST_AGENT" elif agent.lower() in ["app", "appagent"]: agent_type = "APP_AGENT" - elif agent.lower() == "action_prefill": - agent_type = "ACTION_PREFILL_AGENT" + elif agent.lower() == "prefill": + agent_type = "PREFILL_AGENT" elif agent.lower() == "filter": agent_type = "FILTER_AGENT" elif agent.lower() == "backup": From 69731d35214d64df48f89ef3eb273ed67a0db364 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Mon, 21 Oct 2024 21:34:48 +0800 Subject: [PATCH 09/30] Refactor code based on code review: - Added multiple window matching strategies - Added error logging - Implemented timing for each flow - Code refactoring --- instantiation/.gitignore | 1 - instantiation/README.md | 52 +-- instantiation/config/config.py | 9 + instantiation/config/config_dev.yaml | 1 + instantiation/controller/agent/agent.py | 19 +- instantiation/controller/env/env_manager.py | 137 ++++---- .../controller/prompter/agent_prompter.py | 41 ++- .../controller/prompts/visual/api.yaml | 66 ++++ .../controller/prompts/visual/filter.yaml | 26 ++ .../controller/prompts/visual/prefill.yaml | 124 +++++++ .../prompts/visual/prefill_example.yaml | 44 +++ .../workflow/choose_template_flow.py | 140 ++++---- .../controller/workflow/filter_flow.py | 176 ++++------ .../controller/workflow/prefill_flow.py | 307 ++++++++++-------- instantiation/instantiation.py | 180 +++++++--- 15 files changed, 836 insertions(+), 487 deletions(-) create mode 100644 instantiation/controller/prompts/visual/api.yaml create mode 100644 instantiation/controller/prompts/visual/filter.yaml create mode 100644 instantiation/controller/prompts/visual/prefill.yaml create mode 100644 instantiation/controller/prompts/visual/prefill_example.yaml diff --git a/instantiation/.gitignore b/instantiation/.gitignore index c67e0167..9da01687 100644 --- a/instantiation/.gitignore +++ b/instantiation/.gitignore @@ -7,4 +7,3 @@ templates/word/* logs/* controller/utils/ config/config.yaml -controller/prompts/* \ No newline at end of file diff --git a/instantiation/README.md b/instantiation/README.md index 021d0eb0..8bc07e25 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -6,7 +6,7 @@ By using this process, we can obtain clearer and more specific instructions, mak ## How to Use -#### 1. Install Packages +### 1. Install Packages You should install the necessary packages in the UFO root folder: @@ -14,13 +14,13 @@ You should install the necessary packages in the UFO root folder: pip install -r requirements.txt ``` -#### 2. Configure the LLMs +### 2. Configure the LLMs Before using the instantiation section, you need to provide your LLM configurations in `config.yaml` and `config_dev.yaml` located in the `instantiation/config` folder. -`config_dev.yaml` specifies the paths of relevant files and contains default settings. +- `config_dev.yaml` specifies the paths of relevant files and contains default settings. The match strategy for the control filter supports options: `'contains'`, `'fuzzy'`, and `'regex'`, allowing flexible matching between application windows and target files. -`config.yaml` stores the agent information. You should copy the `config.yaml.template` file and fill it out according to the provided hints. +- `config.yaml` stores the agent information. You should copy the `config.yaml.template` file and fill it out according to the provided hints. You will configure the prefill agent and the filter agent individually. The prefill agent is used to prepare the task, while the filter agent evaluates the quality of the prefilled task. You can choose different LLMs for each. @@ -28,13 +28,13 @@ You will configure the prefill agent and the filter agent individually. The pref Once you have filled out the template, rename it to `config.yaml` to complete the LLM configuration. -#### 3. Prepare Files +### 3. Prepare Files Certain files need to be prepared before running the task. -##### 3.1. Tasks as JSON +#### 3.1. Tasks as JSON -The tasks that need to be instantiated should be organized in a folder of JSON files, with the default folder path set to `instantiation/tasks`. This path can be changed in the `instantiation/config/config.yaml` file, or you can specify it in the terminal, as mentioned in **3. Start Running**. For example, a task stored in `instantiation/tasks/prefill/` may look like this: +The tasks that need to be instantiated should be organized in a folder of JSON files, with the default folder path set to `instantiation/tasks`. This path can be changed in the `instantiation/config/config.yaml` file, or you can specify it in the terminal, as mentioned in **4. Start Running**. For example, a task stored in `instantiation/tasks/prefill/` may look like this: ```json { @@ -51,7 +51,7 @@ The tasks that need to be instantiated should be organized in a folder of JSON f } ``` -##### 3.2. Templates and Descriptions +#### 3.2. Templates and Descriptions You should place an app file as a reference for instantiation in a folder named after the app. @@ -69,13 +69,13 @@ Additionally, for each app folder, there should be a `description.json` file loc If a `description.json` file is not present, one template file will be selected at random. -##### 3.3. Final Structure +#### 3.3. Final Structure Ensure the following files are in place: -* [X] JSON files to be instantiated -* [X] Templates as references for instantiation -* [X] Description file in JSON format +- [X] JSON files to be instantiated +- [X] Templates as references for instantiation +- [X] Description file in JSON format The structure of the files can be: @@ -99,7 +99,7 @@ instantiation/ └── ... ``` -#### 4. Start Running +### 4. Start Running Run the `instantiation/action_prefill.py` file in module mode. You can do this by typing the following command in the terminal: @@ -120,7 +120,7 @@ After the process is completed, a new folder named `prefill_instantiated` will b // A unique ID to distinguish different tasks "unique_id": "1", // The chosen template path - "instantial_template_path": "cached template file path", + "instantial_template_path": "copied template file path", // The instantiated task and steps "instantiated_request": "Type 'hello' and set the font type to Arial in the Word document.", "instantiated_plan": [ @@ -172,8 +172,12 @@ After the process is completed, a new folder named `prefill_instantiated` will b } } ], - // The comment for the instantiated task - "request_comment": "The task involves typing the specific string 'hello' and setting the font type to Arial, which can be executed locally within Word." + "duration_sec": { + "choose_template": 10.650701761245728, + "prefill": 44.23913502693176, + "filter": 3.746831178665161, + "total": 58.63666796684265 + } } ``` @@ -187,18 +191,26 @@ There are three key steps in the instantiation process: 2. Prefill the task using the current screenshot. 3. Filter the established task. -##### 1. Choose Template File +#### 1. Choose Template File Templates for your app must be defined and described in `instantiation/templates/app`. For instance, if you want to instantiate tasks for the Word application, place the relevant `.docx` files in `instantiation/templates/word`, along with a `description.json` file. The appropriate template will be selected based on how well its description matches the instruction. -##### 2. Prefill the Task +#### 2. Prefill the Task After selecting the template file, it will be opened, and a screenshot will be taken. If the template file is currently in use, errors may occur. The screenshot will be sent to the action prefill agent, which will return a modified task. -##### 3. Filter Task +#### 3. Filter Task -The completed task will be evaluated by a filter agent, which will assess it and provide feedback. If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass`; otherwise, it will follow the same process for poor instances. \ No newline at end of file +The completed task will be evaluated by a filter agent, which will assess it and provide feedback. If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass/`; otherwise, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_fail/`. + +All encountered error messages and tracebacks are saved in `instantiation/tasks/your_folder_name_instantiated/instances_error/`. + +## Notes + +1. Users should be careful to save the original files while using this project; otherwise, the files will be closed when the app is shut down. + +2. After starting the project, users should not close the app window while the program is taking screenshots. diff --git a/instantiation/config/config.py b/instantiation/config/config.py index 3dcdfa24..6f0bf046 100644 --- a/instantiation/config/config.py +++ b/instantiation/config/config.py @@ -8,6 +8,10 @@ class Config(Config): _instance = None def __init__(self, config_path="instantiation/config/"): + """ + Initializes the Config class. + :param config_path: The path to the config file. + """ self.config_data = self.load_config(config_path) @staticmethod @@ -22,6 +26,11 @@ def get_instance(): return Config._instance def optimize_configs(self, configs): + """ + Optimize the configurations. + :param configs: The configurations to optimize. + :return: The optimized configurations. + """ self.update_api_base(configs, "PREFILL_AGENT") self.update_api_base(configs, "FILTER_AGENT") diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index 231a06d8..6fdeb651 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -8,6 +8,7 @@ CONTROL_BACKEND: "uia" # The backend for control action CONTROL_LIST: ["Button", "Edit", "TabItem", "Document", "ListItem", "MenuItem", "ScrollBar", "TreeItem", "Hyperlink", "ComboBox", "RadioButton", "DataItem", "Spinner"] PRINT_LOG: False # Whether to print the log LOG_LEVEL: "DEBUG" # The log level +MATCH_STRATEGY: "regex" # The match strategy for the control filter, support 'contains', 'fuzzy', 'regex' PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill FILTER_PROMPT: "instantiation/controller/prompts/{mode}/filter.yaml" # The prompt for the filter diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index ad698ff1..26b834ec 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -3,8 +3,10 @@ from typing import Dict, List -from instantiation.controller.prompter.agent_prompter import (FilterPrompter, - PrefillPrompter) +from instantiation.controller.prompter.agent_prompter import ( + FilterPrompter, + PrefillPrompter +) from ufo.agents.agent.basic import BasicAgent @@ -44,6 +46,11 @@ def __init__( def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: """ Get the prompt for the agent. + This is the abstract method from BasicAgent that needs to be implemented. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. :return: The prompt. """ @@ -56,7 +63,6 @@ def message_constructor(self, request: str, app: str) -> List[str]: :param app: The name of the operated app. :return: The prompt message. """ - filter_agent_prompt_system_message = self.prompter.system_prompt_construction( app=app ) @@ -72,6 +78,7 @@ def message_constructor(self, request: str, app: str) -> List[str]: def process_comfirmation(self) -> None: """ Confirm the process. + This is the abstract method from BasicAgent that needs to be implemented. """ pass @@ -112,11 +119,12 @@ def __init__( def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: """ Get the prompt for the agent. + This is the abstract method from BasicAgent that needs to be implemented. :param is_visual: The flag indicating whether the agent is visual or not. :param main_prompt: The main prompt. :param example_prompt: The example prompt. :param api_prompt: The API prompt. - :return: The prompt. + :return: The prompt string. """ return PrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) @@ -155,5 +163,6 @@ def message_constructor( def process_comfirmation(self) -> None: """ Confirm the process. + This is the abstract method from BasicAgent that needs to be implemented. """ - pass + pass \ No newline at end of file diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index cf2bf86d..1b2ba160 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -1,99 +1,108 @@ +import logging +import re import time +from pywinauto import Desktop +from fuzzywuzzy import fuzz + from instantiation.config.config import Config from ufo.automator.puppeteer import ReceiverManager -from PIL import ImageGrab -from pywinauto import Desktop - -configs = Config.get_instance().config_data -if configs is not None: - BACKEND = configs["CONTROL_BACKEND"] +# Load configuration settings +_configs = Config.get_instance().config_data +if _configs is not None: + _BACKEND = _configs["CONTROL_BACKEND"] + _MATCH_STRATEGY = _configs.get("MATCH_STRATEGY", "contains") class WindowsAppEnv: """ - The Windows App Environment. + Represents the Windows Application Environment. """ def __init__(self, app_object: object) -> None: """ - Initialize the Windows App Environment. - :param app_object: The app object containing the app information. + Initializes the Windows Application Environment. + :param app_object: The app object containing information about the application. """ super().__init__() - self.app_window = None - self.app_root_name = app_object.app_root_name # App executable name - self.process_name = ( - app_object.description.lower() - ) # Process name (e.g., 'word') - self.win_app = app_object.win_app # App Windows process name (e.g., 'winword') - - # Setup COM receiver for Windows application - self.receive_factory = ReceiverManager._receiver_factory_registry["COM"][ + self.app_root_name = app_object.app_root_name + self.process_name = app_object.description.lower() + self.win_app = app_object.win_app + self._receive_factory = ReceiverManager._receiver_factory_registry["COM"][ "factory" ] - self.win_com_receiver = self.receive_factory.create_receiver( + self.win_com_receiver = self._receive_factory.create_receiver( self.app_root_name, self.process_name ) - def start(self, cached_template_path: str) -> None: + def start(self, copied_template_path: str) -> None: """ - Start the Window env. - :param cached_template_path: The file path to start the env. + Starts the Windows environment. + :param copied_template_path: The file path to the copied template to start the environment. """ - from ufo.automator.ui_control import openfile - file_controller = openfile.FileController(BACKEND) - file_controller.execute_code( - {"APP": self.win_app, "file_path": cached_template_path} - ) + file_controller = openfile.FileController(_BACKEND) + try: + file_controller.execute_code( + {"APP": self.win_app, "file_path": copied_template_path} + ) + except Exception as e: + logging.exception(f"Failed to start the application: {e}") + raise - def close(self): + def close(self) -> None: """ - Close the Window env. + Closes the Windows environment. """ - - com_object = self.win_com_receiver.get_object_from_process_name() - com_object.Close() - self.win_com_receiver.client.Quit() - time.sleep(1) - - def save_screenshot(self, save_path: str) -> None: + try: + com_object = self.win_com_receiver.get_object_from_process_name() + com_object.Close() + self.win_com_receiver.client.Quit() + time.sleep(1) + except Exception as e: + logging.exception(f"Failed to close the application: {e}") + raise + + def find_matching_window(self, doc_name: str) -> object: """ - Capture the screenshot of the current window or full screen if the window is not found. - :param save_path: The path where the screenshot will be saved. - :return: None + Finds a matching window based on the process name and the configured matching strategy. + :param doc_name: The document name associated with the application. + :return: The matched window or None if no match is found. """ - # Create a Desktop object - desktop = Desktop(backend=BACKEND) - - # Get a list of all windows, including those that are empty + desktop = Desktop(backend=_BACKEND) windows_list = desktop.windows() - - matched_window = None for window in windows_list: window_title = window.element_info.name.lower() - if ( - self.process_name in window_title - ): # Match window name with app_root_name - matched_window = window - break + if self._match_window_name(window_title, doc_name): + return window + return None - if matched_window: - # If the window is found, bring it to the foreground and capture a screenshot - matched_window.set_focus() - rect = matched_window.rectangle() - screenshot = ImageGrab.grab( - bbox=(rect.left, rect.top, rect.right, rect.bottom) - ) + def _match_window_name(self, window_title: str, doc_name: str) -> bool: + """ + Matches the window name based on the strategy specified in the config file. + :param window_title: The title of the window. + :param doc_name: The document name associated with the application. + :return: True if a match is found based on the strategy; False otherwise. + """ + app_name = self.process_name + doc_name = doc_name.lower() + + if _MATCH_STRATEGY == "contains": + return app_name in window_title and doc_name in window_title + elif _MATCH_STRATEGY == "fuzzy": + similarity_app = fuzz.partial_ratio(window_title, app_name) + similarity_doc = fuzz.partial_ratio(window_title, doc_name) + return similarity_app >= 70 and similarity_doc >= 70 + elif _MATCH_STRATEGY == "regex": + combined_name_1 = f"{app_name}.*{doc_name}" + combined_name_2 = f"{doc_name}.*{app_name}" + pattern_1 = re.compile(combined_name_1, flags=re.IGNORECASE) + pattern_2 = re.compile(combined_name_2, flags=re.IGNORECASE) + return re.search(pattern_1, window_title) is not None or \ + re.search(pattern_2, window_title) is not None else: - # If no window is found, take a full-screen screenshot - print("Window not found, taking a full-screen screenshot.") - screenshot = ImageGrab.grab() - - # Save the screenshot to the specified path - screenshot.save(save_path) - print(f"Screenshot saved to {save_path}") \ No newline at end of file + logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") + raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index 28e519ee..47677776 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -38,7 +38,7 @@ def api_prompt_helper(self, apis: Dict = {}, verbose: int = 1) -> str: Construct the prompt for APIs. :param apis: The APIs. :param verbose: The verbosity level. - return: The prompt for APIs. + :return: The prompt for APIs. """ # Construct the prompt for APIs @@ -85,7 +85,7 @@ def system_prompt_construction(self, app: str = "") -> str: """ Construct the prompt for the system. :param app: The app name. - return: The prompt for the system. + :return: The prompt for the system. """ try: @@ -99,7 +99,7 @@ def user_prompt_construction(self, request: str) -> str: """ Construct the prompt for the user. :param request: The user request. - return: The prompt for the user. + :return: The prompt for the user. """ prompt = self.prompt_template["user"].format(request=request) return prompt @@ -107,11 +107,8 @@ def user_prompt_construction(self, request: str) -> str: def user_content_construction(self, request: str) -> List[Dict]: """ Construct the prompt for LLMs. - :param action_history: The action history. - :param control_item: The control item. - :param user_request: The user request. - :param retrieved_docs: The retrieved documents. - return: The prompt for LLMs. + :param request: The user request. + :return: The prompt for LLMs. """ user_content = [] @@ -130,10 +127,10 @@ def examples_prompt_helper( ) -> str: """ Construct the prompt for examples. - :param examples: The examples. :param header: The header of the prompt. :param separator: The separator of the prompt. - return: The prompt for examples. + :param additional_examples: The additional examples. + :return: The prompt for examples. """ template = """ @@ -191,9 +188,8 @@ def __init__( def api_prompt_helper(self, verbose: int = 1) -> str: """ Construct the prompt for APIs. - :param apis: The APIs. :param verbose: The verbosity level. - return: The prompt for APIs. + :return: The prompt for APIs. """ # Construct the prompt for APIs @@ -223,7 +219,7 @@ def system_prompt_construction(self, additional_examples: List = []) -> str: """ Construct the prompt for the system. :param additional_examples: The additional examples. - return: The prompt for the system. + :return: The prompt for the system. """ examples = self.examples_prompt_helper(additional_examples=additional_examples) @@ -238,7 +234,7 @@ def user_prompt_construction( :param given_task: The given task. :param reference_steps: The reference steps. :param doc_control_state: The document control state. - return: The prompt for the user. + :return: The prompt for the user. """ prompt = self.prompt_template["user"].format( @@ -253,6 +249,7 @@ def load_screenshots(self, log_path: str) -> str: """ Load the first and last screenshots from the log path. :param log_path: The path of the log. + :return: The screenshot URL. """ from ufo.prompter.eva_prompter import EvaluationAgentPrompter @@ -269,11 +266,11 @@ def user_content_construction( ) -> List[Dict]: """ Construct the prompt for LLMs. - :param action_history: The action history. - :param control_item: The control item. - :param user_request: The user request. - :param retrieved_docs: The retrieved documents. - return: The prompt for LLMs. + :param given_task: The given task. + :param reference_steps: The reference steps. + :param doc_control_state: The document control state. + :param log_path: The path of the log. + :return: The prompt for LLMs. """ user_content = [] @@ -304,10 +301,10 @@ def examples_prompt_helper( ) -> str: """ Construct the prompt for examples. - :param examples: The examples. :param header: The header of the prompt. :param separator: The separator of the prompt. - return: The prompt for examples + :param additional_examples: The additional examples. + :return: The prompt for examples. """ template = """ @@ -334,4 +331,4 @@ def examples_prompt_helper( example_list += [json.dumps(example) for example in additional_examples] - return self.retrived_documents_prompt_helper(header, separator, example_list) + return self.retrived_documents_prompt_helper(header, separator, example_list) \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/api.yaml b/instantiation/controller/prompts/visual/api.yaml new file mode 100644 index 00000000..e3ba3511 --- /dev/null +++ b/instantiation/controller/prompts/visual/api.yaml @@ -0,0 +1,66 @@ +Click: + summary: |- + "Click" is to click the control item with mouse. + usage: |- + [1] API call: click_input(button=, double) + [2] Args: + - button: 'The mouse button to click. One of ''left'', ''right'', ''middle'' or ''x'' (Default: ''left'')' + - double: 'Whether to perform a double click or not (Default: False)' + [3] Example: click_input(button="left", double=False) + [4] Available control item: All control items. + [5] Return: None + + +SetText: + summary: |- + "SetText" is to input text to the control item. + usage: |- + [1] API call: set_edit_text(text="") + [2] Args: + - text: The text input to the Edit control item. It will change the content of current text in the edit block. Set text ='' if you want to clear current text in the block. You must also use Double Backslash escape character to escape the single quote in the string argument. + [3] Example: set_edit_text(text="Hello World. \\n I enjoy the reading of the book 'The Lord of the Rings'. It's a great book.") + [4] Available control item: [Edit] + [5] Return: None + +Annotate: + summary: |- + "Annotate" is to take a screenshot of the current application window and annotate the control item on the screenshot. + usage: |- + [1] API call: annotation(control_labels: List[str]=[]) + [2] Args: + - control_labels: The list of annotated label of the control item. If the list is empty, it will annotate all the control items on the screenshot. + [3] Example: annotation(control_labels=["1", "2", "3", "36", "58"]) + [4] Available control item: All control items. + [5] Return: None + +Summary: + summary: |- + "Summary" is to summarize your observation of the current application window base on the clean screenshot. This usually happens when the you need to complete the user request by summarizing or describing the information on the current application window. You must use the 'text' argument to input the summarized text. + usage: |- + [1] API call: summary(text="") + [2] Args: None + [3] Example: summary(text="The image shows a workflow of a AI agent framework. \\n The framework has three components: the 'data collection', the 'data processing' and the 'data analysis'.") + [4] Available control item: All control items. + [5] Return: the summary of the image. + +GetText: + summary: |- + "GetText" is to get the text of the control item. It typical apply to Edit and Document control item when user request is to get the text of the control item. + usage: |- + [1] API call: texts() + [2] Args: None + [3] Example: texts() + [4] All control items. + [5] Return: the text content of the control item. + +Scroll: + summary: |- + "Scroll" is to scroll the control item. It typical apply to a ScrollBar type of control item when user request is to scroll the control item, or the targeted control item is not visible nor available in the control item list, but you know the control item is in the application window and you need to scroll to find it. + usage: |- + [1] API call: wheel_mouse_input() + [2] Args: + - wheel_dist: The distance to scroll. Positive values indicate upward scrolling, negative values indicate downward scrolling. + [3] Example: wheel_mouse_input(wheel_dist=-20) + [4] All control items. + [5] Return: None + \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/filter.yaml b/instantiation/controller/prompts/visual/filter.yaml new file mode 100644 index 00000000..7d25195f --- /dev/null +++ b/instantiation/controller/prompts/visual/filter.yaml @@ -0,0 +1,26 @@ +version: 1.0 + +system: |- + You are a task judge, will be provided with a task in the . You need to judge whether this task can be executed locally. + + ## Evaluation Dimension + The task is only related to {app}. + This task should be like a task, not subjective considerations. For example, if there are 'custom', 'you want' and other situations, they cannot be considered and should return false and be classified as Non_task. Any subjective will crash the system. + This task should specify the element, for example, if there are only 'text' without the specific string, they cannot be considered and should return false and be classified as Non_task. + This task should not involve interactions with other application plug-ins, etc., and only rely on Word. If 'Excel', 'Edge' and other interactions are involved, it should return false and be classified as App_involve. + This task should not involve version updates and other interactions that depend on the environment, but only rely on the current version, and do not want to be upgraded or downgraded. It should return false and be classified as Env. + There are other things that you think cannot be executed or are irrelevant, return False, and be classified as Others + + ## Response Format + Your response should be strictly structured in a JSON format, consisting of three distinct parts with the following keys and corresponding content: + {{ + "judge": true or false depends on you think this task whether can be performed. + "thought": "Outline the reason why you give the judgement." + "type": "None/Non_task/App_involve/Env/Others" + }} + Make sure you answer must be strictly in JSON format only, without other redundant text such as json header. Otherwise it will crash the system. + Below is only a example of the response. Do not fall in the example. + +user: |- + {request} + \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/prefill.yaml b/instantiation/controller/prompts/visual/prefill.yaml new file mode 100644 index 00000000..46974554 --- /dev/null +++ b/instantiation/controller/prompts/visual/prefill.yaml @@ -0,0 +1,124 @@ +version: 1.0 + +system: |- + You are a Agent Task Creator and planer. + You will receive a that is abstract and your objective is to instantiate this task, and give the step-by-step actions to take. + - You are provided with a doc file environment, which contains the control information in . + - You should review the doc canvas content and control information to detail the to a .The control information is in a dict tree of available control items format. + - You are provided with , you should review the acions carefully and choose the most suitable ones step-by-step . + You are also provided with some steps to reference in + - You should also review these steps carefully, to help you instantiate the original task and give the actions. + + + ## Control item + - The control item is the element on the page that you can interact with, we limit the actionable control item to the following: + - "Button" is the control item that you can click. + - "Edit" is the control item that you can click and input text. + - "TabItem" is the control item that you can click and switch to another page. + - "ListItem" is the control item that you can click and select. + - "MenuItem" is the control item that you can click and select. + - "ScrollBar" is the control item that you can scroll. + - "TreeItem" is the control item that you can click and select. + - "Document" is the control item that you can click and select text. + - "Hyperlink" is the control item that you can click and open a link. + - "ComboBox" is the control item that you can click and input text. The Google search box is an example of ComboBox. + + ## Available Actions on the control item + - All the available actions are listed below: + {apis} + + Besides, please prefill the task based on the screenshot. you will also be provided with a screenshot, one before the agent's execution and one after the agent's execution. + + ## The requirements for + 1. The must based on the given task, but if more then one options exist in , you must choose one of them. + 2. The must be able to be completed step-by-step by a Windows Operating System or an Application on Windows platform. + 3. The should be specific and individual, you should not provide different options. + 4. You should keep clear and objective, any vague vocabulary or any subjective terms are forbidden. + 5. You should try your best not to make the become verbose, can only add up to 50 words into . + 6. The detailed target in should be specific and clear based on the doc canvas content and control information. + 7. The should be able to implemented by the available controls and actions. + + + ## The requirements for + 1. The should be step-by-step actions to take in the doc file environment. + 2. Each action should be in the available actions from . + 3. Each action should be generated with a "step" description which is the function description of the action. + 4. No need to explain the purpose of the action, just give the actions to take. + 5. Each plan should focus on a single action, if multiple actions need to be performed, you should separate them into different steps. + + ## Response Format + - You are required to response in a JSON format, consisting of several distinct parts with the following keys and corresponding content: + {{ + "observation": , + "thought": , + "new_task":, + "actions_plan": + }} + + ### Action Call Format + - The action call format is the same as the available actions in the API list.You are required to provide the action call format in a JSON format: + {{ + "step ": + "controlLabel": . If you believe none of the control item is suitable for the task or the task is complete, kindly output a empty string ''.> + "controlText": .The control text must match exactly with the selected control label. + If the function to call don't need specify controlText or the task is complete,you can kindly output an empty string ''. + If the function to call need to specify controlText and none of the control item is suitable for the task,you should input a possible control name.> + "function": + "args": + }} + + e.g. + {{ + "step 1": "change the borders", + "controlLabel": "", + "controlText": "Borders", + "function": "click_input", + "args": {{ + "button": "left", + "double": false + }} + }} + + {{ + "step 2": "change the borders", + "controlLabel": "101", + "controlText": "Borders", + "function": "click_input", + "args": {{ + "control_id": "101", + "button": "left", + "double": false + }} + }} + + {{ + "step 3": "select the target text", + "controlLabel": "", + "controlText": "", + "function": "select_text", + "args": {{ + "text": "Test For Fun" + }} + }} + + - The field must be strictly in a format separated each action call by "\n". The list format should be like this: + "action call 1\naction call 2\naction call 3" + - If you think the original task don't need to be detailed, you can directly copy the original task to the "new_task". + - You should review the apis function carefully and if the function to call need to specify target control,the 'controlText' field + cannot be set empty. + - The "step" description should be consistent with the action and also the thought. + + ## Here are some examples for you to complete the user request: + {examples} + + ## Tips + - Read the above instruction carefully. Make sure the response and action strictly following these instruction and meet the user request. + - Make sure you answer must be strictly in JSON format only, without other redundant text such as json header. Your output must be able to be able to be parsed by json.loads(). Otherwise, it will crash the system and destroy the user's computer. + - Your task is very important to improve the agent's performance. I will tip you 200$ if you do well. Thank you for your hard work! + +user: |- + {given_task} + {reference_steps} + {doc_control_state} + \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/prefill_example.yaml b/instantiation/controller/prompts/visual/prefill_example.yaml new file mode 100644 index 00000000..a50635ea --- /dev/null +++ b/instantiation/controller/prompts/visual/prefill_example.yaml @@ -0,0 +1,44 @@ + +version: 1.0 + +example1: + Request: |- + Delete Text in document. + {'w:document': {'w:body': {'w:p': [{'w:r': {'@w:rsidRPr': '00E2735E', 'w:rPr': {'w:rFonts': {'@w:ascii': 'Consolas', '@w:eastAsia': 'Times New Roman', '@w:hAnsi': 'Consolas', '@w:cs': 'Times New Roman'}, 'w:sz': {'@w:val': '21'}, 'w:szCs': {'@w:val': '21'}, 'w:lang': {'@w:eastAsia': 'zh-CN'}, 'w:color': '000000'}, 'w:t': 'text to edit'}}]}}} + Response: + observation: |- + I observe the canvas state is a Word document with a body containing a paragraph with a run element, which has a text element 'text to edit'. + thought: |- + My task is to detail the given task and give the step-by-step actions to take. + The user needs to delete text in the Word document. + Based on the canvas state, there is a text element 'text to edit'. + And based on the available apis and controls,the user can use "select_text" to select the target to delete,and "type_keys" to type in delete. + Therefore,the user can detail the task to delete 'text to edit' in the Word document. + In this case, the user should select the text to edit in the Word document and press the 'Delete' key on the keyboard to delete the selected text. + new_task: |- + Delete the 'text to edit' in the Word document. + action_plans: |- + {{"step 1":"choose the target text 'text to edit'","controlLabel": "", "controlText": "", "function": "select_text", "args": {{"text": "text to edit"}}}} + {{"step 2":"type in delete keys to finish delete","controlLabel": "101", "controlText": "Edit", "function": "type_keys", "args": {{"text": "{DELETE}"}}}} + +example2: + Request: |- + Highlight Text in document. + {'w:document': {'w:body': {'w:p': [{'w:r': {'@w:rsidRPr': '00E2735E', 'w:rPr': {'w:rFonts': {'@w:ascii': 'Consolas', '@w:eastAsia': 'Times New Roman', '@w:hAnsi': 'Consolas', '@w:cs': 'Times New Roman'}, 'w:sz': {'@w:val': '21'}, 'w:szCs': {'@w:val': '21'}, 'w:lang': {'@w:eastAsia': 'zh-CN'}, 'w:color': '000000'}, 'w:t': 'text to edit'}}]}}} + Response: + observation: |- + I observe the canvas state is a Word document with a body containing a paragraph with a run element, which has a text element 'text to edit'. + thought: |- + My task is to detail the given task and give the step-by-step actions to take. + The user needs to highlight text in the Word document. + Based on the canvas state, there is a text element 'text to edit'. + And based on the available apis and controls,the user can use "select_text" to select the target to highlight and then to highlight the text. + Since there is no "Highlight" button available,I should click to the 'Home' tab first and then click the 'Highlight' button. + Therefore,the user can detail the task to highlight 'text to edit' in the Word document. + In this case, the user should select the 'text to edit' in the Word document and press the 'Home' button and 'Highlight' button respectively. + new_task: |- + Highlight 'text to edit' in the Word document. + action_plans: |- + {{"step 1":"choose the target text 'text to edit'","controlLabel": "", "controlText": "", "function": "select_text", "args": {{"text": "text to edit"}}}} + {{"step 2":"change ribbon to Home to show the highlight button","controlLabel": "11", "controlText": "Home", "function": "click_input", "args": {{"button": "left", "double": false}}}} + {{"step 3":"click the highlight button to finish highlight","controlLabel": "", "controlText": "Highlight", "function": "click_input", "args": {{"button": "left", "double": false}}}} diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index d6742d3e..6417d28a 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -1,6 +1,7 @@ -# from instantiation.controller.agent.agent import FilterAgent import json import os +import random +import warnings from datetime import datetime from typing import Dict @@ -16,175 +17,158 @@ class ChooseTemplateFlow: + _SENTENCE_TRANSFORMERS_PREFIX = "sentence-transformers/" + def __init__(self, task_object: TaskObject): """ - :param task_dir_name: Folder name of the task, specific for one process. - :param task_json_object: Json object, which is the json file of the task. - :param task_path_object: Path object, which is related to the path of the task. + Initialize the flow with the given task object. + :param task_object: An instance of TaskObject, representing the task context. """ - self.task_object = task_object - self.embedding_model = ChooseTemplateFlow.load_embedding_model( + self._embedding_model = self._load_embedding_model( model_name=configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"] ) - def execute(self): + def execute(self) -> str: """ - Execute the flow. + Execute the flow and return the copied template path. + :return: The path to the copied template file. """ + template_copied_path=self._choose_template_and_copy() - self.chose_template_and_copy() + return template_copied_path - def create_cache_file( + def _create_copied_file( self, copy_from_path: str, copy_to_folder_path: str, file_name: str = None ) -> str: """ - According to the original path, create a cache file. + Create a cache file from the specified source. :param copy_from_path: The original path of the file. - :param copy_to_folder_path: The path of the cache file. - :param file_name: The name of the task file. - :return: The template path string as a new created file. + :param copy_to_folder_path: The path where the cache file will be created. + :param file_name: Optional; the name of the task file. + :return: The path to the newly created cache file. """ - - # Create the folder if not exists. - if not os.path.exists(copy_to_folder_path): - os.makedirs(copy_to_folder_path) + os.makedirs(copy_to_folder_path, exist_ok=True) time_start = datetime.now() template_extension = self.task_object.app_object.file_extension + if not file_name: - cached_template_path = os.path.join( - # current_path, + copied_template_path = os.path.join( copy_to_folder_path, time_start.strftime("%Y-%m-%d-%H-%M-%S") + template_extension, ) else: - cached_template_path = os.path.join( + copied_template_path = os.path.join( copy_to_folder_path, file_name + template_extension ) with open(copy_from_path, "rb") as f: ori_content = f.read() - with open(cached_template_path, "wb") as f: + with open(copied_template_path, "wb") as f: f.write(ori_content) - return cached_template_path + + return copied_template_path - def get_chosen_file_path(self) -> str: + def _get_chosen_file_path(self) -> str: """ - Choose the most relative template file. - :return: The most relative template file path string. + Choose the most relevant template file based on the task. + :return: The path to the most relevant template file. """ - - # get the description of the templates templates_description_path = os.path.join( configs["TEMPLATE_PATH"], self.task_object.app_object.description.lower(), "description.json", ) - # Check if the description.json file exists try: with open(templates_description_path, "r") as f: templates_file_description = json.load(f) except FileNotFoundError: - print( + warnings.warn( f"Warning: {templates_description_path} does not exist. Choosing a random template." ) - - # List all available template files + template_folder = os.path.join( + configs["TEMPLATE_PATH"], + self.task_object.app_object.description.lower(), + ) template_files = [ f - for f in os.listdir( - os.path.join( - configs["TEMPLATE_PATH"], - self.task_object.app_object.description.lower(), - ) - ) - if os.path.isfile( - os.path.join( - configs["TEMPLATE_PATH"], - self.task_object.app_object.description.lower(), - f, - ) - ) + for f in os.listdir(template_folder) + if os.path.isfile(os.path.join(template_folder, f)) ] - # If no templates are found, raise an exception if not template_files: raise Exception("No template files found in the specified directory.") - # Randomly select one of the available template files chosen_template_file = random.choice(template_files) print(f"Randomly selected template: {chosen_template_file}") return chosen_template_file - templates_file_description = json.load(open(templates_description_path, "r")) - - # get the chosen file path - chosen_file_path = self.chose_target_template_file( + chosen_file_path = self._choose_target_template_file( self.task_object.task, templates_file_description ) - - # Print the chosen template for the user print(f"Chosen template file: {chosen_file_path}") return chosen_file_path - def chose_template_and_copy(self) -> str: + def _choose_template_and_copy(self) -> str: """ Choose the template and copy it to the cache folder. + :return: The path to the copied template file. """ - - # Get the chosen template file path. - chosen_template_file_path = self.get_chosen_file_path() + chosen_template_file_path = self._get_chosen_file_path() chosen_template_full_path = os.path.join( configs["TEMPLATE_PATH"], self.task_object.app_object.description.lower(), chosen_template_file_path, ) - # Get the target template folder path. target_template_folder_path = os.path.join( configs["TASKS_HUB"], self.task_object.task_dir_name + "_templates" ) - # Copy the template to the cache folder. - template_cached_path = self.create_cache_file( + template_copied_path = self._create_copied_file( chosen_template_full_path, target_template_folder_path, self.task_object.task_file_name, ) - self.task_object.instantial_template_path = template_cached_path + self.task_object.instantial_template_path = template_copied_path - return template_cached_path + return template_copied_path - def chose_target_template_file( + def _choose_target_template_file( self, given_task: str, doc_files_description: Dict[str, str] - ): + ) -> str: """ - Get the target file based on the semantic similarity of given task and the template file decription. - :param given_task: The given task. - :param doc_files_description: The description of the template files. - :return: The target file path. + Get the target file based on the semantic similarity of the given task and the template file descriptions. + :param given_task: The task to be matched. + :param doc_files_description: A dictionary of template file descriptions. + :return: The path to the chosen template file. """ - - candidates = [ - doc_file_description - for doc, doc_file_description in doc_files_description.items() - ] + candidates = list(doc_files_description.values()) file_doc_descriptions = { doc_file_description: doc for doc, doc_file_description in doc_files_description.items() } - # use FAISS to get the top k control items texts - db = FAISS.from_texts(candidates, self.embedding_model) + + db = FAISS.from_texts(candidates, self._embedding_model) doc_descriptions = db.similarity_search(given_task, k=1) + + if not doc_descriptions: + raise Exception("No similar templates found.") + doc_description = doc_descriptions[0].page_content doc = file_doc_descriptions[doc_description] return doc @staticmethod - def load_embedding_model(model_name: str): + def _load_embedding_model(model_name: str): + """ + Load the embedding model. + :param model_name: The name of the embedding model to load. + :return: The loaded embedding model. + """ store = LocalFileStore(configs["CONTROL_EMBEDDING_CACHE_PATH"]) - if not model_name.startswith("sentence-transformers"): - model_name = "sentence-transformers/" + model_name + if not model_name.startswith(ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX): + model_name = ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX + model_name embedding_model = HuggingFaceEmbeddings(model_name=model_name) cached_embedder = CacheBackedEmbeddings.from_bytes_store( embedding_model, store, namespace=model_name diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index 3f9eb62f..a0722ae6 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -1,12 +1,14 @@ import json import os +import time from typing import Dict, Tuple +from venv import logger from instantiation.config.config import Config from instantiation.controller.agent.agent import FilterAgent from ufo.module.basic import BaseSession -configs = Config.get_instance().config_data +_configs = Config.get_instance().config_data class FilterFlow: @@ -14,8 +16,7 @@ class FilterFlow: The class to refine the plan steps and prefill the file. """ - # A dictionary to store filter agents for each app. - app_filter_agent_dict: Dict[str, FilterAgent] = dict() + _app_filter_agent_dict: Dict[str, FilterAgent] = dict() def __init__(self, task_object: object) -> None: """ @@ -23,131 +24,98 @@ def __init__(self, task_object: object) -> None: :param task_object: The task object containing task details. """ self.task_object = task_object - self.app_name = self.task_object.app_object.description.lower() + self._app_name = task_object.app_object.description.lower() + self._log_path_configs = _configs["FILTER_LOG_PATH"].format( + task=self.task_object.task_file_name + ) + self._filter_agent = self._get_or_create_filter_agent() + self._initialize_logs() - # If no filter agent exists for the app, create one and store it in the dictionary. - if FilterFlow.app_filter_agent_dict.get(self.app_name) is None: - FilterFlow.app_filter_agent_dict[self.app_name] = FilterAgent( + def _get_or_create_filter_agent(self) -> FilterAgent: + """ + Retrieve or create a filter agent for the application. + :return: The filter agent for the application. + """ + if self._app_name not in FilterFlow._app_filter_agent_dict: + FilterFlow._app_filter_agent_dict[self._app_name] = FilterAgent( "filter", - self.app_name, + self._app_name, is_visual=True, - main_prompt=configs["FILTER_PROMPT"], + main_prompt=_configs["FILTER_PROMPT"], example_prompt="", - api_prompt=configs["API_PROMPT"], + api_prompt=_configs["API_PROMPT"], ) - self.filter_agent = FilterFlow.app_filter_agent_dict[self.app_name] - - # Set up log paths and create directories if necessary. - self.log_path_configs = configs["FILTER_LOG_PATH"].format( - task=self.task_object.task_file_name - ) - os.makedirs(self.log_path_configs, exist_ok=True) - - # Initialize loggers for request messages and responses. - self.filter_message_logger = BaseSession.initialize_logger( - self.log_path_configs, "filter_messages.json", "w", configs - ) - self.filter_response_logger = BaseSession.initialize_logger( - self.log_path_configs, "filter_responses.json", "w", configs - ) + return FilterFlow._app_filter_agent_dict[self._app_name] - def execute(self) -> None: + def execute(self, task_object: object = None) -> bool: """ Execute the filter flow: Filter the task and save the result. + :param task_object: Optional task object, used for external task flow passing. + :return: True if the task quality is good, False otherwise. """ + if task_object: + self.task_object = task_object - self.get_task_filtered() - self.save_instantiated_task() + filtered_task_attributes = self._get_task_filtered() + return filtered_task_attributes["is_quality_good"] - def get_filter_res(self) -> Tuple[bool, str, str]: + def _initialize_logs(self) -> None: + """ + Initialize logging for filter messages and responses. + """ + os.makedirs(self._log_path_configs, exist_ok=True) + self._filter_message_logger = BaseSession.initialize_logger( + self._log_path_configs, "filter_messages.json", "w", _configs + ) + self._filter_response_logger = BaseSession.initialize_logger( + self._log_path_configs, "filter_responses.json", "w", _configs + ) + + def _get_filter_res(self) -> Tuple[bool, str, str]: """ Get the filtered result from the filter agent. :return: A tuple containing whether the request is good, the request comment, and the request type. """ - # app_name = self.task_object.app_object.app_name - prompt_message = self.filter_agent.message_constructor( + prompt_message = self._filter_agent.message_constructor( self.task_object.instantiated_request, self.task_object.app_object.description.lower(), ) - try: - # Log the prompt message - self.filter_message_logger.info(prompt_message) + prompt_json = json.dumps(prompt_message, indent=4) + self._filter_message_logger.info(prompt_json) - response_string, cost = self.filter_agent.get_response( - prompt_message, "filter", use_backup_engine=True, configs=configs + try: + start_time = time.time() + response_string, _ = self._filter_agent.get_response( + prompt_message, "filter", use_backup_engine=True, configs=_configs ) - # Log the response string - self.filter_response_logger.info(response_string) - - # Convert the response to a dictionary and extract information. - response_json = self.filter_agent.response_to_dict(response_string) - request_quality_is_good = response_json["judge"] - request_comment = response_json["thought"] - request_type = response_json["type"] + response_json = self._filter_agent.response_to_dict(response_string) + duration = round(time.time() - start_time, 3) - print("Comment for the instantiation: ", request_comment) - return request_quality_is_good, request_comment, request_type - - except Exception as e: - self.status = "ERROR" - print(f"Error: {e}") - return None + response_json["duration_sec"] = duration + self._filter_response_logger.info(json.dumps(response_json, indent=4)) - def filter_task(self) -> Tuple[bool, str, str]: - """ - Filter the task by sending the request to the filter agent. - :return: A tuple containing whether the request is good, the request comment, and the request type. - """ + return ( + response_json["judge"], + response_json["thought"], + response_json["type"], + ) - try: - return self.get_filter_res() except Exception as e: - print(f"Error in filter_task: {e}") - return False, "", "" - - def get_instance_folder_path(self) -> Tuple[str, str]: - """ - Get the folder paths for passing and failing instances. - :return: A tuple containing the pass and fail folder paths. - """ - - new_folder_path = os.path.join( - configs["TASKS_HUB"], self.task_object.task_dir_name + "_instantiated" - ) - new_folder_pass_path = os.path.join(new_folder_path, "instances_pass") - new_folder_fail_path = os.path.join(new_folder_path, "instances_fail") - return new_folder_pass_path, new_folder_fail_path - - def get_task_filtered(self) -> None: - """ - Filter the task and set the corresponding attributes in the task object. - """ - - request_quality_is_good, request_comment, request_type = self.filter_task() - self.task_object.set_attributes( - request_quality_is_good=request_quality_is_good, - request_comment=request_comment, - request_type=request_type, - ) + logger.exception( + f"Error in _get_filter_res: {str(e)} - Prompt: {prompt_message}", + exc_info=True, + ) + raise - def save_instantiated_task(self) -> None: + def _get_task_filtered(self) -> Dict[str, str]: """ - Save the instantiated task to the pass / fail folder. + Filter the task and return the corresponding attributes. + :return: A dictionary containing filtered task attributes. """ - - # Get the folder path for classified instances. - new_folder_pass_path, new_folder_fail_path = self.get_instance_folder_path() - # Generate the json object of the task. - task_json = self.task_object.to_json() - - # Save the task to the pass / fail folder. - if self.task_object.request_quality_is_good: - new_task_path = os.path.join( - new_folder_pass_path, self.task_object.task_file_base_name - ) - else: - new_task_path = os.path.join( - new_folder_fail_path, self.task_object.task_file_base_name - ) - os.makedirs(os.path.dirname(new_task_path), exist_ok=True) - open(new_task_path, "w").write(json.dumps(task_json)) + request_quality_is_good, request_comment, request_type = self._get_filter_res() + filtered_task_attributes = { + "is_quality_good": request_quality_is_good, + "request_comment": request_comment, + "request_type": request_type, + } + return filtered_task_attributes diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index c9740261..e62c9a4a 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -1,5 +1,7 @@ import json +import logging import os +import time from typing import Any, Dict, List, Tuple from instantiation.config.config import Config @@ -10,221 +12,240 @@ from ufo.automator.ui_control.screenshot import PhotographerFacade from ufo.module.basic import BaseSession -configs = Config.get_instance().config_data -if configs is not None: - BACKEND = configs["CONTROL_BACKEND"] +# Load configuration data +_configs = Config.get_instance().config_data +if _configs: + _BACKEND = _configs["CONTROL_BACKEND"] class PrefillFlow(AppAgentProcessor): """ - The class to refine the plan steps and prefill the file. + Manages the prefill flow by refining plan steps and interacting with the UI using automation tools. """ - app_prefill_agent_dict: Dict = dict() + _app_prefill_agent_dict: Dict[str, PrefillAgent] = {} - def __init__(self, task_object, environment: WindowsAppEnv = None) -> None: + def __init__(self, task_object: Any, environment: WindowsAppEnv = None) -> None: """ - Initialize the follow flow. + Initialize the prefill flow. :param task_object: The object containing task details (should have app_object and task_file_name). :param environment: The environment of the app (optional). """ - self.task_object = task_object - self.app_name = task_object.app_object.description.lower() - self.task_file_name = task_object.task_file_name - - if environment is None: - from instantiation.controller.env.env_manager import WindowsAppEnv - - self.app_env = WindowsAppEnv(task_object.app_object) - else: - self.app_env = environment + self._app_name = task_object.app_object.description.lower() + self._task_file_name = task_object.task_file_name + self._app_env = environment or WindowsAppEnv(task_object.app_object) - # Create the action prefill agent - if self.app_name not in PrefillFlow.app_prefill_agent_dict: - self.app_prefill_agent_dict[self.app_name] = PrefillAgent( + # Create or reuse a PrefillAgent for the app + if self._app_name not in PrefillFlow._app_prefill_agent_dict: + PrefillFlow._app_prefill_agent_dict[self._app_name] = PrefillAgent( "prefill", - self.app_name, + self._app_name, is_visual=True, - main_prompt=configs["PREFILL_PROMPT"], - example_prompt=configs["PREFILL_EXAMPLE_PROMPT"], - api_prompt=configs["API_PROMPT"], + main_prompt=_configs["PREFILL_PROMPT"], + example_prompt=_configs["PREFILL_EXAMPLE_PROMPT"], + api_prompt=_configs["API_PROMPT"], ) - self.prefill_agent = PrefillFlow.app_prefill_agent_dict[self.app_name] + self._prefill_agent = PrefillFlow._app_prefill_agent_dict[self._app_name] - self.file_path = "" + # Initialize execution step and UI control tools + self._execute_step = 0 + self._control_inspector = ControlInspectorFacade(_BACKEND) + self._photographer = PhotographerFacade() - self.execute_step = 0 - self.control_inspector = ControlInspectorFacade(BACKEND) - self.photographer = PhotographerFacade() - - self.control_state = None - self.custom_doc = None - self.status = "" - self.file_path = "" - self.control_annotation = None + # Set default states + self._status = "" # Initialize loggers for messages and responses - self.log_path_configs = configs["PREFILL_LOG_PATH"].format( - task=self.task_file_name + self._log_path_configs = _configs["PREFILL_LOG_PATH"].format( + task=self._task_file_name ) - os.makedirs(self.log_path_configs, exist_ok=True) + os.makedirs(self._log_path_configs, exist_ok=True) - self.message_logger = BaseSession.initialize_logger( - self.log_path_configs, "prefill_messages.json", "w", configs + # Set up loggers + self._message_logger = BaseSession.initialize_logger( + self._log_path_configs, "prefill_messages.json", "w", _configs ) - self.response_logger = BaseSession.initialize_logger( - self.log_path_configs, "prefill_responses.json", "w", configs + self._response_logger = BaseSession.initialize_logger( + self._log_path_configs, "prefill_responses.json", "w", _configs ) - def execute(self) -> None: + def execute(self, template_copied_path: str) -> Dict[str, Any]: """ - Execute the prefill flow by retrieving the instantiated result. + Start the execution by retrieving the instantiated result. + :param template_copied_path: The path of the copied template to use. + :return: The updated task object after execution. """ + self._instantiate_task(template_copied_path) + return self.task_object - self.get_instantiated_result() - - def get_instantiated_result(self) -> None: + def _instantiate_task(self, template_copied_path: str) -> None: """ - Get the instantiated result for the task. - - This method interacts with the PrefillAgent to get the refined task and action plans. + Retrieve and process the instantiated result for the task. + Interacts with the PrefillAgent to refine the task and generate action plans. + :param template_copied_path: The path of the copied template to use. """ - template_cached_path = self.task_object.instantial_template_path - self.app_env.start(template_cached_path) + self._app_env.start(template_copied_path) + try: - instantiated_request, instantiated_plan = self.get_prefill_actions( + # Retrieve prefill actions and task plan + instantiated_request, instantiated_plan = self._get_prefill_actions( self.task_object.task, self.task_object.refined_steps, - template_cached_path, + template_copied_path, ) print(f"Original Task: {self.task_object.task}") print(f"Prefilled Task: {instantiated_request}") + + # Update task object attributes self.task_object.set_attributes( instantiated_request=instantiated_request, instantiated_plan=instantiated_plan, ) except Exception as e: - print(f"Error! get_instantiated_result: {e}") + logging.exception(f"Error in prefilling task: {e}") + raise + finally: - self.app_env.close() + self._app_env.close() - def update_state(self, file_path: str) -> None: + def _update_state(self, file_path: str) -> None: """ - Get current states of app with pywinauto、win32com - - :param file_path: The file path of the app. + Update the current state of the app by inspecting UI elements. + :param file_path: Path of the app file to inspect. """ - print(f"updating the state of app file: {file_path}") + print(f"Updating the app state using the file: {file_path}") - control_list = self.control_inspector.find_control_elements_in_descendants( - self.app_env.app_window, - control_type_list=configs["CONTROL_LIST"], - class_name_list=configs["CONTROL_LIST"], + # Retrieve control elements in the app window + control_list = self._control_inspector.find_control_elements_in_descendants( + self._app_env.app_window, + control_type_list=_configs["CONTROL_LIST"], + class_name_list=_configs["CONTROL_LIST"], ) - self._annotation_dict = self.photographer.get_annotation_dict( - self.app_env.app_window, control_list, annotation_type="number" + + # Capture UI control annotations + self._annotation_dict = self._photographer.get_annotation_dict( + self._app_env.app_window, control_list, annotation_type="number" ) - # Filter out irrelevant control items based on the previous plan. - self.filtered_annotation_dict = self.get_filtered_annotation_dict( - self._annotation_dict, configs=configs + # Filter out irrelevant control elements + self._filtered_annotation_dict = self.get_filtered_annotation_dict( + self._annotation_dict, configs=_configs ) - self._control_info = self.control_inspector.get_control_info_list_of_dict( + # Gather control info for both full and filtered lists + self._control_info = self._control_inspector.get_control_info_list_of_dict( self._annotation_dict, - ["control_text", "control_type" if BACKEND == "uia" else "control_class"], + ["control_text", "control_type" if _BACKEND == "uia" else "control_class"], ) - self.filtered_control_info = ( - self.control_inspector.get_control_info_list_of_dict( - self.filtered_annotation_dict, + self._filtered_control_info = ( + self._control_inspector.get_control_info_list_of_dict( + self._filtered_annotation_dict, [ "control_text", - "control_type" if BACKEND == "uia" else "control_class", + "control_type" if _BACKEND == "uia" else "control_class", ], ) ) - - def log_prefill_agent_info( - self, - messages: List[Dict[str, Any]], - agent_response: Dict[str, Any], - error: str = "", - ) -> None: - """ - Record the prefill information. - - :param messages: The messages of the conversation. - :param agent_response: The response of the agent. - :param error: The error message. - """ - - # Log message - messages_log_entry = { - "step": self.execute_step, - "messages": messages, - "error": error, - } - self.message_logger.info(json.dumps(messages_log_entry)) - - # Log response - response_log_entry = { - "step": self.execute_step, - "agent_response": agent_response, - "error": error, - } - self.response_logger.info(json.dumps(response_log_entry)) - - def get_prefill_actions( + def _get_prefill_actions( self, given_task: str, reference_steps: List[str], file_path: str ) -> Tuple[str, List[str]]: """ - Call the PlanRefine Agent to select files - - :param given_task: The given task. - :param reference_steps: The reference steps. - :param file_path: The file path. - :return: The prefilled task and the action plans. + Generate refined tasks and action plans using the PrefillAgent. + :param given_task: The task to refine. + :param reference_steps: Reference steps for the task. + :param file_path: Path to the task template. + :return: The refined task and corresponding action plans. """ + self._update_state(file_path) + + # Save a screenshot of the app state + screenshot_path = os.path.join(self._log_path_configs, "screenshot.png") + self._save_screenshot(self.task_object.task_file_name, screenshot_path) + + # Construct prompt message for the PrefillAgent + prompt_message = self._prefill_agent.message_constructor( + "", + given_task, + reference_steps, + self._filtered_control_info, + self._log_path_configs, + ) - error_msg = "" - self.update_state(file_path) - - # Save the screenshot - screenshot_path = self.log_path_configs + "screenshot.png" - self.app_env.save_screenshot(screenshot_path) + # Log the constructed message + self._log_message(prompt_message) - # filter the controls - filter_control_state = self.filtered_control_info - # filter the apis - prompt_message = self.prefill_agent.message_constructor( - "", given_task, reference_steps, filter_control_state, self.log_path_configs - ) try: - response_string, cost = self.prefill_agent.get_response( - prompt_message, - "prefill", - use_backup_engine=True, - configs=configs, + # Record start time and get PrefillAgent response + start_time = time.time() + response_string, _ = self._prefill_agent.get_response( + prompt_message, "prefill", use_backup_engine=True, configs=_configs ) - response_json = self.prefill_agent.response_to_dict(response_string) + end_time = time.time() + + # Parse and log the response + response_json = self._prefill_agent.response_to_dict(response_string) new_task = response_json["new_task"] action_plans = response_json["actions_plan"] except Exception as e: - self.status = "ERROR" - error_msg = str(e) - self.log_prefill_agent_info( - prompt_message, {"PrefillAgent": response_json}, error_msg - ) - - return None, None - else: - self.log_prefill_agent_info( - prompt_message, {"PrefillAgent": response_json}, error_msg - ) + self._status = "ERROR" + logging.exception(f"Error in prefilling task: {e}") + raise + finally: + # Log the response and execution time + duration_sec = end_time - start_time + self._log_response(response_json, duration_sec) return new_task, action_plans + + def _log_message(self, prompt_message: str) -> None: + """ + Log the constructed prompt message for the PrefillAgent. + :param prompt_message: The message constructed for PrefillAgent. + """ + messages_log_entry = { + "step": self._execute_step, + "messages": prompt_message, + "error": "", + } + self._message_logger.info(json.dumps(messages_log_entry, indent=4)) + + def _log_response(self, response_json: Dict[str, Any], duration_sec: float) -> None: + """ + Log the response received from PrefillAgent along with execution duration. + :param response_json: Response data from PrefillAgent. + :param duration_sec: Time taken for the PrefillAgent call. + """ + response_log_entry = { + "step": self._execute_step, + "duration_sec": duration_sec, + "agent_response": response_json, + "error": "", + } + self._response_logger.info(json.dumps(response_log_entry, indent=4)) + + def _save_screenshot(self, doc_name: str, save_path: str) -> None: + """ + Captures a screenshot of the current window or the full screen if the window is not found. + :param doc_name: The name or description of the document to match the window. + :param save_path: The path where the screenshot will be saved. + """ + try: + matched_window = self._app_env.find_matching_window(doc_name) + if matched_window: + screenshot = self._photographer.capture_app_window_screenshot( + matched_window + ) + else: + logging.warning("Window not found, taking a full-screen screenshot.") + screenshot = self._photographer.capture_desktop_screen_screenshot() + + screenshot.save(save_path) + print(f"Screenshot saved to {save_path}") + except Exception as e: + logging.exception(f"Failed to save screenshot: {e}") + raise diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index 952eea25..0376eb9b 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -4,9 +4,14 @@ import argparse import glob import json +import logging import os import sys +import time +import traceback +import textwrap from enum import Enum +from typing import Tuple # Add the project root to the system path. current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -20,57 +25,54 @@ # Parse the arguments. args = argparse.ArgumentParser() -args.add_argument( - "--task", help="The name of the task.", type=str, default="prefill" -) +args.add_argument("--task", help="The name of the task.", type=str, default="prefill") parsed_args = args.parse_args() +logging.basicConfig( + level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" +) + + class AppEnum(Enum): """ - Define the apps can be used in the instantiation. + Define the apps that can be used in the instantiation. """ WORD = 1, "Word", ".docx", "winword" EXCEL = 2, "Excel", ".xlsx", "excel" POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" - def __init__(self, id : int, description : str, file_extension : str, win_app : str): + def __init__(self, id: int, description: str, file_extension: str, win_app: str): """ :param id: The unique id of the app. - :param description: The description of the app. Example: Word, Excel, PowerPoint. - :param file_extension: The file extension of the app. Example: .docx, .xlsx, .pptx. - :param win_app: The windows app name of the app. Example: winword, excel, powerpnt. + :param description: The description of the app. + :param file_extension: The file extension of the app. + :param win_app: The windows app name of the app. """ - self.id = id self.description = description self.file_extension = file_extension self.win_app = win_app - # The root name of the app to be used in opening and closing app window. self.app_root_name = win_app.upper() + ".EXE" -class TaskObject(): +class TaskObject: """ The task object from the json file. """ - def __init__(self, task_dir_name : str, task_file: str) -> None: + def __init__(self, task_dir_name: str, task_file: str) -> None: """ Initialize the task object from the json file. + :param task_dir_name: The name of the directory containing the task. :param task_file: The task file to load from. """ - self.task_dir_name = task_dir_name self.task_file = task_file - # The folder name of the task, specific for one process. Example: prefill. self.task_file_dir_name = os.path.dirname(os.path.dirname(task_file)) - # The base name of the task file. Example: task_1.json. self.task_file_base_name = os.path.basename(task_file) - # The task name of the task file without extension. Example: task_1. self.task_file_name = self.task_file_base_name.split(".")[0] - - # Load the json file and get the app object. + with open(task_file, "r") as f: task_json_file = json.load(f) self.app_object = self.choose_app_from_json(task_json_file) @@ -78,7 +80,6 @@ def __init__(self, task_dir_name : str, task_file: str) -> None: for key, value in task_json_file.items(): setattr(self, key.lower().replace(" ", "_"), value) - # The fields to be saved in the json file. self.json_fields = [ "unique_id", "instantiated_request", @@ -93,74 +94,157 @@ def choose_app_from_json(self, task_json_file: dict) -> AppEnum: :param task_json_file: The JSON file of the task. :return: The app object. """ - for app in AppEnum: - app_name = app.description.lower() - json_app_name = task_json_file["app"].lower() - if app_name == json_app_name: + if app.description.lower() == task_json_file["app"].lower(): return app raise ValueError("Not a correct App") - def to_json(self) -> dict: """ Convert the object to a JSON object. :return: The JSON object. """ - json_data = {} - for key in self.json_fields: - if hasattr(self, key): - json_data[key] = getattr(self, key) - return json_data + return { + key: getattr(self, key) for key in self.json_fields if hasattr(self, key) + } def set_attributes(self, **kwargs) -> None: """ Add all input fields as attributes. :param kwargs: The fields to be added. """ - for key, value in kwargs.items(): setattr(self, key, value) class InstantiationProcess: """ - Key process to instantialize the task. + Key process to instantiate the task. Control the process of the task. - """ + """ def instantiate_files(self, task_dir_name: str) -> None: """ + Instantiate all the task files. + :param task_dir_name: The name of the task directory. """ - all_task_file_path: str = os.path.join(configs["TASKS_HUB"], task_dir_name, "*") all_task_files = glob.glob(all_task_file_path) for index, task_file in enumerate(all_task_files, start=1): print(f"Task starts: {index} / {len(all_task_files)}") try: - task_object = TaskObject(task_dir_name, task_file) self.instantiate_single_file(task_object) except Exception as e: - print(f"Error in task {index} with file {task_file}: {e}") + logging.exception(f"Error in task {index}: {str(e)}") + traceback.print_exc() print("All tasks have been processed.") - def instantiate_single_file(self, task_object : TaskObject) -> None: + def instantiate_single_file(self, task_object: TaskObject) -> None: """ Execute the process for one task. :param task_object: The TaskObject containing task details. - The execution includes getting the instantiated result, evaluating the task and saving the instantiated task. """ - - from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow - from instantiation.controller.workflow.prefill_flow import PrefillFlow + from instantiation.controller.workflow.choose_template_flow import ( + ChooseTemplateFlow, + ) from instantiation.controller.workflow.filter_flow import FilterFlow + from instantiation.controller.workflow.prefill_flow import PrefillFlow + + try: + start_time = time.time() + + # Measure time for ChooseTemplateFlow + start_choose_template_time = time.time() + template_copied_path = ChooseTemplateFlow(task_object).execute() + choose_template_duration = time.time() - start_choose_template_time + + # Measure time for PrefillFlow + start_prefill_time = time.time() + instantiated_task = PrefillFlow(task_object).execute(template_copied_path) + prefill_duration = time.time() - start_prefill_time + + # Measure time for FilterFlow + start_filter_time = time.time() + is_quality_good = FilterFlow(task_object).execute(instantiated_task) + filter_duration = time.time() - start_filter_time + + # Calculate total duration + total_duration = time.time() - start_time - ChooseTemplateFlow(task_object).execute() - PrefillFlow(task_object).execute() - FilterFlow(task_object).execute() + durations = { + "choose_template": choose_template_duration, + "prefill": prefill_duration, + "filter": filter_duration, + "total": total_duration, + } + + self._save_instantiated_task(task_object, is_quality_good, durations) + + except Exception as e: + logging.exception(f"Error processing task: {str(e)}") + self.log_error(task_object.task_file_base_name, str(e)) + raise + + + def _save_instantiated_task( + self, task_object: TaskObject, is_quality_good: bool, durations: dict + ) -> None: + """ + Save the instantiated task along with duration information to the pass/fail folder. + :param task_object: The task object to save. + :param is_quality_good: Indicator of whether the task quality is good. + :param durations: A dictionary containing duration information for each flow. + """ + pass_path, fail_path = self._get_instance_folder_path() + task_json = task_object.to_json() + task_json["duration_sec"] = durations + + target_folder = pass_path if is_quality_good else fail_path + new_task_path = os.path.join(target_folder, task_object.task_file_base_name) + + os.makedirs(os.path.dirname(new_task_path), exist_ok=True) + + with open(new_task_path, "w", encoding="utf-8") as f: + json.dump(task_json, f, ensure_ascii=False) + + def _get_instance_folder_path(self) -> Tuple[str, str]: + """ + Get the folder paths for passing and failing instances. + :return: A tuple containing the pass and fail folder paths. + """ + instance_folder = os.path.join(configs["TASKS_HUB"], "prefill_instantiated") + pass_folder = os.path.join(instance_folder, "instances_pass") + fail_folder = os.path.join(instance_folder, "instances_fail") + return pass_folder, fail_folder + + def log_error(self, task_file_base_name: str, message: str) -> None: + """ + Log the error message with traceback to a specified file in JSON format. + :param task_file_base_name: The name of the task for the log filename. + :param message: The error message to log. + """ + error_folder = os.path.join( + configs["TASKS_HUB"], "prefill_instantiated", "instances_error" + ) + os.makedirs(error_folder, exist_ok=True) + + # Ensure the file name has the .json extension + error_file_path = os.path.join(error_folder, task_file_base_name) + + # Use splitlines to keep the original line breaks in traceback + formatted_traceback = traceback.format_exc().splitlines() + formatted_traceback = "\n".join(formatted_traceback) + + error_log = { + "error_message": message, + "traceback": formatted_traceback # Keep original traceback line breaks + } + + with open(error_file_path, "w", encoding="utf-8") as f: + json.dump(error_log, f, indent=4, ensure_ascii=False) @@ -168,19 +252,15 @@ def main(): """ The main function to process the tasks. """ - - # Load the configs. from instantiation.config.config import Config - config_path = ( - os.path.normpath(os.path.join(current_dir, "config/")) + "\\" - ) + config_path = os.path.normpath(os.path.join(current_dir, "config/")) + "\\" global configs configs = Config(config_path).get_instance().config_data task_dir_name = parsed_args.task.lower() - InstantiationProcess().instantiate_files(task_dir_name) + if __name__ == "__main__": - main() \ No newline at end of file + main() From 5adb40417092e705ec86f5df774e5b2792769e8d Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Thu, 24 Oct 2024 15:45:39 +0800 Subject: [PATCH 10/30] restructure the timing section --- instantiation/README.md | 2 +- instantiation/config/config_dev.yaml | 3 +- instantiation/controller/agent/agent.py | 8 +- instantiation/controller/env/env_manager.py | 8 +- .../workflow/choose_template_flow.py | 68 +++++----- .../controller/workflow/filter_flow.py | 71 +++++----- .../controller/workflow/prefill_flow.py | 84 +++++++----- instantiation/instantiation.py | 123 ++++++++---------- 8 files changed, 182 insertions(+), 185 deletions(-) diff --git a/instantiation/README.md b/instantiation/README.md index 8bc07e25..961bb179 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -172,7 +172,7 @@ After the process is completed, a new folder named `prefill_instantiated` will b } } ], - "duration_sec": { + "execution_time": { "choose_template": 10.650701761245728, "prefill": 44.23913502693176, "filter": 3.746831178665161, diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index 6fdeb651..2de30d6d 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -7,7 +7,8 @@ OPENAI_API_MODEL: "gpt-4-0125-preview" # The only OpenAI model by now that acce CONTROL_BACKEND: "uia" # The backend for control action CONTROL_LIST: ["Button", "Edit", "TabItem", "Document", "ListItem", "MenuItem", "ScrollBar", "TreeItem", "Hyperlink", "ComboBox", "RadioButton", "DataItem", "Spinner"] PRINT_LOG: False # Whether to print the log -LOG_LEVEL: "DEBUG" # The log level +LOG_LEVEL: "INFO" # The log level +LOG_FORMAT: "%(asctime)s - %(levelname)s - %(message)s" MATCH_STRATEGY: "regex" # The match strategy for the control filter, support 'contains', 'fuzzy', 'regex' PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index 26b834ec..dd5985c5 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -3,10 +3,8 @@ from typing import Dict, List -from instantiation.controller.prompter.agent_prompter import ( - FilterPrompter, - PrefillPrompter -) +from instantiation.controller.prompter.agent_prompter import (FilterPrompter, + PrefillPrompter) from ufo.agents.agent.basic import BasicAgent @@ -165,4 +163,4 @@ def process_comfirmation(self) -> None: Confirm the process. This is the abstract method from BasicAgent that needs to be implemented. """ - pass \ No newline at end of file + pass diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index 1b2ba160..c1382b71 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -2,8 +2,8 @@ import re import time -from pywinauto import Desktop from fuzzywuzzy import fuzz +from pywinauto import Desktop from instantiation.config.config import Config from ufo.automator.puppeteer import ReceiverManager @@ -101,8 +101,10 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: combined_name_2 = f"{doc_name}.*{app_name}" pattern_1 = re.compile(combined_name_1, flags=re.IGNORECASE) pattern_2 = re.compile(combined_name_2, flags=re.IGNORECASE) - return re.search(pattern_1, window_title) is not None or \ - re.search(pattern_2, window_title) is not None + return ( + re.search(pattern_1, window_title) is not None + or re.search(pattern_2, window_title) is not None + ) else: logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index 6417d28a..22b3168f 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -1,6 +1,7 @@ import json import os import random +import time import warnings from datetime import datetime from typing import Dict @@ -11,7 +12,7 @@ from langchain_community.vectorstores import FAISS from instantiation.config.config import Config -from instantiation.instantiation import TaskObject +from instantiation.instantiation import AppEnum configs = Config.get_instance().config_data @@ -19,12 +20,15 @@ class ChooseTemplateFlow: _SENTENCE_TRANSFORMERS_PREFIX = "sentence-transformers/" - def __init__(self, task_object: TaskObject): + def __init__(self, app_object: AppEnum, task_file_name: str): """ - Initialize the flow with the given task object. - :param task_object: An instance of TaskObject, representing the task context. + Initialize the flow with the given task context. + :param app_object: An instance of AppEnum, representing the application context. + :param task_file_name: The name of the task file. """ - self.task_object = task_object + self._app_object = app_object + self._task_file_name = task_file_name + self.execution_time = 0 self._embedding_model = self._load_embedding_model( model_name=configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"] ) @@ -34,8 +38,9 @@ def execute(self) -> str: Execute the flow and return the copied template path. :return: The path to the copied template file. """ - template_copied_path=self._choose_template_and_copy() - + start_time = time.time() + template_copied_path = self._choose_template_and_copy() + self.execution_time = round(time.time() - start_time, 3) return template_copied_path def _create_copied_file( @@ -50,7 +55,7 @@ def _create_copied_file( """ os.makedirs(copy_to_folder_path, exist_ok=True) time_start = datetime.now() - template_extension = self.task_object.app_object.file_extension + template_extension = self._app_object.file_extension if not file_name: copied_template_path = os.path.join( @@ -61,11 +66,11 @@ def _create_copied_file( copied_template_path = os.path.join( copy_to_folder_path, file_name + template_extension ) - with open(copy_from_path, "rb") as f: - ori_content = f.read() - with open(copied_template_path, "wb") as f: - f.write(ori_content) - + with open(copy_from_path, "rb") as f: + ori_content = f.read() + with open(copied_template_path, "wb") as f: + f.write(ori_content) + return copied_template_path def _get_chosen_file_path(self) -> str: @@ -75,7 +80,7 @@ def _get_chosen_file_path(self) -> str: """ templates_description_path = os.path.join( configs["TEMPLATE_PATH"], - self.task_object.app_object.description.lower(), + self._app_object.description.lower(), "description.json", ) @@ -88,7 +93,7 @@ def _get_chosen_file_path(self) -> str: ) template_folder = os.path.join( configs["TEMPLATE_PATH"], - self.task_object.app_object.description.lower(), + self._app_object.description.lower(), ) template_files = [ f @@ -104,7 +109,7 @@ def _get_chosen_file_path(self) -> str: return chosen_template_file chosen_file_path = self._choose_target_template_file( - self.task_object.task, templates_file_description + self._task_file_name, templates_file_description ) print(f"Chosen template file: {chosen_file_path}") return chosen_file_path @@ -117,20 +122,20 @@ def _choose_template_and_copy(self) -> str: chosen_template_file_path = self._get_chosen_file_path() chosen_template_full_path = os.path.join( configs["TEMPLATE_PATH"], - self.task_object.app_object.description.lower(), + self._app_object.description.lower(), chosen_template_file_path, ) target_template_folder_path = os.path.join( - configs["TASKS_HUB"], self.task_object.task_dir_name + "_templates" + configs["TASKS_HUB"], + os.path.dirname(os.path.dirname(self._task_file_name)) + "_templates", ) template_copied_path = self._create_copied_file( chosen_template_full_path, target_template_folder_path, - self.task_object.task_file_name, + self._task_file_name, ) - self.task_object.instantial_template_path = template_copied_path return template_copied_path @@ -143,24 +148,21 @@ def _choose_target_template_file( :param doc_files_description: A dictionary of template file descriptions. :return: The path to the chosen template file. """ - candidates = list(doc_files_description.values()) - file_doc_descriptions = { - doc_file_description: doc - for doc, doc_file_description in doc_files_description.items() + file_doc_map = { + desc: file_name for file_name, desc in doc_files_description.items() } + db = FAISS.from_texts( + list(doc_files_description.values()), self._embedding_model + ) + most_similar = db.similarity_search(given_task, k=1) - db = FAISS.from_texts(candidates, self._embedding_model) - doc_descriptions = db.similarity_search(given_task, k=1) - - if not doc_descriptions: - raise Exception("No similar templates found.") + if not most_similar: + raise ValueError("No similar templates found.") - doc_description = doc_descriptions[0].page_content - doc = file_doc_descriptions[doc_description] - return doc + return file_doc_map[most_similar[0].page_content] @staticmethod - def _load_embedding_model(model_name: str): + def _load_embedding_model(model_name: str) -> CacheBackedEmbeddings: """ Load the embedding model. :param model_name: The name of the embedding model to load. diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index a0722ae6..7d62e75d 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -1,11 +1,12 @@ import json +import logging import os import time from typing import Dict, Tuple -from venv import logger from instantiation.config.config import Config from instantiation.controller.agent.agent import FilterAgent +from instantiation.instantiation import AppEnum from ufo.module.basic import BaseSession _configs = Config.get_instance().config_data @@ -13,28 +14,27 @@ class FilterFlow: """ - The class to refine the plan steps and prefill the file. + Class to refine the plan steps and prefill the file based on filtering criteria. """ - _app_filter_agent_dict: Dict[str, FilterAgent] = dict() + _app_filter_agent_dict: Dict[str, FilterAgent] = {} - def __init__(self, task_object: object) -> None: + def __init__(self, app_object: AppEnum, task_file_name: str) -> None: """ Initialize the filter flow for a task. - :param task_object: The task object containing task details. + :param app_object: Application object containing task details. + :param task_file_name: Name of the task file being processed. """ - self.task_object = task_object - self._app_name = task_object.app_object.description.lower() - self._log_path_configs = _configs["FILTER_LOG_PATH"].format( - task=self.task_object.task_file_name - ) + self.execution_time = 0 + self._app_name = app_object.description.lower() + self._log_path_configs = _configs["FILTER_LOG_PATH"].format(task=task_file_name) self._filter_agent = self._get_or_create_filter_agent() self._initialize_logs() def _get_or_create_filter_agent(self) -> FilterAgent: """ - Retrieve or create a filter agent for the application. - :return: The filter agent for the application. + Retrieve or create a filter agent for the given application. + :return: FilterAgent instance for the specified application. """ if self._app_name not in FilterFlow._app_filter_agent_dict: FilterFlow._app_filter_agent_dict[self._app_name] = FilterAgent( @@ -47,17 +47,18 @@ def _get_or_create_filter_agent(self) -> FilterAgent: ) return FilterFlow._app_filter_agent_dict[self._app_name] - def execute(self, task_object: object = None) -> bool: + def execute(self, instantiated_request) -> Tuple[bool, str, str]: """ Execute the filter flow: Filter the task and save the result. - :param task_object: Optional task object, used for external task flow passing. - :return: True if the task quality is good, False otherwise. + :param instantiated_request: Request object to be filtered. + :return: Tuple containing task quality flag, comment, and task type. """ - if task_object: - self.task_object = task_object - - filtered_task_attributes = self._get_task_filtered() - return filtered_task_attributes["is_quality_good"] + start_time = time.time() + is_quality_good, request_comment, request_type = self._get_filtered_result( + instantiated_request + ) + self.execution_time = round(time.time() - start_time, 3) + return is_quality_good, request_comment, request_type def _initialize_logs(self) -> None: """ @@ -71,14 +72,15 @@ def _initialize_logs(self) -> None: self._log_path_configs, "filter_responses.json", "w", _configs ) - def _get_filter_res(self) -> Tuple[bool, str, str]: + def _get_filtered_result(self, instantiated_request) -> Tuple[bool, str, str]: """ Get the filtered result from the filter agent. - :return: A tuple containing whether the request is good, the request comment, and the request type. + :param instantiated_request: Request object containing task details. + :return: Tuple containing task quality flag, request comment, and request type. """ prompt_message = self._filter_agent.message_constructor( - self.task_object.instantiated_request, - self.task_object.app_object.description.lower(), + instantiated_request, + self._app_name, ) prompt_json = json.dumps(prompt_message, indent=4) self._filter_message_logger.info(prompt_json) @@ -89,9 +91,9 @@ def _get_filter_res(self) -> Tuple[bool, str, str]: prompt_message, "filter", use_backup_engine=True, configs=_configs ) response_json = self._filter_agent.response_to_dict(response_string) - duration = round(time.time() - start_time, 3) + execution_time = round(time.time() - start_time, 3) - response_json["duration_sec"] = duration + response_json["execution_time"] = execution_time self._filter_response_logger.info(json.dumps(response_json, indent=4)) return ( @@ -101,21 +103,8 @@ def _get_filter_res(self) -> Tuple[bool, str, str]: ) except Exception as e: - logger.exception( - f"Error in _get_filter_res: {str(e)} - Prompt: {prompt_message}", + logging.exception( + f"Error in _get_filtered_result: {str(e)} - Prompt: {prompt_message}", exc_info=True, ) raise - - def _get_task_filtered(self) -> Dict[str, str]: - """ - Filter the task and return the corresponding attributes. - :return: A dictionary containing filtered task attributes. - """ - request_quality_is_good, request_comment, request_type = self._get_filter_res() - filtered_task_attributes = { - "is_quality_good": request_quality_is_good, - "request_comment": request_comment, - "request_type": request_type, - } - return filtered_task_attributes diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index e62c9a4a..d6658667 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -7,6 +7,7 @@ from instantiation.config.config import Config from instantiation.controller.agent.agent import PrefillAgent from instantiation.controller.env.env_manager import WindowsAppEnv +from instantiation.instantiation import AppEnum from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.automator.ui_control.inspector import ControlInspectorFacade from ufo.automator.ui_control.screenshot import PhotographerFacade @@ -25,16 +26,22 @@ class PrefillFlow(AppAgentProcessor): _app_prefill_agent_dict: Dict[str, PrefillAgent] = {} - def __init__(self, task_object: Any, environment: WindowsAppEnv = None) -> None: + def __init__( + self, + app_object: AppEnum, + task_file_name: str, + environment: WindowsAppEnv = None, + ) -> None: """ - Initialize the prefill flow. - :param task_object: The object containing task details (should have app_object and task_file_name). - :param environment: The environment of the app (optional). + Initialize the prefill flow with the application context. + :param app_object: The application enum object representing the app to be automated. + :param task_file_name: The name of the task file for logging and tracking. + :param environment: The environment of the app, defaults to a new WindowsAppEnv if not provided. """ - self.task_object = task_object - self._app_name = task_object.app_object.description.lower() - self._task_file_name = task_object.task_file_name - self._app_env = environment or WindowsAppEnv(task_object.app_object) + self.execution_time = 0 + self._task_file_name = task_file_name + self._app_name = app_object.description.lower() + self._app_env = environment or WindowsAppEnv(app_object) # Create or reuse a PrefillAgent for the app if self._app_name not in PrefillFlow._app_prefill_agent_dict: @@ -70,46 +77,54 @@ def __init__(self, task_object: Any, environment: WindowsAppEnv = None) -> None: self._log_path_configs, "prefill_responses.json", "w", _configs ) - def execute(self, template_copied_path: str) -> Dict[str, Any]: + def execute( + self, template_copied_path: str, original_task: str, refined_steps: List[str] + ) -> Tuple[str, List[str]]: """ Start the execution by retrieving the instantiated result. :param template_copied_path: The path of the copied template to use. - :return: The updated task object after execution. + :param original_task: The original task to refine. + :param refined_steps: The steps to guide the refinement process. + :return: The refined task and corresponding action plans. """ - self._instantiate_task(template_copied_path) - return self.task_object + start_time = time.time() + instantiated_request, instantiated_plan = self._instantiate_task( + template_copied_path, original_task, refined_steps + ) + self.execution_time = round(time.time() - start_time, 3) + return instantiated_request, instantiated_plan - def _instantiate_task(self, template_copied_path: str) -> None: + def _instantiate_task( + self, template_copied_path: str, original_task: str, refined_steps: List[str] + ) -> Tuple[str, List[str]]: """ Retrieve and process the instantiated result for the task. Interacts with the PrefillAgent to refine the task and generate action plans. :param template_copied_path: The path of the copied template to use. + :param original_task: The original task to refine. + :param refined_steps: The steps to guide the refinement process. + :return: The refined task and corresponding action plans. """ self._app_env.start(template_copied_path) try: # Retrieve prefill actions and task plan instantiated_request, instantiated_plan = self._get_prefill_actions( - self.task_object.task, - self.task_object.refined_steps, + original_task, + refined_steps, template_copied_path, ) - print(f"Original Task: {self.task_object.task}") + print(f"Original Task: {original_task}") print(f"Prefilled Task: {instantiated_request}") - # Update task object attributes - self.task_object.set_attributes( - instantiated_request=instantiated_request, - instantiated_plan=instantiated_plan, - ) - except Exception as e: logging.exception(f"Error in prefilling task: {e}") raise finally: self._app_env.close() + return instantiated_request, instantiated_plan def _update_state(self, file_path: str) -> None: """ @@ -161,10 +176,10 @@ def _get_prefill_actions( :return: The refined task and corresponding action plans. """ self._update_state(file_path) - + execution_time = 0 # Save a screenshot of the app state screenshot_path = os.path.join(self._log_path_configs, "screenshot.png") - self._save_screenshot(self.task_object.task_file_name, screenshot_path) + self._save_screenshot(self._task_file_name, screenshot_path) # Construct prompt message for the PrefillAgent prompt_message = self._prefill_agent.message_constructor( @@ -184,12 +199,12 @@ def _get_prefill_actions( response_string, _ = self._prefill_agent.get_response( prompt_message, "prefill", use_backup_engine=True, configs=_configs ) - end_time = time.time() + execution_time = round(time.time() - start_time, 3) # Parse and log the response response_json = self._prefill_agent.response_to_dict(response_string) - new_task = response_json["new_task"] - action_plans = response_json["actions_plan"] + instantiated_request = response_json["new_task"] + instantiated_plan = response_json["actions_plan"] except Exception as e: self._status = "ERROR" @@ -197,10 +212,9 @@ def _get_prefill_actions( raise finally: # Log the response and execution time - duration_sec = end_time - start_time - self._log_response(response_json, duration_sec) + self._log_response(response_json, execution_time) - return new_task, action_plans + return instantiated_request, instantiated_plan def _log_message(self, prompt_message: str) -> None: """ @@ -214,15 +228,17 @@ def _log_message(self, prompt_message: str) -> None: } self._message_logger.info(json.dumps(messages_log_entry, indent=4)) - def _log_response(self, response_json: Dict[str, Any], duration_sec: float) -> None: + def _log_response( + self, response_json: Dict[str, Any], execution_time: float + ) -> None: """ - Log the response received from PrefillAgent along with execution duration. + Log the response received from PrefillAgent along with execution time. :param response_json: Response data from PrefillAgent. - :param duration_sec: Time taken for the PrefillAgent call. + :param execution_time: Time taken for the PrefillAgent call. """ response_log_entry = { "step": self._execute_step, - "duration_sec": duration_sec, + "execution_time": execution_time, "agent_response": response_json, "error": "", } diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index 0376eb9b..0b02f196 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -9,9 +9,10 @@ import sys import time import traceback -import textwrap from enum import Enum -from typing import Tuple +from typing import Any, Dict, Tuple + +from instantiation.config.config import Config # Add the project root to the system path. current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -28,8 +29,10 @@ args.add_argument("--task", help="The name of the task.", type=str, default="prefill") parsed_args = args.parse_args() +_configs = Config.get_instance().config_data logging.basicConfig( - level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" + level=_configs.get("LOG_LEVEL", "INFO"), + format=_configs.get("LOG_FORMAT", "%(asctime)s - %(levelname)s - %(message)s"), ) @@ -80,14 +83,6 @@ def __init__(self, task_dir_name: str, task_file: str) -> None: for key, value in task_json_file.items(): setattr(self, key.lower().replace(" ", "_"), value) - self.json_fields = [ - "unique_id", - "instantiated_request", - "instantiated_plan", - "instantial_template_path", - "request_comment", - ] - def choose_app_from_json(self, task_json_file: dict) -> AppEnum: """ Generate an app object by traversing AppEnum based on the app specified in the JSON. @@ -99,23 +94,6 @@ def choose_app_from_json(self, task_json_file: dict) -> AppEnum: return app raise ValueError("Not a correct App") - def to_json(self) -> dict: - """ - Convert the object to a JSON object. - :return: The JSON object. - """ - return { - key: getattr(self, key) for key in self.json_fields if hasattr(self, key) - } - - def set_attributes(self, **kwargs) -> None: - """ - Add all input fields as attributes. - :param kwargs: The fields to be added. - """ - for key, value in kwargs.items(): - setattr(self, key, value) - class InstantiationProcess: """ @@ -147,41 +125,50 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: Execute the process for one task. :param task_object: The TaskObject containing task details. """ - from instantiation.controller.workflow.choose_template_flow import ( - ChooseTemplateFlow, - ) + from instantiation.controller.workflow.choose_template_flow import \ + ChooseTemplateFlow from instantiation.controller.workflow.filter_flow import FilterFlow from instantiation.controller.workflow.prefill_flow import PrefillFlow try: start_time = time.time() - # Measure time for ChooseTemplateFlow - start_choose_template_time = time.time() - template_copied_path = ChooseTemplateFlow(task_object).execute() - choose_template_duration = time.time() - start_choose_template_time + app_object = task_object.app_object + task_file_name = task_object.task_file_name + choose_template_flow = ChooseTemplateFlow(app_object, task_file_name) + template_copied_path = choose_template_flow.execute() - # Measure time for PrefillFlow - start_prefill_time = time.time() - instantiated_task = PrefillFlow(task_object).execute(template_copied_path) - prefill_duration = time.time() - start_prefill_time + prefill_flow = PrefillFlow(app_object, task_file_name) + instantiated_request, instantiated_plan = prefill_flow.execute( + template_copied_path, task_object.task, task_object.refined_steps + ) # Measure time for FilterFlow - start_filter_time = time.time() - is_quality_good = FilterFlow(task_object).execute(instantiated_task) - filter_duration = time.time() - start_filter_time - - # Calculate total duration - total_duration = time.time() - start_time - - durations = { - "choose_template": choose_template_duration, - "prefill": prefill_duration, - "filter": filter_duration, - "total": total_duration, + filter_flow = FilterFlow(app_object, task_file_name) + is_quality_good, request_comment, request_type = filter_flow.execute( + instantiated_request + ) + + total_execution_time = round(time.time() - start_time, 3) + + execution_time_list = { + "choose_template": choose_template_flow.execution_time, + "prefill": prefill_flow.execution_time, + "filter": filter_flow.execution_time, + "total": total_execution_time, + } + + instantiated_task_info = { + "unique_id": task_object.unique_id, + "instantiated_request": instantiated_request, + "instantiated_plan": instantiated_plan, + "request_comment": request_comment, + "execution_time_list": execution_time_list, } - self._save_instantiated_task(task_object, is_quality_good, durations) + self._save_instantiated_task( + instantiated_task_info, task_object.task_file_base_name, is_quality_good + ) except Exception as e: logging.exception(f"Error processing task: {str(e)}") @@ -190,25 +177,28 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: def _save_instantiated_task( - self, task_object: TaskObject, is_quality_good: bool, durations: dict + self, + instantiated_task_info: Dict[str, Any], + task_file_base_name: str, + is_quality_good: bool, ) -> None: """ - Save the instantiated task along with duration information to the pass/fail folder. - :param task_object: The task object to save. - :param is_quality_good: Indicator of whether the task quality is good. - :param durations: A dictionary containing duration information for each flow. + Save the instantiated task information to a JSON file. + :param instantiated_task_info: A dictionary containing instantiated task details. + :param task_file_base_name: The base name of the task file. + :param is_quality_good: Indicates whether the quality of the task is good. """ - pass_path, fail_path = self._get_instance_folder_path() - task_json = task_object.to_json() - task_json["duration_sec"] = durations - - target_folder = pass_path if is_quality_good else fail_path - new_task_path = os.path.join(target_folder, task_object.task_file_base_name) + # Convert the dictionary to a JSON string + task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) + target_folder = self._get_instance_folder_path()[is_quality_good] + new_task_path = os.path.join(target_folder, task_file_base_name) os.makedirs(os.path.dirname(new_task_path), exist_ok=True) with open(new_task_path, "w", encoding="utf-8") as f: - json.dump(task_json, f, ensure_ascii=False) + f.write(task_json) + + print(f"Task saved to {new_task_path}") def _get_instance_folder_path(self) -> Tuple[str, str]: """ @@ -230,7 +220,7 @@ def log_error(self, task_file_base_name: str, message: str) -> None: configs["TASKS_HUB"], "prefill_instantiated", "instances_error" ) os.makedirs(error_folder, exist_ok=True) - + # Ensure the file name has the .json extension error_file_path = os.path.join(error_folder, task_file_base_name) @@ -240,14 +230,13 @@ def log_error(self, task_file_base_name: str, message: str) -> None: error_log = { "error_message": message, - "traceback": formatted_traceback # Keep original traceback line breaks + "traceback": formatted_traceback, # Keep original traceback line breaks } with open(error_file_path, "w", encoding="utf-8") as f: json.dump(error_log, f, indent=4, ensure_ascii=False) - def main(): """ The main function to process the tasks. From 6544d2e18779b902584892a0431845b0dada22e0 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Fri, 25 Oct 2024 10:10:19 +0800 Subject: [PATCH 11/30] polish the readme file --- instantiation/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/instantiation/README.md b/instantiation/README.md index 961bb179..b197f6be 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -172,6 +172,7 @@ After the process is completed, a new folder named `prefill_instantiated` will b } } ], + "request_comment": "The task involves using the drawing tool in the Word desktop app, which is a feature available within Word and does not require any external applications or subjective considerations.", "execution_time": { "choose_template": 10.650701761245728, "prefill": 44.23913502693176, From d7c8b12c8e2b36dcc2b47348716dd4191fbac37f Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Fri, 25 Oct 2024 18:05:37 +0800 Subject: [PATCH 12/30] Refactor: Improve instantiation process - Eliminate unnecessary console outputs. - Adjust logging. --- instantiation/README.md | 4 +- instantiation/config/config_dev.yaml | 3 +- .../workflow/choose_template_flow.py | 124 +++++++---------- .../controller/workflow/filter_flow.py | 6 +- .../controller/workflow/prefill_flow.py | 3 +- instantiation/instantiation.py | 126 +++++++++--------- 6 files changed, 119 insertions(+), 147 deletions(-) diff --git a/instantiation/README.md b/instantiation/README.md index b197f6be..54ceb6d0 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -172,7 +172,9 @@ After the process is completed, a new folder named `prefill_instantiated` will b } } ], - "request_comment": "The task involves using the drawing tool in the Word desktop app, which is a feature available within Word and does not require any external applications or subjective considerations.", + "result": { + "filter": "Drawing or writing a signature using the drawing tools in the Word desktop app is a task that can be executed locally within the application." + }, "execution_time": { "choose_template": 10.650701761245728, "prefill": 44.23913502693176, diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index 2de30d6d..7b5509e2 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -8,13 +8,12 @@ CONTROL_BACKEND: "uia" # The backend for control action CONTROL_LIST: ["Button", "Edit", "TabItem", "Document", "ListItem", "MenuItem", "ScrollBar", "TreeItem", "Hyperlink", "ComboBox", "RadioButton", "DataItem", "Spinner"] PRINT_LOG: False # Whether to print the log LOG_LEVEL: "INFO" # The log level -LOG_FORMAT: "%(asctime)s - %(levelname)s - %(message)s" MATCH_STRATEGY: "regex" # The match strategy for the control filter, support 'contains', 'fuzzy', 'regex' PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill FILTER_PROMPT: "instantiation/controller/prompts/{mode}/filter.yaml" # The prompt for the filter PREFILL_EXAMPLE_PROMPT: "instantiation/controller/prompts/{mode}/prefill_example.yaml" # The prompt for the action prefill example -API_PROMPT: "instantiation/controller/prompts/{mode}/api.yaml" # The prompt for the API +API_PROMPT: "ufo/prompts/share/lite/api.yaml" # The prompt for the API # Exploration Configuration TASKS_HUB: "instantiation/tasks" # The tasks hub for the exploration diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index 22b3168f..ccd89229 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -5,6 +5,7 @@ import warnings from datetime import datetime from typing import Dict +from pathlib import Path from langchain.embeddings import CacheBackedEmbeddings from langchain.storage import LocalFileStore @@ -16,8 +17,10 @@ configs = Config.get_instance().config_data - class ChooseTemplateFlow: + """ + Class to select and copy the most relevant template file based on the given task context. + """ _SENTENCE_TRANSFORMERS_PREFIX = "sentence-transformers/" def __init__(self, app_object: AppEnum, task_file_name: str): @@ -43,9 +46,7 @@ def execute(self) -> str: self.execution_time = round(time.time() - start_time, 3) return template_copied_path - def _create_copied_file( - self, copy_from_path: str, copy_to_folder_path: str, file_name: str = None - ) -> str: + def _create_copied_file(self, copy_from_path: Path, copy_to_folder_path: Path, file_name: str = None) -> str: """ Create a cache file from the specified source. :param copy_from_path: The original path of the file. @@ -54,18 +55,9 @@ def _create_copied_file( :return: The path to the newly created cache file. """ os.makedirs(copy_to_folder_path, exist_ok=True) - time_start = datetime.now() - template_extension = self._app_object.file_extension + copied_template_path = self._generate_copied_file_path(copy_to_folder_path, file_name) - if not file_name: - copied_template_path = os.path.join( - copy_to_folder_path, - time_start.strftime("%Y-%m-%d-%H-%M-%S") + template_extension, - ) - else: - copied_template_path = os.path.join( - copy_to_folder_path, file_name + template_extension - ) + # Copy the content from the original file to the new file with open(copy_from_path, "rb") as f: ori_content = f.read() with open(copied_template_path, "wb") as f: @@ -73,46 +65,48 @@ def _create_copied_file( return copied_template_path + def _generate_copied_file_path(self, folder_path: Path, file_name: str) -> str: + """ + Generate the file path for the copied template. + :param folder_path: The folder where the file will be created. + :param file_name: Optional; the name of the task file. + :return: The path to the newly created file. + """ + template_extension = self._app_object.file_extension + if file_name: + return str(folder_path / f"{file_name}{template_extension}") + timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + return str(folder_path / f"{timestamp}{template_extension}") + def _get_chosen_file_path(self) -> str: """ Choose the most relevant template file based on the task. :return: The path to the most relevant template file. """ - templates_description_path = os.path.join( - configs["TEMPLATE_PATH"], - self._app_object.description.lower(), - "description.json", - ) + templates_description_path = Path(configs["TEMPLATE_PATH"]) /\ + self._app_object.description.lower() / "description.json" try: with open(templates_description_path, "r") as f: - templates_file_description = json.load(f) + return self._choose_target_template_file(self._task_file_name, json.load(f)) except FileNotFoundError: - warnings.warn( - f"Warning: {templates_description_path} does not exist. Choosing a random template." - ) - template_folder = os.path.join( - configs["TEMPLATE_PATH"], - self._app_object.description.lower(), - ) - template_files = [ - f - for f in os.listdir(template_folder) - if os.path.isfile(os.path.join(template_folder, f)) - ] - - if not template_files: - raise Exception("No template files found in the specified directory.") - - chosen_template_file = random.choice(template_files) - print(f"Randomly selected template: {chosen_template_file}") - return chosen_template_file - - chosen_file_path = self._choose_target_template_file( - self._task_file_name, templates_file_description - ) - print(f"Chosen template file: {chosen_file_path}") - return chosen_file_path + warnings.warn(f"Warning: {templates_description_path} does not exist. Choosing a random template.") + return self._choose_random_template() + + def _choose_random_template(self) -> str: + """ + Select a random template file from the template folder. + :return: The path to the randomly selected template file. + """ + template_folder = Path(configs["TEMPLATE_PATH"]) / self._app_object.description.lower() + template_files = [f for f in template_folder.iterdir() if f.is_file()] + + if not template_files: + raise Exception("No template files found in the specified directory.") + + chosen_template_file = random.choice(template_files) + print(f"Randomly selected template: {chosen_template_file.name}") + return str(chosen_template_file) def _choose_template_and_copy(self) -> str: """ @@ -120,40 +114,23 @@ def _choose_template_and_copy(self) -> str: :return: The path to the copied template file. """ chosen_template_file_path = self._get_chosen_file_path() - chosen_template_full_path = os.path.join( - configs["TEMPLATE_PATH"], - self._app_object.description.lower(), - chosen_template_file_path, - ) - - target_template_folder_path = os.path.join( - configs["TASKS_HUB"], - os.path.dirname(os.path.dirname(self._task_file_name)) + "_templates", - ) + chosen_template_full_path = Path(configs["TEMPLATE_PATH"]) / \ + self._app_object.description.lower() / chosen_template_file_path - template_copied_path = self._create_copied_file( - chosen_template_full_path, - target_template_folder_path, - self._task_file_name, - ) + target_template_folder_path = Path(configs["TASKS_HUB"]) / \ + (os.path.dirname(os.path.dirname(self._task_file_name)) + "_templates") - return template_copied_path + return self._create_copied_file(chosen_template_full_path, target_template_folder_path, self._task_file_name) - def _choose_target_template_file( - self, given_task: str, doc_files_description: Dict[str, str] - ) -> str: + def _choose_target_template_file(self, given_task: str, doc_files_description: Dict[str, str]) -> str: """ Get the target file based on the semantic similarity of the given task and the template file descriptions. :param given_task: The task to be matched. :param doc_files_description: A dictionary of template file descriptions. :return: The path to the chosen template file. """ - file_doc_map = { - desc: file_name for file_name, desc in doc_files_description.items() - } - db = FAISS.from_texts( - list(doc_files_description.values()), self._embedding_model - ) + file_doc_map = {desc: file_name for file_name, desc in doc_files_description.items()} + db = FAISS.from_texts(list(doc_files_description.values()), self._embedding_model) most_similar = db.similarity_search(given_task, k=1) if not most_similar: @@ -172,7 +149,4 @@ def _load_embedding_model(model_name: str) -> CacheBackedEmbeddings: if not model_name.startswith(ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX): model_name = ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX + model_name embedding_model = HuggingFaceEmbeddings(model_name=model_name) - cached_embedder = CacheBackedEmbeddings.from_bytes_store( - embedding_model, store, namespace=model_name - ) - return cached_embedder + return CacheBackedEmbeddings.from_bytes_store(embedding_model, store, namespace=model_name) diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index 7d62e75d..730a5ff4 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -54,11 +54,11 @@ def execute(self, instantiated_request) -> Tuple[bool, str, str]: :return: Tuple containing task quality flag, comment, and task type. """ start_time = time.time() - is_quality_good, request_comment, request_type = self._get_filtered_result( + is_quality_good, filter_result, request_type = self._get_filtered_result( instantiated_request ) self.execution_time = round(time.time() - start_time, 3) - return is_quality_good, request_comment, request_type + return is_quality_good, filter_result, request_type def _initialize_logs(self) -> None: """ @@ -78,6 +78,7 @@ def _get_filtered_result(self, instantiated_request) -> Tuple[bool, str, str]: :param instantiated_request: Request object containing task details. :return: Tuple containing task quality flag, request comment, and request type. """ + # Construct the prompt message for the filter agent prompt_message = self._filter_agent.message_constructor( instantiated_request, self._app_name, @@ -85,6 +86,7 @@ def _get_filtered_result(self, instantiated_request) -> Tuple[bool, str, str]: prompt_json = json.dumps(prompt_message, indent=4) self._filter_message_logger.info(prompt_json) + # Get the response from the filter agent try: start_time = time.time() response_string, _ = self._filter_agent.get_response( diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index d6658667..f5eafc52 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -21,7 +21,7 @@ class PrefillFlow(AppAgentProcessor): """ - Manages the prefill flow by refining plan steps and interacting with the UI using automation tools. + Class to manage the prefill process by refining planning steps and automating UI interactions """ _app_prefill_agent_dict: Dict[str, PrefillAgent] = {} @@ -251,6 +251,7 @@ def _save_screenshot(self, doc_name: str, save_path: str) -> None: :param save_path: The path where the screenshot will be saved. """ try: + # Find the window matching the document name matched_window = self._app_env.find_matching_window(doc_name) if matched_window: screenshot = self._photographer.capture_app_window_screenshot( diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index 0b02f196..54d769f6 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -13,6 +13,7 @@ from typing import Any, Dict, Tuple from instantiation.config.config import Config +from ufo.module.basic import BaseSession # Add the project root to the system path. current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -30,11 +31,6 @@ parsed_args = args.parse_args() _configs = Config.get_instance().config_data -logging.basicConfig( - level=_configs.get("LOG_LEVEL", "INFO"), - format=_configs.get("LOG_FORMAT", "%(asctime)s - %(levelname)s - %(message)s"), -) - class AppEnum(Enum): """ @@ -72,18 +68,17 @@ def __init__(self, task_dir_name: str, task_file: str) -> None: """ self.task_dir_name = task_dir_name self.task_file = task_file - self.task_file_dir_name = os.path.dirname(os.path.dirname(task_file)) self.task_file_base_name = os.path.basename(task_file) self.task_file_name = self.task_file_base_name.split(".")[0] with open(task_file, "r") as f: task_json_file = json.load(f) - self.app_object = self.choose_app_from_json(task_json_file) + self.app_object = self._choose_app_from_json(task_json_file) for key, value in task_json_file.items(): setattr(self, key.lower().replace(" ", "_"), value) - def choose_app_from_json(self, task_json_file: dict) -> AppEnum: + def _choose_app_from_json(self, task_json_file: dict) -> AppEnum: """ Generate an app object by traversing AppEnum based on the app specified in the JSON. :param task_json_file: The JSON file of the task. @@ -100,13 +95,12 @@ class InstantiationProcess: Key process to instantiate the task. Control the process of the task. """ - def instantiate_files(self, task_dir_name: str) -> None: """ Instantiate all the task files. :param task_dir_name: The name of the task directory. """ - all_task_file_path: str = os.path.join(configs["TASKS_HUB"], task_dir_name, "*") + all_task_file_path: str = os.path.join(_configs["TASKS_HUB"], task_dir_name, "*") all_task_files = glob.glob(all_task_file_path) for index, task_file in enumerate(all_task_files, start=1): @@ -116,65 +110,102 @@ def instantiate_files(self, task_dir_name: str) -> None: self.instantiate_single_file(task_object) except Exception as e: logging.exception(f"Error in task {index}: {str(e)}") - traceback.print_exc() + self._handle_error(task_object.task_file_base_name, e) print("All tasks have been processed.") + def instantiate_single_file(self, task_object: TaskObject) -> None: """ Execute the process for one task. :param task_object: The TaskObject containing task details. """ - from instantiation.controller.workflow.choose_template_flow import \ - ChooseTemplateFlow + from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow from instantiation.controller.workflow.filter_flow import FilterFlow from instantiation.controller.workflow.prefill_flow import PrefillFlow + # Extract relevant task details from the TaskObject + app_object = task_object.app_object + task_file_name = task_object.task_file_name + try: + # Record the start time to measure execution duration start_time = time.time() - - app_object = task_object.app_object - task_file_name = task_object.task_file_name + + # Initialize the template flow and execute it to copy the template choose_template_flow = ChooseTemplateFlow(app_object, task_file_name) template_copied_path = choose_template_flow.execute() + # Initialize the prefill flow and execute it with the copied template and task details prefill_flow = PrefillFlow(app_object, task_file_name) instantiated_request, instantiated_plan = prefill_flow.execute( template_copied_path, task_object.task, task_object.refined_steps ) - # Measure time for FilterFlow + # Initialize the filter flow to evaluate the instantiated request filter_flow = FilterFlow(app_object, task_file_name) - is_quality_good, request_comment, request_type = filter_flow.execute( + is_quality_good, filter_result, request_type = filter_flow.execute( instantiated_request ) + # Calculate total execution time for the process total_execution_time = round(time.time() - start_time, 3) - execution_time_list = { + # Prepare a dictionary to store the execution time for each stage + execution_time = { "choose_template": choose_template_flow.execution_time, "prefill": prefill_flow.execution_time, "filter": filter_flow.execution_time, "total": total_execution_time, } + # Prepare the result structure to capture the filter result + result = { + "filter": filter_result + } + + # Create a summary of the instantiated task information instantiated_task_info = { "unique_id": task_object.unique_id, + "original_task": task_object.task, + "original_steps": task_object.refined_steps, "instantiated_request": instantiated_request, "instantiated_plan": instantiated_plan, - "request_comment": request_comment, - "execution_time_list": execution_time_list, + "result": result, + "execution_time": execution_time, } + # Save the instantiated task information using the designated method self._save_instantiated_task( instantiated_task_info, task_object.task_file_base_name, is_quality_good ) except Exception as e: + # Handle any errors encountered during the instantiation process logging.exception(f"Error processing task: {str(e)}") - self.log_error(task_object.task_file_base_name, str(e)) raise - + + + def _handle_error(self, task_file_base_name: str, error: Exception) -> None: + """ + Handle error logging for task processing. + :param task_file_base_name: The base name of the task file. + :param error: The exception raised during processing. + """ + error_folder = os.path.join(_configs["TASKS_HUB"], "prefill_instantiated", "instances_error") + os.makedirs(error_folder, exist_ok=True) + + err_logger = BaseSession.initialize_logger(error_folder, task_file_base_name, "w", _configs) + + # Use splitlines to keep the original line breaks in traceback + formatted_traceback = traceback.format_exc() + + error_log = { + "error_message": str(error), + "traceback": formatted_traceback, # Keep original traceback line breaks + } + + err_logger.error(json.dumps(error_log, ensure_ascii=False, indent=4)) def _save_instantiated_task( self, @@ -191,7 +222,12 @@ def _save_instantiated_task( # Convert the dictionary to a JSON string task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) - target_folder = self._get_instance_folder_path()[is_quality_good] + # Define folder paths for passing and failing instances + instance_folder = os.path.join(_configs["TASKS_HUB"], "prefill_instantiated") + pass_folder = os.path.join(instance_folder, "instances_pass") + fail_folder = os.path.join(instance_folder, "instances_fail") + target_folder = pass_folder if is_quality_good else fail_folder + new_task_path = os.path.join(target_folder, task_file_base_name) os.makedirs(os.path.dirname(new_task_path), exist_ok=True) @@ -200,53 +236,11 @@ def _save_instantiated_task( print(f"Task saved to {new_task_path}") - def _get_instance_folder_path(self) -> Tuple[str, str]: - """ - Get the folder paths for passing and failing instances. - :return: A tuple containing the pass and fail folder paths. - """ - instance_folder = os.path.join(configs["TASKS_HUB"], "prefill_instantiated") - pass_folder = os.path.join(instance_folder, "instances_pass") - fail_folder = os.path.join(instance_folder, "instances_fail") - return pass_folder, fail_folder - - def log_error(self, task_file_base_name: str, message: str) -> None: - """ - Log the error message with traceback to a specified file in JSON format. - :param task_file_base_name: The name of the task for the log filename. - :param message: The error message to log. - """ - error_folder = os.path.join( - configs["TASKS_HUB"], "prefill_instantiated", "instances_error" - ) - os.makedirs(error_folder, exist_ok=True) - - # Ensure the file name has the .json extension - error_file_path = os.path.join(error_folder, task_file_base_name) - - # Use splitlines to keep the original line breaks in traceback - formatted_traceback = traceback.format_exc().splitlines() - formatted_traceback = "\n".join(formatted_traceback) - - error_log = { - "error_message": message, - "traceback": formatted_traceback, # Keep original traceback line breaks - } - - with open(error_file_path, "w", encoding="utf-8") as f: - json.dump(error_log, f, indent=4, ensure_ascii=False) - def main(): """ The main function to process the tasks. """ - from instantiation.config.config import Config - - config_path = os.path.normpath(os.path.join(current_dir, "config/")) + "\\" - global configs - configs = Config(config_path).get_instance().config_data - task_dir_name = parsed_args.task.lower() InstantiationProcess().instantiate_files(task_dir_name) From e70f15d1ec2961325f709e69f3b2a4122b6df4f7 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Mon, 28 Oct 2024 15:34:48 +0800 Subject: [PATCH 13/30] Refactored to separate argument handling from `instantiation.py` and optimize parameters. --- instantiation/controller/agent/agent.py | 4 +- instantiation/controller/env/env_manager.py | 12 +- .../controller/instantiation_process.py | 228 ++++++++++++++++ .../controller/prompter/agent_prompter.py | 2 +- .../workflow/choose_template_flow.py | 76 ++++-- .../controller/workflow/filter_flow.py | 10 +- .../controller/workflow/prefill_flow.py | 15 +- instantiation/instantiation.py | 250 ++---------------- 8 files changed, 319 insertions(+), 278 deletions(-) create mode 100644 instantiation/controller/instantiation_process.py diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index dd5985c5..f5a96411 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -3,8 +3,8 @@ from typing import Dict, List -from instantiation.controller.prompter.agent_prompter import (FilterPrompter, - PrefillPrompter) +from controller.prompter.agent_prompter import FilterPrompter, PrefillPrompter + from ufo.agents.agent.basic import BasicAgent diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index c1382b71..0c5b8792 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -2,10 +2,10 @@ import re import time +from config.config import Config from fuzzywuzzy import fuzz from pywinauto import Desktop -from instantiation.config.config import Config from ufo.automator.puppeteer import ReceiverManager # Load configuration settings @@ -28,15 +28,17 @@ def __init__(self, app_object: object) -> None: super().__init__() self.app_window = None self.app_root_name = app_object.app_root_name - self.process_name = app_object.description.lower() + self.app_name = app_object.description.lower() self.win_app = app_object.win_app self._receive_factory = ReceiverManager._receiver_factory_registry["COM"][ "factory" ] self.win_com_receiver = self._receive_factory.create_receiver( - self.app_root_name, self.process_name + self.app_root_name, self.app_name ) + self._all_controls = None + def start(self, copied_template_path: str) -> None: """ Starts the Windows environment. @@ -77,6 +79,8 @@ def find_matching_window(self, doc_name: str) -> object: for window in windows_list: window_title = window.element_info.name.lower() if self._match_window_name(window_title, doc_name): + # Cache all controls for the window + self._all_controls = window.children() return window return None @@ -87,7 +91,7 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: :param doc_name: The document name associated with the application. :return: True if a match is found based on the strategy; False otherwise. """ - app_name = self.process_name + app_name = self.app_name doc_name = doc_name.lower() if _MATCH_STRATEGY == "contains": diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py new file mode 100644 index 00000000..23b6046c --- /dev/null +++ b/instantiation/controller/instantiation_process.py @@ -0,0 +1,228 @@ +import glob +import json +import logging +import os +import time +import traceback +from enum import Enum +from typing import Any, Dict + +from config.config import Config + +from ufo.module.basic import BaseSession + +# Set the environment variable for the run configuration. +os.environ["RUN_CONFIGS"] = "false" + +# Load configuration data. +_configs = Config.get_instance().config_data + + +class AppEnum(Enum): + """ + Define the apps that can be used in the instantiation. + """ + + WORD = 1, "Word", ".docx", "winword" + EXCEL = 2, "Excel", ".xlsx", "excel" + POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" + + def __init__(self, id: int, description: str, file_extension: str, win_app: str): + """ + :param id: The unique id of the app. + :param description: The description of the app. + :param file_extension: The file extension of the app. + :param win_app: The windows app name of the app. + """ + self.id = id + self.description = description + self.file_extension = file_extension + self.win_app = win_app + self.app_root_name = win_app.upper() + ".EXE" + + +class TaskObject: + """ + The task object from the json file. + """ + + def __init__(self, task_dir_name: str, task_file: str) -> None: + """ + Initialize the task object from the json file. + :param task_dir_name: The name of the directory containing the task. + :param task_file: The task file to load from. + """ + self.task_dir_name = task_dir_name + self.task_file = task_file + self.task_file_base_name = os.path.basename(task_file) + self.task_file_name = self.task_file_base_name.split(".")[0] + + with open(task_file, "r") as f: + task_json_file = json.load(f) + self.app_object = self._choose_app_from_json(task_json_file) + + for key, value in task_json_file.items(): + setattr(self, key.lower().replace(" ", "_"), value) + + def _choose_app_from_json(self, task_json_file: dict) -> AppEnum: + """ + Generate an app object by traversing AppEnum based on the app specified in the JSON. + :param task_json_file: The JSON file of the task. + :return: The app object. + """ + for app in AppEnum: + if app.description.lower() == task_json_file["app"].lower(): + return app + raise ValueError("Not a correct App") + + +class InstantiationProcess: + """ + Key process to instantiate the task. + Control the overall process. + """ + + def instantiate_files(self, task_dir_name: str) -> None: + """ + Instantiate all the task files. + :param task_dir_name: The name of the task directory. + """ + all_task_file_path: str = os.path.join( + _configs["TASKS_HUB"], task_dir_name, "*" + ) + all_task_files = glob.glob(all_task_file_path) + + for index, task_file in enumerate(all_task_files, start=1): + print(f"Task starts: {index} / {len(all_task_files)}") + try: + task_object = TaskObject(task_dir_name, task_file) + self.instantiate_single_file(task_object) + except Exception as e: + logging.exception(f"Error in task {index}: {str(e)}") + self._handle_error(task_object.task_file_base_name, e) + + print("All tasks have been processed.") + + def instantiate_single_file(self, task_object: TaskObject) -> None: + """ + Execute the process for one task. + :param task_object: The TaskObject containing task details. + """ + from controller.env.env_manager import WindowsAppEnv + from controller.workflow.choose_template_flow import ChooseTemplateFlow + from controller.workflow.filter_flow import FilterFlow + from controller.workflow.prefill_flow import PrefillFlow + + # Initialize the app environment and the task file name. + app_object = task_object.app_object + app_name = app_object.description.lower() + app_env = WindowsAppEnv(app_object) + task_file_name = task_object.task_file_name + + try: + start_time = time.time() + + # Initialize the template flow and execute it to copy the template + choose_template_flow = ChooseTemplateFlow( + app_name, app_object.file_extension, task_file_name + ) + template_copied_path = choose_template_flow.execute() + + # Initialize the prefill flow and execute it with the copied template and task details + prefill_flow = PrefillFlow(app_env, task_file_name) + instantiated_request, instantiated_plan = prefill_flow.execute( + template_copied_path, task_object.task, task_object.refined_steps + ) + + # Initialize the filter flow to evaluate the instantiated request + filter_flow = FilterFlow(app_name, task_file_name) + is_quality_good, filter_result, request_type = filter_flow.execute( + instantiated_request + ) + + # Calculate total execution time for the process + total_execution_time = round(time.time() - start_time, 3) + + # Prepare a dictionary to store the execution time for each stage + execution_time = { + "choose_template": choose_template_flow.execution_time, + "prefill": prefill_flow.execution_time, + "filter": filter_flow.execution_time, + "total": total_execution_time, + } + + # Prepare the result structure to capture the filter result + result = {"filter": filter_result} + + # Create a summary of the instantiated task information + instantiated_task_info = { + "unique_id": task_object.unique_id, + "original_task": task_object.task, + "original_steps": task_object.refined_steps, + "instantiated_request": instantiated_request, + "instantiated_plan": instantiated_plan, + "result": result, + "execution_time": execution_time, + } + + # Save the instantiated task information using the designated method + self._save_instantiated_task( + instantiated_task_info, task_object.task_file_base_name, is_quality_good + ) + except Exception as e: + logging.exception(f"Error processing task: {str(e)}") + raise + + def _handle_error(self, task_file_base_name: str, error: Exception) -> None: + """ + Handle error logging for task processing. + :param task_file_base_name: The base name of the task file. + :param error: The exception raised during processing. + """ + error_folder = os.path.join( + _configs["TASKS_HUB"], "prefill_instantiated", "instances_error" + ) + os.makedirs(error_folder, exist_ok=True) + + err_logger = BaseSession.initialize_logger( + error_folder, task_file_base_name, "w", _configs + ) + + # Use splitlines to keep the original line breaks in traceback + formatted_traceback = traceback.format_exc() + + error_log = { + "error_message": str(error), + "traceback": formatted_traceback, # Keep original traceback line breaks + } + + err_logger.error(json.dumps(error_log, ensure_ascii=False, indent=4)) + + def _save_instantiated_task( + self, + instantiated_task_info: Dict[str, Any], + task_file_base_name: str, + is_quality_good: bool, + ) -> None: + """ + Save the instantiated task information to a JSON file. + :param instantiated_task_info: A dictionary containing instantiated task details. + :param task_file_base_name: The base name of the task file. + :param is_quality_good: Indicates whether the quality of the task is good. + """ + # Convert the dictionary to a JSON string + task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) + + # Define folder paths for passing and failing instances + instance_folder = os.path.join(_configs["TASKS_HUB"], "prefill_instantiated") + pass_folder = os.path.join(instance_folder, "instances_pass") + fail_folder = os.path.join(instance_folder, "instances_fail") + target_folder = pass_folder if is_quality_good else fail_folder + + new_task_path = os.path.join(target_folder, task_file_base_name) + os.makedirs(os.path.dirname(new_task_path), exist_ok=True) + + with open(new_task_path, "w", encoding="utf-8") as f: + f.write(task_json) + + print(f"Task saved to {new_task_path}") diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index 47677776..c3d84a29 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -331,4 +331,4 @@ def examples_prompt_helper( example_list += [json.dumps(example) for example in additional_examples] - return self.retrived_documents_prompt_helper(header, separator, example_list) \ No newline at end of file + return self.retrived_documents_prompt_helper(header, separator, example_list) diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index ccd89229..f7ed8d04 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -4,36 +4,38 @@ import time import warnings from datetime import datetime -from typing import Dict from pathlib import Path +from typing import Dict +from config.config import Config from langchain.embeddings import CacheBackedEmbeddings from langchain.storage import LocalFileStore from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS -from instantiation.config.config import Config -from instantiation.instantiation import AppEnum +_configs = Config.get_instance().config_data -configs = Config.get_instance().config_data class ChooseTemplateFlow: """ Class to select and copy the most relevant template file based on the given task context. """ + _SENTENCE_TRANSFORMERS_PREFIX = "sentence-transformers/" - def __init__(self, app_object: AppEnum, task_file_name: str): + def __init__(self, app_name: str, file_extension: str, task_file_name: str): """ Initialize the flow with the given task context. - :param app_object: An instance of AppEnum, representing the application context. + :param app_name: The name of the application. + :param file_extension: The file extension of the template. :param task_file_name: The name of the task file. """ - self._app_object = app_object + self._app_name = app_name + self._file_extension = file_extension self._task_file_name = task_file_name self.execution_time = 0 self._embedding_model = self._load_embedding_model( - model_name=configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"] + model_name=_configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"] ) def execute(self) -> str: @@ -46,7 +48,9 @@ def execute(self) -> str: self.execution_time = round(time.time() - start_time, 3) return template_copied_path - def _create_copied_file(self, copy_from_path: Path, copy_to_folder_path: Path, file_name: str = None) -> str: + def _create_copied_file( + self, copy_from_path: Path, copy_to_folder_path: Path, file_name: str = None + ) -> str: """ Create a cache file from the specified source. :param copy_from_path: The original path of the file. @@ -55,9 +59,10 @@ def _create_copied_file(self, copy_from_path: Path, copy_to_folder_path: Path, f :return: The path to the newly created cache file. """ os.makedirs(copy_to_folder_path, exist_ok=True) - copied_template_path = self._generate_copied_file_path(copy_to_folder_path, file_name) + copied_template_path = self._generate_copied_file_path( + copy_to_folder_path, file_name + ) - # Copy the content from the original file to the new file with open(copy_from_path, "rb") as f: ori_content = f.read() with open(copied_template_path, "wb") as f: @@ -72,7 +77,7 @@ def _generate_copied_file_path(self, folder_path: Path, file_name: str) -> str: :param file_name: Optional; the name of the task file. :return: The path to the newly created file. """ - template_extension = self._app_object.file_extension + template_extension = self._file_extension if file_name: return str(folder_path / f"{file_name}{template_extension}") timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") @@ -83,14 +88,19 @@ def _get_chosen_file_path(self) -> str: Choose the most relevant template file based on the task. :return: The path to the most relevant template file. """ - templates_description_path = Path(configs["TEMPLATE_PATH"]) /\ - self._app_object.description.lower() / "description.json" + templates_description_path = ( + Path(_configs["TEMPLATE_PATH"]) / self._app_name / "description.json" + ) try: with open(templates_description_path, "r") as f: - return self._choose_target_template_file(self._task_file_name, json.load(f)) + return self._choose_target_template_file( + self._task_file_name, json.load(f) + ) except FileNotFoundError: - warnings.warn(f"Warning: {templates_description_path} does not exist. Choosing a random template.") + warnings.warn( + f"Warning: {templates_description_path} does not exist. Choosing a random template." + ) return self._choose_random_template() def _choose_random_template(self) -> str: @@ -98,7 +108,7 @@ def _choose_random_template(self) -> str: Select a random template file from the template folder. :return: The path to the randomly selected template file. """ - template_folder = Path(configs["TEMPLATE_PATH"]) / self._app_object.description.lower() + template_folder = Path(_configs["TEMPLATE_PATH"]) / self._app_name template_files = [f for f in template_folder.iterdir() if f.is_file()] if not template_files: @@ -114,23 +124,33 @@ def _choose_template_and_copy(self) -> str: :return: The path to the copied template file. """ chosen_template_file_path = self._get_chosen_file_path() - chosen_template_full_path = Path(configs["TEMPLATE_PATH"]) / \ - self._app_object.description.lower() / chosen_template_file_path + chosen_template_full_path = ( + Path(_configs["TEMPLATE_PATH"]) / self._app_name / chosen_template_file_path + ) - target_template_folder_path = Path(configs["TASKS_HUB"]) / \ - (os.path.dirname(os.path.dirname(self._task_file_name)) + "_templates") + target_template_folder_path = Path(_configs["TASKS_HUB"]) / ( + os.path.dirname(os.path.dirname(self._task_file_name)) + "_templates" + ) - return self._create_copied_file(chosen_template_full_path, target_template_folder_path, self._task_file_name) + return self._create_copied_file( + chosen_template_full_path, target_template_folder_path, self._task_file_name + ) - def _choose_target_template_file(self, given_task: str, doc_files_description: Dict[str, str]) -> str: + def _choose_target_template_file( + self, given_task: str, doc_files_description: Dict[str, str] + ) -> str: """ Get the target file based on the semantic similarity of the given task and the template file descriptions. :param given_task: The task to be matched. :param doc_files_description: A dictionary of template file descriptions. :return: The path to the chosen template file. """ - file_doc_map = {desc: file_name for file_name, desc in doc_files_description.items()} - db = FAISS.from_texts(list(doc_files_description.values()), self._embedding_model) + file_doc_map = { + desc: file_name for file_name, desc in doc_files_description.items() + } + db = FAISS.from_texts( + list(doc_files_description.values()), self._embedding_model + ) most_similar = db.similarity_search(given_task, k=1) if not most_similar: @@ -145,8 +165,10 @@ def _load_embedding_model(model_name: str) -> CacheBackedEmbeddings: :param model_name: The name of the embedding model to load. :return: The loaded embedding model. """ - store = LocalFileStore(configs["CONTROL_EMBEDDING_CACHE_PATH"]) + store = LocalFileStore(_configs["CONTROL_EMBEDDING_CACHE_PATH"]) if not model_name.startswith(ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX): model_name = ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX + model_name embedding_model = HuggingFaceEmbeddings(model_name=model_name) - return CacheBackedEmbeddings.from_bytes_store(embedding_model, store, namespace=model_name) + return CacheBackedEmbeddings.from_bytes_store( + embedding_model, store, namespace=model_name + ) diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index 730a5ff4..a2016cd3 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -4,9 +4,9 @@ import time from typing import Dict, Tuple -from instantiation.config.config import Config -from instantiation.controller.agent.agent import FilterAgent -from instantiation.instantiation import AppEnum +from config.config import Config +from controller.agent.agent import FilterAgent + from ufo.module.basic import BaseSession _configs = Config.get_instance().config_data @@ -19,14 +19,14 @@ class FilterFlow: _app_filter_agent_dict: Dict[str, FilterAgent] = {} - def __init__(self, app_object: AppEnum, task_file_name: str) -> None: + def __init__(self, app_name: str, task_file_name: str) -> None: """ Initialize the filter flow for a task. :param app_object: Application object containing task details. :param task_file_name: Name of the task file being processed. """ self.execution_time = 0 - self._app_name = app_object.description.lower() + self._app_name = app_name self._log_path_configs = _configs["FILTER_LOG_PATH"].format(task=task_file_name) self._filter_agent = self._get_or_create_filter_agent() self._initialize_logs() diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index f5eafc52..38e27427 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -4,10 +4,10 @@ import time from typing import Any, Dict, List, Tuple -from instantiation.config.config import Config -from instantiation.controller.agent.agent import PrefillAgent -from instantiation.controller.env.env_manager import WindowsAppEnv -from instantiation.instantiation import AppEnum +from config.config import Config +from controller.agent.agent import PrefillAgent +from controller.env.env_manager import WindowsAppEnv + from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.automator.ui_control.inspector import ControlInspectorFacade from ufo.automator.ui_control.screenshot import PhotographerFacade @@ -28,9 +28,8 @@ class PrefillFlow(AppAgentProcessor): def __init__( self, - app_object: AppEnum, + environment: WindowsAppEnv, task_file_name: str, - environment: WindowsAppEnv = None, ) -> None: """ Initialize the prefill flow with the application context. @@ -39,9 +38,9 @@ def __init__( :param environment: The environment of the app, defaults to a new WindowsAppEnv if not provided. """ self.execution_time = 0 + self._app_env = environment self._task_file_name = task_file_name - self._app_name = app_object.description.lower() - self._app_env = environment or WindowsAppEnv(app_object) + self._app_name = self._app_env.app_name # Create or reuse a PrefillAgent for the app if self._app_name not in PrefillFlow._app_prefill_agent_dict: diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index 54d769f6..6445cd18 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -1,247 +1,35 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - import argparse -import glob -import json -import logging import os import sys -import time -import traceback -from enum import Enum -from typing import Any, Dict, Tuple -from instantiation.config.config import Config -from ufo.module.basic import BaseSession # Add the project root to the system path. -current_dir = os.path.dirname(os.path.abspath(__file__)) -project_root = os.path.abspath(os.path.join(current_dir, "..")) - -if project_root not in sys.path: - sys.path.append(project_root) - -# Set the environment variable. -os.environ["RUN_CONFIGS"] = "false" - -# Parse the arguments. -args = argparse.ArgumentParser() -args.add_argument("--task", help="The name of the task.", type=str, default="prefill") -parsed_args = args.parse_args() - -_configs = Config.get_instance().config_data - -class AppEnum(Enum): - """ - Define the apps that can be used in the instantiation. - """ - - WORD = 1, "Word", ".docx", "winword" - EXCEL = 2, "Excel", ".xlsx", "excel" - POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" - - def __init__(self, id: int, description: str, file_extension: str, win_app: str): - """ - :param id: The unique id of the app. - :param description: The description of the app. - :param file_extension: The file extension of the app. - :param win_app: The windows app name of the app. - """ - self.id = id - self.description = description - self.file_extension = file_extension - self.win_app = win_app - self.app_root_name = win_app.upper() + ".EXE" - +def add_project_root_to_sys_path() -> None: + """Add project root to system path if not already present.""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(current_dir, "..")) + if project_root not in sys.path: + sys.path.append(project_root) -class TaskObject: - """ - The task object from the json file. - """ - - def __init__(self, task_dir_name: str, task_file: str) -> None: - """ - Initialize the task object from the json file. - :param task_dir_name: The name of the directory containing the task. - :param task_file: The task file to load from. - """ - self.task_dir_name = task_dir_name - self.task_file = task_file - self.task_file_base_name = os.path.basename(task_file) - self.task_file_name = self.task_file_base_name.split(".")[0] - - with open(task_file, "r") as f: - task_json_file = json.load(f) - self.app_object = self._choose_app_from_json(task_json_file) - - for key, value in task_json_file.items(): - setattr(self, key.lower().replace(" ", "_"), value) - - def _choose_app_from_json(self, task_json_file: dict) -> AppEnum: - """ - Generate an app object by traversing AppEnum based on the app specified in the JSON. - :param task_json_file: The JSON file of the task. - :return: The app object. - """ - for app in AppEnum: - if app.description.lower() == task_json_file["app"].lower(): - return app - raise ValueError("Not a correct App") +def parse_arguments() -> argparse.Namespace: + """Parse command-line arguments. -class InstantiationProcess: + :return: Parsed command-line arguments. """ - Key process to instantiate the task. - Control the process of the task. - """ - def instantiate_files(self, task_dir_name: str) -> None: - """ - Instantiate all the task files. - :param task_dir_name: The name of the task directory. - """ - all_task_file_path: str = os.path.join(_configs["TASKS_HUB"], task_dir_name, "*") - all_task_files = glob.glob(all_task_file_path) - - for index, task_file in enumerate(all_task_files, start=1): - print(f"Task starts: {index} / {len(all_task_files)}") - try: - task_object = TaskObject(task_dir_name, task_file) - self.instantiate_single_file(task_object) - except Exception as e: - logging.exception(f"Error in task {index}: {str(e)}") - self._handle_error(task_object.task_file_base_name, e) - - print("All tasks have been processed.") - - - def instantiate_single_file(self, task_object: TaskObject) -> None: - """ - Execute the process for one task. - :param task_object: The TaskObject containing task details. - """ - from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow - from instantiation.controller.workflow.filter_flow import FilterFlow - from instantiation.controller.workflow.prefill_flow import PrefillFlow + parser = argparse.ArgumentParser() + parser.add_argument( + "--task", help="The name of the task.", type=str, default="prefill" + ) + return parser.parse_args() - # Extract relevant task details from the TaskObject - app_object = task_object.app_object - task_file_name = task_object.task_file_name - try: - # Record the start time to measure execution duration - start_time = time.time() - - # Initialize the template flow and execute it to copy the template - choose_template_flow = ChooseTemplateFlow(app_object, task_file_name) - template_copied_path = choose_template_flow.execute() +def main() -> None: + """Main entry point of the script.""" + add_project_root_to_sys_path() # Ensure project root is added + task_dir_name = parse_arguments().task.lower() + from controller.instantiation_process import InstantiationProcess - # Initialize the prefill flow and execute it with the copied template and task details - prefill_flow = PrefillFlow(app_object, task_file_name) - instantiated_request, instantiated_plan = prefill_flow.execute( - template_copied_path, task_object.task, task_object.refined_steps - ) - - # Initialize the filter flow to evaluate the instantiated request - filter_flow = FilterFlow(app_object, task_file_name) - is_quality_good, filter_result, request_type = filter_flow.execute( - instantiated_request - ) - - # Calculate total execution time for the process - total_execution_time = round(time.time() - start_time, 3) - - # Prepare a dictionary to store the execution time for each stage - execution_time = { - "choose_template": choose_template_flow.execution_time, - "prefill": prefill_flow.execution_time, - "filter": filter_flow.execution_time, - "total": total_execution_time, - } - - # Prepare the result structure to capture the filter result - result = { - "filter": filter_result - } - - # Create a summary of the instantiated task information - instantiated_task_info = { - "unique_id": task_object.unique_id, - "original_task": task_object.task, - "original_steps": task_object.refined_steps, - "instantiated_request": instantiated_request, - "instantiated_plan": instantiated_plan, - "result": result, - "execution_time": execution_time, - } - - # Save the instantiated task information using the designated method - self._save_instantiated_task( - instantiated_task_info, task_object.task_file_base_name, is_quality_good - ) - - except Exception as e: - # Handle any errors encountered during the instantiation process - logging.exception(f"Error processing task: {str(e)}") - raise - - - def _handle_error(self, task_file_base_name: str, error: Exception) -> None: - """ - Handle error logging for task processing. - :param task_file_base_name: The base name of the task file. - :param error: The exception raised during processing. - """ - error_folder = os.path.join(_configs["TASKS_HUB"], "prefill_instantiated", "instances_error") - os.makedirs(error_folder, exist_ok=True) - - err_logger = BaseSession.initialize_logger(error_folder, task_file_base_name, "w", _configs) - - # Use splitlines to keep the original line breaks in traceback - formatted_traceback = traceback.format_exc() - - error_log = { - "error_message": str(error), - "traceback": formatted_traceback, # Keep original traceback line breaks - } - - err_logger.error(json.dumps(error_log, ensure_ascii=False, indent=4)) - - def _save_instantiated_task( - self, - instantiated_task_info: Dict[str, Any], - task_file_base_name: str, - is_quality_good: bool, - ) -> None: - """ - Save the instantiated task information to a JSON file. - :param instantiated_task_info: A dictionary containing instantiated task details. - :param task_file_base_name: The base name of the task file. - :param is_quality_good: Indicates whether the quality of the task is good. - """ - # Convert the dictionary to a JSON string - task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) - - # Define folder paths for passing and failing instances - instance_folder = os.path.join(_configs["TASKS_HUB"], "prefill_instantiated") - pass_folder = os.path.join(instance_folder, "instances_pass") - fail_folder = os.path.join(instance_folder, "instances_fail") - target_folder = pass_folder if is_quality_good else fail_folder - - new_task_path = os.path.join(target_folder, task_file_base_name) - os.makedirs(os.path.dirname(new_task_path), exist_ok=True) - - with open(new_task_path, "w", encoding="utf-8") as f: - f.write(task_json) - - print(f"Task saved to {new_task_path}") - - -def main(): - """ - The main function to process the tasks. - """ - task_dir_name = parsed_args.task.lower() InstantiationProcess().instantiate_files(task_dir_name) From 2ec4dd29ae9260f7ee923c5a2ffcfa50a6442f7c Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Mon, 28 Oct 2024 15:53:52 +0800 Subject: [PATCH 14/30] Correct the erroneous comments --- instantiation/controller/workflow/prefill_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index 38e27427..443499cc 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -33,9 +33,8 @@ def __init__( ) -> None: """ Initialize the prefill flow with the application context. - :param app_object: The application enum object representing the app to be automated. + :param environment: The environment of the app. :param task_file_name: The name of the task file for logging and tracking. - :param environment: The environment of the app, defaults to a new WindowsAppEnv if not provided. """ self.execution_time = 0 self._app_env = environment From cfe23ec474dd3f65434a5b68fd4f9e30a2d9066c Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Mon, 28 Oct 2024 20:13:39 +0800 Subject: [PATCH 15/30] Revert "Correct the erroneous comments" This reverts commit 2ec4dd29ae9260f7ee923c5a2ffcfa50a6442f7c. --- instantiation/controller/workflow/prefill_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index 443499cc..38e27427 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -33,8 +33,9 @@ def __init__( ) -> None: """ Initialize the prefill flow with the application context. - :param environment: The environment of the app. + :param app_object: The application enum object representing the app to be automated. :param task_file_name: The name of the task file for logging and tracking. + :param environment: The environment of the app, defaults to a new WindowsAppEnv if not provided. """ self.execution_time = 0 self._app_env = environment From 28782866ff714bdabcf96193de84959b0ae2e3f1 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Mon, 28 Oct 2024 20:23:32 +0800 Subject: [PATCH 16/30] Correct the erroneous comments --- instantiation/controller/workflow/prefill_flow.py | 3 +-- instantiation/instantiation.py | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index 38e27427..443499cc 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -33,9 +33,8 @@ def __init__( ) -> None: """ Initialize the prefill flow with the application context. - :param app_object: The application enum object representing the app to be automated. + :param environment: The environment of the app. :param task_file_name: The name of the task file for logging and tracking. - :param environment: The environment of the app, defaults to a new WindowsAppEnv if not provided. """ self.execution_time = 0 self._app_env = environment diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index 6445cd18..b692f7ab 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -26,10 +26,12 @@ def parse_arguments() -> argparse.Namespace: def main() -> None: """Main entry point of the script.""" - add_project_root_to_sys_path() # Ensure project root is added + # Add the project root to the system path. + add_project_root_to_sys_path() + task_dir_name = parse_arguments().task.lower() - from controller.instantiation_process import InstantiationProcess + from controller.instantiation_process import InstantiationProcess InstantiationProcess().instantiate_files(task_dir_name) From 9318a7445fad9294355691d31b6920b13a6b0629 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Mon, 28 Oct 2024 21:25:22 +0800 Subject: [PATCH 17/30] run in package and resolve conflict --- instantiation/controller/agent/agent.py | 2 +- instantiation/controller/env/env_manager.py | 2 +- instantiation/controller/instantiation_process.py | 10 +++++----- .../controller/workflow/choose_template_flow.py | 2 +- instantiation/controller/workflow/filter_flow.py | 4 ++-- instantiation/controller/workflow/prefill_flow.py | 6 +++--- instantiation/instantiation.py | 2 +- ufo/agents/agent/basic.py | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index f5a96411..ecfabeb3 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -3,7 +3,7 @@ from typing import Dict, List -from controller.prompter.agent_prompter import FilterPrompter, PrefillPrompter +from instantiation.controller.prompter.agent_prompter import FilterPrompter, PrefillPrompter from ufo.agents.agent.basic import BasicAgent diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index 0c5b8792..51e9056f 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -2,7 +2,7 @@ import re import time -from config.config import Config +from instantiation.config.config import Config from fuzzywuzzy import fuzz from pywinauto import Desktop diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py index 23b6046c..e3116af7 100644 --- a/instantiation/controller/instantiation_process.py +++ b/instantiation/controller/instantiation_process.py @@ -7,7 +7,7 @@ from enum import Enum from typing import Any, Dict -from config.config import Config +from instantiation.config.config import Config from ufo.module.basic import BaseSession @@ -108,10 +108,10 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: Execute the process for one task. :param task_object: The TaskObject containing task details. """ - from controller.env.env_manager import WindowsAppEnv - from controller.workflow.choose_template_flow import ChooseTemplateFlow - from controller.workflow.filter_flow import FilterFlow - from controller.workflow.prefill_flow import PrefillFlow + from instantiation.controller.env.env_manager import WindowsAppEnv + from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow + from instantiation.controller.workflow.filter_flow import FilterFlow + from instantiation.controller.workflow.prefill_flow import PrefillFlow # Initialize the app environment and the task file name. app_object = task_object.app_object diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index f7ed8d04..1fac860b 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Dict -from config.config import Config +from instantiation.config.config import Config from langchain.embeddings import CacheBackedEmbeddings from langchain.storage import LocalFileStore from langchain_community.embeddings import HuggingFaceEmbeddings diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index a2016cd3..16a447bc 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -4,8 +4,8 @@ import time from typing import Dict, Tuple -from config.config import Config -from controller.agent.agent import FilterAgent +from instantiation.config.config import Config +from instantiation.controller.agent.agent import FilterAgent from ufo.module.basic import BaseSession diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index 443499cc..2fcbd536 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -4,9 +4,9 @@ import time from typing import Any, Dict, List, Tuple -from config.config import Config -from controller.agent.agent import PrefillAgent -from controller.env.env_manager import WindowsAppEnv +from instantiation.config.config import Config +from instantiation.controller.agent.agent import PrefillAgent +from instantiation.controller.env.env_manager import WindowsAppEnv from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.automator.ui_control.inspector import ControlInspectorFacade diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index b692f7ab..2524092a 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -31,7 +31,7 @@ def main() -> None: task_dir_name = parse_arguments().task.lower() - from controller.instantiation_process import InstantiationProcess + from instantiation.controller.instantiation_process import InstantiationProcess InstantiationProcess().instantiate_files(task_dir_name) diff --git a/ufo/agents/agent/basic.py b/ufo/agents/agent/basic.py index 123ed06e..1f54e08e 100644 --- a/ufo/agents/agent/basic.py +++ b/ufo/agents/agent/basic.py @@ -237,7 +237,7 @@ def process_resume(self) -> None: self.processor.resume() - def process_asker(self, configs = configs) -> None: + def process_asker(self, ask_user: bool = True) -> None: """ Ask for the process. :param ask_user: Whether to ask the user for the questions. From 36c10249b2ee8db6a4b8508b9aef32b8fff2379c Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Thu, 14 Nov 2024 10:55:36 +0800 Subject: [PATCH 18/30] add unfinished execution codes --- .gitignore | 5 +- instantiation/config/config_dev.yaml | 4 +- instantiation/controller/agent/agent.py | 96 +++++++++- instantiation/controller/env/env_manager.py | 59 ++++++ .../controller/instantiation_process.py | 16 +- .../controller/prompter/agent_prompter.py | 173 ++++++++++++++++++ .../controller/prompts/visual/execute.yaml | 26 +++ .../controller/workflow/execute_flow.py | 123 +++++++++++++ .../controller/workflow/prefill_flow.py | 5 +- instantiation/instantiation.py | 3 - ufo/agents/agent/app_agent.py | 7 +- ufo/agents/agent/basic.py | 2 +- ufo/agents/processors/app_agent_processor.py | 48 +++-- ufo/agents/processors/basic.py | 2 +- ufo/automator/puppeteer.py | 2 +- 15 files changed, 534 insertions(+), 37 deletions(-) create mode 100644 instantiation/controller/prompts/visual/execute.yaml create mode 100644 instantiation/controller/workflow/execute_flow.py diff --git a/.gitignore b/.gitignore index 15338e1b..68e93709 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,7 @@ scripts/* !vectordb/docs/example/ !vectordb/demonstration/example.yaml -.vscode \ No newline at end of file +.vscode + +# Ignore the logs +logs 3/* \ No newline at end of file diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index 7b5509e2..e7b9b932 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -13,6 +13,7 @@ MATCH_STRATEGY: "regex" # The match strategy for the control filter, support 'c PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill FILTER_PROMPT: "instantiation/controller/prompts/{mode}/filter.yaml" # The prompt for the filter PREFILL_EXAMPLE_PROMPT: "instantiation/controller/prompts/{mode}/prefill_example.yaml" # The prompt for the action prefill example +EXECUTE_PROMPT: "instantiation/controller/prompts/{mode}/execute.yaml" # The prompt for the action execute API_PROMPT: "ufo/prompts/share/lite/api.yaml" # The prompt for the API # Exploration Configuration @@ -28,4 +29,5 @@ CONTROL_FILTER_TOP_K_PLAN: 2 # The control filter effect on top k plans from UF # log path LOG_PATH: "instantiation/logs/{task}" PREFILL_LOG_PATH: "instantiation/logs/{task}/prefill/" -FILTER_LOG_PATH: "instantiation/logs/{task}/filter/" \ No newline at end of file +FILTER_LOG_PATH: "instantiation/logs/{task}/filter/" +EXECUTE_LOG_PATH: "instantiation/logs/{task}/execute/" \ No newline at end of file diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index ecfabeb3..c39b441d 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -3,8 +3,13 @@ from typing import Dict, List -from instantiation.controller.prompter.agent_prompter import FilterPrompter, PrefillPrompter +from instantiation.controller.prompter.agent_prompter import ( + FilterPrompter, + PrefillPrompter, + ExecutePrompter +) +from ufo.agents.agent.app_agent import AppAgent from ufo.agents.agent.basic import BasicAgent @@ -164,3 +169,92 @@ def process_comfirmation(self) -> None: This is the abstract method from BasicAgent that needs to be implemented. """ pass + +# TODO: ExecuteAgent +class ExecuteAgent(AppAgent): + """ + The Agent for task execution. + """ + + def __init__( + self, + name: str, + process_name: str, + app_root_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the ExecuteAgent. + :param name: The name of the agent. + :param process_name: The name of the process. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + self._step = 0 + self._complete = False + self._name = name + self._status = None + self.prompter: ExecutePrompter = self.get_prompter( + is_visual, main_prompt, example_prompt, api_prompt + ) + self._process_name = process_name + self._app_root_name = app_root_name + + self.Puppeteer = self.create_puppeteer_interface() + + def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: + """ + Get the prompt for the agent. + This is the abstract method from BasicAgent that needs to be implemented. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + :return: The prompt string. + """ + + return ExecutePrompter(is_visual, main_prompt, example_prompt, api_prompt) + + def message_constructor( + self, + dynamic_examples: str, + given_task: str, + reference_steps: List[str], + doc_control_state: Dict[str, str], + log_path: str, + ) -> List[str]: + """ + Construct the prompt message for the ExecuteAgent. + :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. + :param given_task: The given task. + :param reference_steps: The reference steps. + :param doc_control_state: The document control state. + :param log_path: The path of the log. + :return: The prompt message. + """ + + execute_agent_prompt_system_message = self.prompter.system_prompt_construction( + dynamic_examples + ) + execute_agent_prompt_user_message = self.prompter.user_content_construction( + given_task, reference_steps, doc_control_state, log_path + ) + appagent_prompt_message = self.prompter.prompt_construction( + execute_agent_prompt_system_message, + execute_agent_prompt_user_message, + ) + + return appagent_prompt_message + + def process_comfirmation(self) -> None: + """ + Confirm the process. + This is the abstract method from BasicAgent that needs to be implemented. + """ + pass diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index 51e9056f..b092f83c 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -7,6 +7,8 @@ from pywinauto import Desktop from ufo.automator.puppeteer import ReceiverManager +from ufo.automator.ui_control.inspector import ControlInspectorFacade + # Load configuration settings _configs = Config.get_instance().config_data @@ -26,6 +28,7 @@ def __init__(self, app_object: object) -> None: :param app_object: The app object containing information about the application. """ super().__init__() + # FIX: 私有属性修改 self.app_window = None self.app_root_name = app_object.app_root_name self.app_name = app_object.description.lower() @@ -36,6 +39,7 @@ def __init__(self, app_object: object) -> None: self.win_com_receiver = self._receive_factory.create_receiver( self.app_root_name, self.app_name ) + self._control_inspector = ControlInspectorFacade(_BACKEND) self._all_controls = None @@ -76,11 +80,13 @@ def find_matching_window(self, doc_name: str) -> object: """ desktop = Desktop(backend=_BACKEND) windows_list = desktop.windows() + # windows_list = self._control_inspector.get_desktop_windows() for window in windows_list: window_title = window.element_info.name.lower() if self._match_window_name(window_title, doc_name): # Cache all controls for the window self._all_controls = window.children() + self.app_window = window return window return None @@ -112,3 +118,56 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: else: logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") + + def find_matching_controller(self, control_label: str, control_text: str) -> object: + """ + Finds a matching controller based on the control label and control text. + :param control_label: The label of the control to identify it. + :param control_text: The text content of the control for additional context. + :return: The matched controller object or None if no match is found. + """ + # self._all_controls = self._control_inspector.find_control_elements_in_descendants(self.win_com_receiver) + try: + # Retrieve controls to match against + for control in self._all_controls: + if self._match_controller(control, control_label, control_text): + return control + except Exception as e: + # Log the error or handle it as needed + logging.exception(f"Error finding matching controller: {e}") + # Assume log_error is a method for logging errors + raise + # No match found + return None + from pywinauto.controls.uiawrapper import UIAWrapper + + def _match_controller(self, control_to_match: UIAWrapper, control_label: str, control_text: str) -> bool: + """ + Matches the controller based on the strategy specified in the config file. + :param control_to_match: The control object to match against. + :param control_label: The label of the control to identify it. + :param control_text: The text content of the control for additional context. + :return: True if a match is found based on the strategy; False otherwise. + """ + control_name = control_to_match.class_name() if control_to_match.class_name() else "" # 默认空字符串 + control_content = control_to_match.window_text() if control_to_match.window_text() else "" # 默认空字符串 + + if _MATCH_STRATEGY == "contains": + return control_label in control_name and control_text in control_content + elif _MATCH_STRATEGY == "fuzzy": + similarity_label = fuzz.partial_ratio(control_name, control_label) + similarity_text = fuzz.partial_ratio(control_content, control_text) + return similarity_label >= 70 and similarity_text >= 70 + elif _MATCH_STRATEGY == "regex": + combined_name_1 = f"{control_label}.*{control_text}" + combined_name_2 = f"{control_text}.*{control_label}" + pattern_1 = re.compile(combined_name_1, flags=re.IGNORECASE) + pattern_2 = re.compile(combined_name_2, flags=re.IGNORECASE) + + return ( + (re.search(pattern_1, control_name) is not None) + or (re.search(pattern_2, control_name) is not None) + ) + else: + logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") + raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") \ No newline at end of file diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py index e3116af7..5d9ed951 100644 --- a/instantiation/controller/instantiation_process.py +++ b/instantiation/controller/instantiation_process.py @@ -7,6 +7,8 @@ from enum import Enum from typing import Any, Dict +from zmq import Context + from instantiation.config.config import Config from ufo.module.basic import BaseSession @@ -91,7 +93,6 @@ def instantiate_files(self, task_dir_name: str) -> None: _configs["TASKS_HUB"], task_dir_name, "*" ) all_task_files = glob.glob(all_task_file_path) - for index, task_file in enumerate(all_task_files, start=1): print(f"Task starts: {index} / {len(all_task_files)}") try: @@ -112,6 +113,7 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow from instantiation.controller.workflow.filter_flow import FilterFlow from instantiation.controller.workflow.prefill_flow import PrefillFlow + from instantiation.controller.workflow.execute_flow import ExecuteFlow # Initialize the app environment and the task file name. app_object = task_object.app_object @@ -139,6 +141,12 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: is_quality_good, filter_result, request_type = filter_flow.execute( instantiated_request ) + from instantiation.controller.workflow.execute_flow import Context + context = Context() + execute_flow = ExecuteFlow(app_env, task_file_name, context) + is_quality_good, execute_result = execute_flow.execute( + instantiated_request, instantiated_plan + ) # Calculate total execution time for the process total_execution_time = round(time.time() - start_time, 3) @@ -148,11 +156,15 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: "choose_template": choose_template_flow.execution_time, "prefill": prefill_flow.execution_time, "filter": filter_flow.execution_time, + "execute": execute_flow.execution_time, "total": total_execution_time, } # Prepare the result structure to capture the filter result - result = {"filter": filter_result} + result = { + "filter": filter_result, + "execute": execute_result, + } # Create a summary of the instantiated task information instantiated_task_info = { diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index c3d84a29..89faa924 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -332,3 +332,176 @@ def examples_prompt_helper( example_list += [json.dumps(example) for example in additional_examples] return self.retrived_documents_prompt_helper(header, separator, example_list) + +class ExecutePrompter(BasicPrompter): + """ + Load the prompt for the ExecuteAgent. + """ + + def __init__( + self, + is_visual: bool, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + ): + """ + Initialize the ExecutePrompter. + :param is_visual: The flag indicating whether the prompter is visual or not. + :param prompt_template: The prompt template. + :param example_prompt_template: The example prompt template. + :param api_prompt_template: The API prompt template. + """ + + super().__init__(is_visual, prompt_template, example_prompt_template) + self.api_prompt_template = self.load_prompt_template( + api_prompt_template, is_visual + ) + + def api_prompt_helper(self, verbose: int = 1) -> str: + """ + Construct the prompt for APIs. + :param verbose: The verbosity level. + :return: The prompt for APIs. + """ + + # Construct the prompt for APIs + api_list = [ + "- The action type are limited to {actions}.".format( + actions=list(self.api_prompt_template.keys()) + ) + ] + + # Construct the prompt for each API + for key in self.api_prompt_template.keys(): + api = self.api_prompt_template[key] + if verbose > 0: + api_text = "{summary}\n{usage}".format( + summary=api["summary"], usage=api["usage"] + ) + else: + api_text = api["summary"] + + api_list.append(api_text) + + api_prompt = self.retrived_documents_prompt_helper("", "", api_list) + + return api_prompt + + def system_prompt_construction(self, additional_examples: List = []) -> str: + """ + Construct the prompt for the system. + :param additional_examples: The additional examples. + :return: The prompt for the system. + """ + + examples = self.examples_prompt_helper(additional_examples=additional_examples) + apis = self.api_prompt_helper(verbose=0) + return self.prompt_template["system"].format(apis=apis, examples=examples) + + def user_prompt_construction( + self, given_task: str, reference_steps: List, doc_control_state: Dict + ) -> str: + """ + Construct the prompt for the user. + :param given_task: The given task. + :param reference_steps: The reference steps. + :param doc_control_state: The document control state. + :return: The prompt for the user. + """ + + prompt = self.prompt_template["user"].format( + given_task=given_task, + reference_steps=json.dumps(reference_steps), + doc_control_state=json.dumps(doc_control_state), + ) + + return prompt + + def load_screenshots(self, log_path: str) -> str: + """ + Load the first and last screenshots from the log path. + :param log_path: The path of the log. + :return: The screenshot URL. + """ + from ufo.prompter.eva_prompter import EvaluationAgentPrompter + + init_image = os.path.join(log_path, "screenshot.png") + init_image_url = EvaluationAgentPrompter.load_single_screenshot(init_image) + return init_image_url + + def user_content_construction( + self, + given_task: str, + reference_steps: List, + doc_control_state: Dict, + log_path: str, + ) -> List[Dict]: + """ + Construct the prompt for LLMs. + :param given_task: The given task. + :param reference_steps: The reference steps. + :param doc_control_state: The document control state. + :param log_path: The path of the log. + :return: The prompt for LLMs. + """ + + user_content = [] + if self.is_visual: + screenshot = self.load_screenshots(log_path) + screenshot_text = """You are a action execute agent, responsible to execute the given task. + This is the screenshot of the current environment, please check it and execute task accodingly.""" + + user_content.append({"type": "text", "text": screenshot_text}) + user_content.append({"type": "image_url", "image_url": {"url": screenshot}}) + + user_content.append( + { + "type": "text", + "text": self.user_prompt_construction( + given_task, reference_steps, doc_control_state + ), + } + ) + + return user_content + + def examples_prompt_helper( + self, + header: str = "## Response Examples", + separator: str = "Example", + additional_examples: List[str] = [], + ) -> str: + """ + Construct the prompt for examples. + :param header: The header of the prompt. + :param separator: The separator of the prompt. + :param additional_examples: The additional examples. + :return: The prompt for examples. + """ + + template = """ + [User Request]: + {request} + [Response]: + {response} + [Tip] + {tip} + """ + + example_list = [] + + for key in self.example_prompt_template.keys(): + if key.startswith("example"): + example = template.format( + request=self.example_prompt_template[key].get("Request"), + response=json.dumps( + self.example_prompt_template[key].get("Response") + ), + tip=self.example_prompt_template[key].get("Tips", ""), + ) + example_list.append(example) + + example_list += [json.dumps(example) for example in additional_examples] + + return self.retrived_documents_prompt_helper(header, separator, example_list) diff --git a/instantiation/controller/prompts/visual/execute.yaml b/instantiation/controller/prompts/visual/execute.yaml new file mode 100644 index 00000000..7d25195f --- /dev/null +++ b/instantiation/controller/prompts/visual/execute.yaml @@ -0,0 +1,26 @@ +version: 1.0 + +system: |- + You are a task judge, will be provided with a task in the . You need to judge whether this task can be executed locally. + + ## Evaluation Dimension + The task is only related to {app}. + This task should be like a task, not subjective considerations. For example, if there are 'custom', 'you want' and other situations, they cannot be considered and should return false and be classified as Non_task. Any subjective will crash the system. + This task should specify the element, for example, if there are only 'text' without the specific string, they cannot be considered and should return false and be classified as Non_task. + This task should not involve interactions with other application plug-ins, etc., and only rely on Word. If 'Excel', 'Edge' and other interactions are involved, it should return false and be classified as App_involve. + This task should not involve version updates and other interactions that depend on the environment, but only rely on the current version, and do not want to be upgraded or downgraded. It should return false and be classified as Env. + There are other things that you think cannot be executed or are irrelevant, return False, and be classified as Others + + ## Response Format + Your response should be strictly structured in a JSON format, consisting of three distinct parts with the following keys and corresponding content: + {{ + "judge": true or false depends on you think this task whether can be performed. + "thought": "Outline the reason why you give the judgement." + "type": "None/Non_task/App_involve/Env/Others" + }} + Make sure you answer must be strictly in JSON format only, without other redundant text such as json header. Otherwise it will crash the system. + Below is only a example of the response. Do not fall in the example. + +user: |- + {request} + \ No newline at end of file diff --git a/instantiation/controller/workflow/execute_flow.py b/instantiation/controller/workflow/execute_flow.py new file mode 100644 index 00000000..9f97756e --- /dev/null +++ b/instantiation/controller/workflow/execute_flow.py @@ -0,0 +1,123 @@ +import json +import logging +import os +import time +from typing import Dict, Tuple + +from zmq import Context + +from instantiation.config.config import Config +from instantiation.controller.env.env_manager import WindowsAppEnv +from instantiation.controller.agent.agent import ExecuteAgent +from ufo.agents.processors.app_agent_processor import AppAgentProcessor +from ufo.automator import puppeteer +from ufo.module.basic import BaseSession, Context, ContextNames + +_configs = Config.get_instance().config_data + +class ExecuteFlow(AppAgentProcessor): + """ + Class to refine the plan steps and prefill the file based on executeing criteria. + """ + + _app_execute_agent_dict: Dict[str, ExecuteAgent] = {} + + def __init__(self, environment: WindowsAppEnv, task_file_name: str, context: Context) -> None: + """ + Initialize the execute flow for a task. + :param app_object: Application object containing task details. + :param task_file_name: Name of the task file being processed. + """ + super().__init__(agent=ExecuteAgent, context=context) + + self.execution_time = 0 + self._app_env = environment + self._task_file_name = task_file_name + self._app_name = self._app_env.app_name + + + # FIXME: os.makedirs() function should be invloved in the Context class + log_path = _configs["EXECUTE_LOG_PATH"].format(task=task_file_name) + os.makedirs(log_path, exist_ok=True) + self.context.set(ContextNames.LOG_PATH, log_path) + self.application_window = self._app_env.find_matching_window(task_file_name) + # self.log_path = _configs["EXECUTE_LOG_PATH"].format(task=task_file_name) + self.app_agent = self._get_or_create_execute_agent() + self._initialize_logs() + + def _get_or_create_execute_agent(self) -> ExecuteAgent: + """ + Retrieve or create a execute agent for the given application. + :return: ExecuteAgent instance for the specified application. + """ + if self._app_name not in ExecuteFlow._app_execute_agent_dict: + ExecuteFlow._app_execute_agent_dict[self._app_name] = ExecuteAgent( + "execute", + self._app_name, + self._app_env.app_root_name, + is_visual=True, + main_prompt=_configs["EXECUTE_PROMPT"], + example_prompt="", + api_prompt=_configs["API_PROMPT"], + ) + return ExecuteFlow._app_execute_agent_dict[self._app_name] + + def execute(self, instantiated_request, instantiated_plan) -> Tuple[bool, str, str]: + """ + Execute the execute flow: Execute the task and save the result. + :param instantiated_request: Request object to be executeed. + :return: Tuple containing task quality flag, comment, and task type. + """ + start_time = time.time() + is_quality_good, execute_result = self._get_executeed_result( + instantiated_request, instantiated_plan + ) + self.execution_time = round(time.time() - start_time, 3) + return is_quality_good, execute_result + + def _initialize_logs(self) -> None: + """ + Initialize logging for execute messages and responses. + """ + os.makedirs(self.log_path, exist_ok=True) + self._execute_message_logger = BaseSession.initialize_logger( + self.log_path, "execute_log.json", "w", _configs + ) + + from typing import Tuple + + def _get_executeed_result(self, instantiated_request, instantiated_plan) -> Tuple[bool, str, str]: + """ + Get the executeed result from the execute agent. + :param instantiated_request: Request object containing task details. + :param instantiated_plan: Plan containing steps to execute. + :return: Tuple containing task quality flag, request comment, and request type. + """ + execute_result_dict = {} + print("Starting execution of instantiated plan...") + # print("Instantiated plan:", instantiated_plan) + # self.capture_screenshot() + + for index, step_context in enumerate(instantiated_plan): + # step = index + 1 + # control_label = step_context["controlLabel"] + # control_text = step_context["controlText"] + # function_name = step_context["function"] + # function_args = step_context["args"] + print(f"Executing step {index + 1}: {step_context}") + + self._response = step_context + self.parse_response() + self.capture_screenshot() + self.execute_action() + + print("Execution complete.") + # print("Execueted result:", execute_result_dict) + is_quality_good = True + + self._app_env.close() + + # Return the evaluation result (assuming it returns a tuple as specified) + return is_quality_good, execute_result_dict # Ensure this matches the expected Tuple[bool, str, str] + + diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index 2fcbd536..7e4979aa 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -40,7 +40,6 @@ def __init__( self._app_env = environment self._task_file_name = task_file_name self._app_name = self._app_env.app_name - # Create or reuse a PrefillAgent for the app if self._app_name not in PrefillFlow._app_prefill_agent_dict: PrefillFlow._app_prefill_agent_dict[self._app_name] = PrefillAgent( @@ -120,8 +119,8 @@ def _instantiate_task( logging.exception(f"Error in prefilling task: {e}") raise - finally: - self._app_env.close() + # finally: + # self._app_env.close() return instantiated_request, instantiated_plan def _update_state(self, file_path: str) -> None: diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index 2524092a..b34b315d 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -2,8 +2,6 @@ import os import sys - -# Add the project root to the system path. def add_project_root_to_sys_path() -> None: """Add project root to system path if not already present.""" current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -14,7 +12,6 @@ def add_project_root_to_sys_path() -> None: def parse_arguments() -> argparse.Namespace: """Parse command-line arguments. - :return: Parsed command-line arguments. """ parser = argparse.ArgumentParser() diff --git a/ufo/agents/agent/app_agent.py b/ufo/agents/agent/app_agent.py index 7485a5bb..ed84441f 100644 --- a/ufo/agents/agent/app_agent.py +++ b/ufo/agents/agent/app_agent.py @@ -37,7 +37,7 @@ def __init__( skip_prompter: bool = False, ) -> None: """ - Initialize the AppAgent. + Initialize the AppAgent.TODO:simplify related init :name: The name of the agent. :param process_name: The process name of the app. :param app_root_name: The root name of the app. @@ -58,8 +58,7 @@ def __init__( self.online_doc_retriever = None self.experience_retriever = None self.human_demonstration_retriever = None - - self.Puppeteer = self.create_puppteer_interface() + self.Puppeteer = self.create_puppeteer_interface() self.set_state(ContinueAppAgentState()) def get_prompter( @@ -295,7 +294,7 @@ def process(self, context: Context) -> None: self.processor.process() self.status = self.processor.status - def create_puppteer_interface(self) -> puppeteer.AppPuppeteer: + def create_puppeteer_interface(self) -> puppeteer.AppPuppeteer: """ Create the Puppeteer interface to automate the app. :return: The Puppeteer interface. diff --git a/ufo/agents/agent/basic.py b/ufo/agents/agent/basic.py index 1f54e08e..a32eca0b 100644 --- a/ufo/agents/agent/basic.py +++ b/ufo/agents/agent/basic.py @@ -98,7 +98,7 @@ def blackboard(self) -> Blackboard: """ return self.host.blackboard - def create_puppteer_interface(self) -> puppeteer.AppPuppeteer: + def create_puppeteer_interface(self) -> puppeteer.AppPuppeteer: """ Create the puppeteer interface. """ diff --git a/ufo/agents/processors/app_agent_processor.py b/ufo/agents/processors/app_agent_processor.py index bc6aa896..5a2650d4 100644 --- a/ufo/agents/processors/app_agent_processor.py +++ b/ufo/agents/processors/app_agent_processor.py @@ -11,6 +11,7 @@ from ufo import utils from ufo.agents.processors.basic import BaseProcessor +from ufo.agents.states.basic import AgentStatus from ufo.automator.ui_control.screenshot import PhotographerDecorator from ufo.automator.ui_control.control_filter import ControlFilterFactory from ufo.config.config import Config @@ -135,7 +136,8 @@ def capture_screenshot(self) -> None: ) # If the configuration is set to include the last screenshot with selected controls tagged, save the last screenshot. - if configs["INCLUDE_LAST_SCREENSHOT"]: + # if configs["INCLUDE_LAST_SCREENSHOT"]: + if False: last_screenshot_save_path = ( self.log_path + f"action_step{self.session_step - 1}.png" ) @@ -261,29 +263,29 @@ def parse_response(self) -> None: """ # Try to parse the response. If an error occurs, catch the exception and log the error. - try: - self._response_json = self.app_agent.response_to_dict(self._response) - - except Exception: - self.general_error_handler() - - self._control_label = self._response_json.get("ControlLabel", "") - self.control_text = self._response_json.get("ControlText", "") - self._operation = self._response_json.get("Function", "") - self.question_list = self._response_json.get("Questions", []) - self._args = utils.revise_line_breaks(self._response_json.get("Args", "")) + # try: + # self._response_json = self.app_agent.response_to_dict(self._response) + + # except Exception: + # self.general_error_handler() + # FIXME:The cationing of the namefiled affect the code could not get the data from the json + self._control_label = self._response.get("controlLabel", "") + self.control_text = self._response.get("controlText", "") + self._operation = self._response.get("function", "") + self.question_list = self._response.get("questions", []) + self._args = utils.revise_line_breaks(self._response.get("args", "")) # Convert the plan from a string to a list if the plan is a string. - self.plan = self.string2list(self._response_json.get("Plan", "")) - self._response_json["Plan"] = self.plan + self.plan = self.string2list(self._response.get("plan", "")) + self._response["plan"] = self.plan # Compose the function call and the arguments string. self.action = self.app_agent.Puppeteer.get_command_string( self._operation, self._args ) - self.status = self._response_json.get("Status", "") - self.app_agent.print_response(self._response_json) + self.status = self._response.get("status", "") + # self.app_agent.print_response(self._response) @BaseProcessor.method_timer def execute_action(self) -> None: @@ -292,6 +294,9 @@ def execute_action(self) -> None: """ control_selected = self._annotation_dict.get(self._control_label, None) + control_selected_2 = self._annotation_dict.get(self.control_text, None) + if control_selected is None and control_selected_2 is not None: + control_selected = control_selected_2 try: # Get the selected control item from the annotation dictionary and LLM response. @@ -329,8 +334,13 @@ def execute_action(self) -> None: # Save the screenshot of the tagged selected control. self.capture_control_screenshot(control_selected) + + # # AttributeError: 'property' object has no attribute 'SCREENSHOT' + # agentStatus = self._agent_status_manager + # status_value = agentStatus.SCREENSHOT.value + # if self.status.upper() == self._agent_status_manager.SCREENSHOT.value: - if self.status.upper() == self._agent_status_manager.SCREENSHOT.value: + if self.status.upper() == "SCREENSHOT": self.handle_screenshot_status() else: self._results = self.app_agent.Puppeteer.execute_command( @@ -403,7 +413,7 @@ def update_memory(self) -> None: "Cost": self._cost, "Results": self._results, } - self._memory_data.set_values_from_dict(self._response_json) + self._memory_data.set_values_from_dict(self._response) self._memory_data.set_values_from_dict(additional_memory) self._memory_data.set_values_from_dict(self._control_log) self._memory_data.set_values_from_dict({"time_cost": self._time_cost}) @@ -439,7 +449,7 @@ def _update_image_blackboard(self) -> None: """ Save the screenshot to the blackboard if the SaveScreenshot flag is set to True by the AppAgent. """ - screenshot_saving = self._response_json.get("SaveScreenshot", {}) + screenshot_saving = self._response.get("SaveScreenshot", {}) if screenshot_saving.get("save", False): diff --git a/ufo/agents/processors/basic.py b/ufo/agents/processors/basic.py index 9e10c27b..b24134bb 100644 --- a/ufo/agents/processors/basic.py +++ b/ufo/agents/processors/basic.py @@ -50,7 +50,7 @@ def __init__(self, agent: BasicAgent, context: Context) -> None: self._cost = 0 self._control_label = None self._control_text = None - self._response_json = {} + self._response = {} self._memory_data = MemoryItem() self._results = None self._question_list = [] diff --git a/ufo/automator/puppeteer.py b/ufo/automator/puppeteer.py index cd92f1f8..88e15d22 100644 --- a/ufo/automator/puppeteer.py +++ b/ufo/automator/puppeteer.py @@ -236,7 +236,7 @@ def get_receiver_from_command_name(self, command_name: str) -> ReceiverBasic: :param command_name: The command name. :return: The mapped receiver. """ - receiver = self.receiver_registry.get(command_name, None) + receiver = self.receiver_registry.get(command_name, None)#select text, click input, etc. if receiver is None: raise ValueError(f"Receiver for command {command_name} is not found.") return receiver From 0c84528c173d571a37e2535cf7750fe771ece52e Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Tue, 19 Nov 2024 01:34:18 +0800 Subject: [PATCH 19/30] Preliminary implementation of `execution_flow`. --- .gitignore | 5 +- instantiation/config/config_dev.yaml | 10 +- instantiation/controller/agent/agent.py | 44 ++- instantiation/controller/env/env_manager.py | 95 ++++- .../controller/instantiation_process.py | 19 +- .../controller/prompter/agent_prompter.py | 38 ++ .../workflow/choose_template_flow.py | 3 +- .../controller/workflow/execute_flow.py | 363 ++++++++++++++++++ .../controller/workflow/filter_flow.py | 1 - .../controller/workflow/prefill_flow.py | 3 - instantiation/instantiation.py | 7 +- ufo/agents/agent/app_agent.py | 5 +- ufo/agents/agent/basic.py | 2 +- ufo/agents/processors/app_agent_processor.py | 13 +- ufo/automator/puppeteer.py | 2 +- 15 files changed, 579 insertions(+), 31 deletions(-) create mode 100644 instantiation/controller/workflow/execute_flow.py diff --git a/.gitignore b/.gitignore index 15338e1b..68e93709 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,7 @@ scripts/* !vectordb/docs/example/ !vectordb/demonstration/example.yaml -.vscode \ No newline at end of file +.vscode + +# Ignore the logs +logs 3/* \ No newline at end of file diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index 7b5509e2..a3c05b67 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -13,6 +13,7 @@ MATCH_STRATEGY: "regex" # The match strategy for the control filter, support 'c PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill FILTER_PROMPT: "instantiation/controller/prompts/{mode}/filter.yaml" # The prompt for the filter PREFILL_EXAMPLE_PROMPT: "instantiation/controller/prompts/{mode}/prefill_example.yaml" # The prompt for the action prefill example +EXECUTE_PROMPT: "instantiation/controller/prompts/{mode}/execute.yaml" # The prompt for the action execute API_PROMPT: "ufo/prompts/share/lite/api.yaml" # The prompt for the API # Exploration Configuration @@ -28,4 +29,11 @@ CONTROL_FILTER_TOP_K_PLAN: 2 # The control filter effect on top k plans from UF # log path LOG_PATH: "instantiation/logs/{task}" PREFILL_LOG_PATH: "instantiation/logs/{task}/prefill/" -FILTER_LOG_PATH: "instantiation/logs/{task}/filter/" \ No newline at end of file +FILTER_LOG_PATH: "instantiation/logs/{task}/filter/" +EXECUTE_LOG_PATH: "instantiation/logs/{task}/execute/" + +# Screenshot Configuration +RECTANGLE_TIME: 1 +SHOW_VISUAL_OUTLINE_ON_SCREEN: False +INCLUDE_LAST_SCREENSHOT: True # Whether to include the last screenshot in the observation +CONCAT_SCREENSHOT: False # Whether to concat the screenshot for the control item \ No newline at end of file diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index ecfabeb3..3de4b71a 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -3,9 +3,14 @@ from typing import Dict, List -from instantiation.controller.prompter.agent_prompter import FilterPrompter, PrefillPrompter - +from instantiation.controller.prompter.agent_prompter import ( + ExecutePrompter, + FilterPrompter, + PrefillPrompter, +) +from ufo.agents.agent.app_agent import AppAgent from ufo.agents.agent.basic import BasicAgent +from ufo.agents.memory.memory import Memory class FilterAgent(BasicAgent): @@ -164,3 +169,38 @@ def process_comfirmation(self) -> None: This is the abstract method from BasicAgent that needs to be implemented. """ pass + + +class ExecuteAgent(AppAgent): + """ + The Agent for task execution. + """ + + def __init__( + self, + name: str, + process_name: str, + app_root_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the ExecuteAgent. + :param name: The name of the agent. + :param process_name: The name of the process. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + self._step = 0 + self._complete = False + self._name = name + self._status = None + self._process_name = process_name + self._app_root_name = app_root_name + self._memory = Memory() + self.Puppeteer = self.create_puppeteer_interface() diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index 51e9056f..c002a680 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -2,11 +2,13 @@ import re import time -from instantiation.config.config import Config from fuzzywuzzy import fuzz from pywinauto import Desktop +from pywinauto.controls.uiawrapper import UIAWrapper +from instantiation.config.config import Config from ufo.automator.puppeteer import ReceiverManager +from ufo.automator.ui_control.inspector import ControlInspectorFacade # Load configuration settings _configs = Config.get_instance().config_data @@ -36,6 +38,7 @@ def __init__(self, app_object: object) -> None: self.win_com_receiver = self._receive_factory.create_receiver( self.app_root_name, self.app_name ) + self._control_inspector = ControlInspectorFacade(_BACKEND) self._all_controls = None @@ -81,6 +84,7 @@ def find_matching_window(self, doc_name: str) -> object: if self._match_window_name(window_title, doc_name): # Cache all controls for the window self._all_controls = window.children() + self.app_window = window return window return None @@ -112,3 +116,92 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: else: logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") + + def find_matching_controller(self, control_label: str, control_text: str) -> object: + """ + Finds a matching controller based on the control label and control text. + :param control_label: The label of the control to identify it. + :param control_text: The text content of the control for additional context. + :return: The matched controller object or None if no match is found. + """ + # self._all_controls = self._control_inspector.find_control_elements_in_descendants(self.win_com_receiver) + try: + # Retrieve controls to match against + for control in self._all_controls: + if self._match_controller(control, control_label, control_text): + return control + except Exception as e: + # Log the error or handle it as needed + logging.exception(f"Error finding matching controller: {e}") + # Assume log_error is a method for logging errors + raise + # No match found + return None + + def _match_controller( + self, control_to_match: UIAWrapper, control_label: str, control_text: str + ) -> bool: + """ + Matches the controller based on the strategy specified in the config file. + :param control_to_match: The control object to match against. + :param control_label: The label of the control to identify it. + :param control_text: The text content of the control for additional context. + :return: True if a match is found based on the strategy; False otherwise. + """ + control_name = ( + control_to_match.class_name() if control_to_match.class_name() else "" + ) # 默认空字符串 + control_content = ( + control_to_match.window_text() if control_to_match.window_text() else "" + ) # 默认空字符串 + + if _MATCH_STRATEGY == "contains": + return control_label in control_name and control_text in control_content + elif _MATCH_STRATEGY == "fuzzy": + similarity_label = fuzz.partial_ratio(control_name, control_label) + similarity_text = fuzz.partial_ratio(control_content, control_text) + return similarity_label >= 70 and similarity_text >= 70 + elif _MATCH_STRATEGY == "regex": + combined_name_1 = f"{control_label}.*{control_text}" + combined_name_2 = f"{control_text}.*{control_label}" + pattern_1 = re.compile(combined_name_1, flags=re.IGNORECASE) + pattern_2 = re.compile(combined_name_2, flags=re.IGNORECASE) + + return (re.search(pattern_1, control_name) is not None) or ( + re.search(pattern_2, control_name) is not None + ) + else: + logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") + raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") + + def is_matched_controller( + self, control_to_match: UIAWrapper, control_text: str + ) -> bool: + """ + Matches the controller based on the strategy specified in the config file. + :param control_to_match: The control object to match against. + :param control_text: The text content of the control for additional context. + :return: True if a match is found based on the strategy; False otherwise. + """ + control_content = ( + control_to_match.window_text() if control_to_match.window_text() else "" + ) # Default to empty string + + # Match strategies based on the configured _MATCH_STRATEGY + if _MATCH_STRATEGY == "contains": + return ( + control_text in control_content + ) # Check if the control's content contains the target text + elif _MATCH_STRATEGY == "fuzzy": + # Fuzzy matching to compare the content + similarity_text = fuzz.partial_ratio(control_content, control_text) + return similarity_text >= 70 # Set a threshold for fuzzy matching + elif _MATCH_STRATEGY == "regex": + # Use regular expressions for more flexible matching + pattern = re.compile(f"{re.escape(control_text)}", flags=re.IGNORECASE) + return ( + re.search(pattern, control_content) is not None + ) # Return True if pattern matches control content + else: + logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") + raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py index e3116af7..d8658494 100644 --- a/instantiation/controller/instantiation_process.py +++ b/instantiation/controller/instantiation_process.py @@ -7,8 +7,9 @@ from enum import Enum from typing import Any, Dict -from instantiation.config.config import Config +from zmq import Context +from instantiation.config.config import Config from ufo.module.basic import BaseSession # Set the environment variable for the run configuration. @@ -91,7 +92,6 @@ def instantiate_files(self, task_dir_name: str) -> None: _configs["TASKS_HUB"], task_dir_name, "*" ) all_task_files = glob.glob(all_task_file_path) - for index, task_file in enumerate(all_task_files, start=1): print(f"Task starts: {index} / {len(all_task_files)}") try: @@ -109,7 +109,10 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: :param task_object: The TaskObject containing task details. """ from instantiation.controller.env.env_manager import WindowsAppEnv - from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow + from instantiation.controller.workflow.choose_template_flow import ( + ChooseTemplateFlow, + ) + from instantiation.controller.workflow.execute_flow import Context, ExecuteFlow from instantiation.controller.workflow.filter_flow import FilterFlow from instantiation.controller.workflow.prefill_flow import PrefillFlow @@ -118,6 +121,7 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: app_name = app_object.description.lower() app_env = WindowsAppEnv(app_object) task_file_name = task_object.task_file_name + task_file_base_name = task_object.task_file_base_name try: start_time = time.time() @@ -139,6 +143,9 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: is_quality_good, filter_result, request_type = filter_flow.execute( instantiated_request ) + context = Context() + execute_flow = ExecuteFlow(app_env, task_file_name, context) + execute_result = execute_flow.execute(instantiated_plan) # Calculate total execution time for the process total_execution_time = round(time.time() - start_time, 3) @@ -148,11 +155,15 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: "choose_template": choose_template_flow.execution_time, "prefill": prefill_flow.execution_time, "filter": filter_flow.execution_time, + "execute": execute_flow.execution_time, "total": total_execution_time, } # Prepare the result structure to capture the filter result - result = {"filter": filter_result} + result = { + "filter": filter_result, + "execute": execute_result, + } # Create a summary of the instantiated task information instantiated_task_info = { diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index c3d84a29..a4332364 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -332,3 +332,41 @@ def examples_prompt_helper( example_list += [json.dumps(example) for example in additional_examples] return self.retrived_documents_prompt_helper(header, separator, example_list) + + +class ExecutePrompter(BasicPrompter): + """ + Load the prompt for the ExecuteAgent. + """ + + def __init__( + self, + is_visual: bool, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + ): + """ + Initialize the ExecutePrompter. + :param is_visual: The flag indicating whether the prompter is visual or not. + :param prompt_template: The prompt template. + :param example_prompt_template: The example prompt template. + :param api_prompt_template: The API prompt template. + """ + + super().__init__(is_visual, prompt_template, example_prompt_template) + self.api_prompt_template = self.load_prompt_template( + api_prompt_template, is_visual + ) + + def load_screenshots(self, log_path: str) -> str: + """ + Load the first and last screenshots from the log path. + :param log_path: The path of the log. + :return: The screenshot URL. + """ + from ufo.prompter.eva_prompter import EvaluationAgentPrompter + + init_image = os.path.join(log_path, "screenshot.png") + init_image_url = EvaluationAgentPrompter.load_single_screenshot(init_image) + return init_image_url diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index 1fac860b..81b7805a 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -7,12 +7,13 @@ from pathlib import Path from typing import Dict -from instantiation.config.config import Config from langchain.embeddings import CacheBackedEmbeddings from langchain.storage import LocalFileStore from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS +from instantiation.config.config import Config + _configs = Config.get_instance().config_data diff --git a/instantiation/controller/workflow/execute_flow.py b/instantiation/controller/workflow/execute_flow.py new file mode 100644 index 00000000..d783f918 --- /dev/null +++ b/instantiation/controller/workflow/execute_flow.py @@ -0,0 +1,363 @@ +import json +import logging +import os +from textwrap import indent +import time +from typing import Dict, Tuple, Any + +from docx import Document +from zmq import Context + +from instantiation.config.config import Config +from instantiation.controller.env.env_manager import WindowsAppEnv +from instantiation.controller.agent.agent import ExecuteAgent +from ufo import utils +from ufo.agents.processors.app_agent_processor import AppAgentProcessor +from ufo.automator import puppeteer +from ufo.module.basic import BaseSession, Context, ContextNames +from ufo.automator.ui_control.screenshot import PhotographerDecorator +from ufo.agents.memory.memory import Memory + +_configs = Config.get_instance().config_data + +class ExecuteFlow(AppAgentProcessor): + """ + Class to refine the plan steps and prefill the file based on executeing criteria. + """ + + _app_execute_agent_dict: Dict[str, ExecuteAgent] = {} + + def __init__(self, environment: WindowsAppEnv, task_file_name: str, context: Context) -> None: + """ + Initialize the execute flow for a task. + :param app_object: Application object containing task details. + :param task_file_name: Name of the task file being processed. + """ + super().__init__(agent=ExecuteAgent, context=context) + + self.execution_time = 0 + self._app_env = environment + self._task_file_name = task_file_name + self._app_name = self._app_env.app_name + + log_path = _configs["EXECUTE_LOG_PATH"].format(task=task_file_name) + os.makedirs(log_path, exist_ok=True) + self._initialize_logs() + + self.context.set(ContextNames.LOG_PATH, log_path) + self.application_window = self._app_env.find_matching_window(task_file_name) + self.app_agent = self._get_or_create_execute_agent() + + self.save_pass_folder, self.save_error_folder = self._init_save_folders() + + def _get_or_create_execute_agent(self) -> ExecuteAgent: + """ + Retrieve or create a execute agent for the given application. + :return: ExecuteAgent instance for the specified application. + """ + if self._app_name not in ExecuteFlow._app_execute_agent_dict: + ExecuteFlow._app_execute_agent_dict[self._app_name] = ExecuteAgent( + "execute", + self._app_name, + self._app_env.app_root_name, + is_visual=True, + main_prompt=_configs["EXECUTE_PROMPT"], + example_prompt="", + api_prompt=_configs["API_PROMPT"], + ) + return ExecuteFlow._app_execute_agent_dict[self._app_name] + + def execute(self, instantiated_plan) -> Tuple[bool, str, str]: + """ + Execute the execute flow: Execute the task and save the result. + :param instantiated_request: Request object to be executeed. + :return: Tuple containing task quality flag, comment, and task type. + """ + start_time = time.time() + is_quality_good = self._get_executeed_result( + instantiated_plan + ) + self.execution_time = round(time.time() - start_time, 3) + return is_quality_good + + def _initialize_logs(self) -> None: + """ + Initialize logging for execute messages and responses. + """ + os.makedirs(self.log_path, exist_ok=True) + self._execute_message_logger = BaseSession.initialize_logger( + self.log_path, "execute_log.json", "w", _configs + ) + + + def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: + """ + Get the executed result from the execute agent. + :param instantiated_plan: Plan containing steps to execute. + :return: Tuple containing task quality flag, request comment, and request type. + """ + print("Starting execution of instantiated plan...") + + self.step_index = 0 + is_quality_good = True # Initialize as True + + try: + # Initialize the API receiver + self.app_agent.Puppeteer.receiver_manager.create_api_receiver( + self.app_agent._app_root_name, self.app_agent._process_name + ) + + # Get the filtered annotation dictionary for the control items. + self.filtered_annotation_dict = self.get_filtered_control_dict() + + for _, step_plan in enumerate(instantiated_plan): + try: + self.capture_screenshot() + self.step_index += 1 + print(f"Executing step {self.step_index}: {step_plan}") + + # Parse the step plan + self._parse_step_plan(step_plan) + + # Get the filtered annotation dictionary for the control items + control_selected = self.get_selected_controller() + self.capture_control_screenshot(control_selected) + self.execute_action(control_selected) + self._log_step_message() + self._execute_message_logger.info(json.dumps(self.app_agent.memory.to_json(), indent=4)) + + except Exception as step_error: + # Handle errors specific to the step execution + logging.exception(f"Error while executing step {self.step_index}: {step_error}") + self._execute_message_logger.error(f"Step {self.step_index} failed: {step_error}") + + # Mark quality as false due to failure + is_quality_good = False + continue # Continue with the next step + + print("Execution complete.") + + self._app_env.close() + # Return the evaluation result + if is_quality_good: + return "Execution completed successfully." + else: + return "Execution completed with errors." + + except Exception as e: + # Log and handle unexpected errors during the entire process + logging.exception(f"Unexpected error during execution: {e}") + self._execute_message_logger.error(f"Execution failed: {e}") + return False, f"Execution failed: {str(e)}", "Error" + + def _parse_step_plan(self, step_plan) -> None: + """ + Parse the response. + """ + self.control_text = step_plan.get("controlText", "") + self._operation = step_plan.get("function", "") + self.question_list = step_plan.get("questions", []) + self._args = utils.revise_line_breaks(step_plan.get("args", "")) + + # Convert the plan from a string to a list if the plan is a string. + step_plan_key = "step "+str(self.step_index) + self.plan = self.string2list(step_plan.get(step_plan_key, "")) + + # Compose the function call and the arguments string. + self.action = self.app_agent.Puppeteer.get_command_string( + self._operation, self._args + ) + + self.status = step_plan.get("status", "") + + def execute_action(self, control_selected) -> None: + """ + Execute the action. + """ + try: + + if self._operation: + + if _configs.get("SHOW_VISUAL_OUTLINE_ON_SCREEN", True): + control_selected.draw_outline(colour="red", thickness=3) + time.sleep(_configs.get("RECTANGLE_TIME", 0)) + + if control_selected: + control_coordinates = PhotographerDecorator.coordinate_adjusted( + self.application_window.rectangle(), + control_selected.rectangle(), + ) + self._control_log = { + "control_class": control_selected.element_info.class_name, + "control_type": control_selected.element_info.control_type, + "control_automation_id": control_selected.element_info.automation_id, + "control_friendly_class_name": control_selected.friendly_class_name(), + "control_coordinates": { + "left": control_coordinates[0], + "top": control_coordinates[1], + "right": control_coordinates[2], + "bottom": control_coordinates[3], + }, + } + else: + self._control_log = {} + + self.app_agent.Puppeteer.receiver_manager.create_ui_control_receiver( + control_selected, self.application_window + ) + + # Save the screenshot of the tagged selected control. + self.capture_control_screenshot(control_selected) + + self._results = self.app_agent.Puppeteer.execute_command( + self._operation, self._args + ) + self.control_reannotate = None + if not utils.is_json_serializable(self._results): + self._results = "" + + return + + except Exception: + logging.exception("Failed to execute the action.") + raise + + + def get_filtered_control_dict(self): + # Get the control elements in the application window if the control items are not provided for reannotation. + if type(self.control_reannotate) == list and len(self.control_reannotate) > 0: + control_list = self.control_reannotate + else: + control_list = self.control_inspector.find_control_elements_in_descendants( + self.application_window, + control_type_list=_configs["CONTROL_LIST"], + class_name_list=_configs["CONTROL_LIST"], + ) + + # Get the annotation dictionary for the control items, in a format of {control_label: control_element}. + self._annotation_dict = self.photographer.get_annotation_dict( + self.application_window, control_list, annotation_type="number" + ) + + # Attempt to filter out irrelevant control items based on the previous plan. + filtered_annotation_dict = self.get_filtered_annotation_dict( + self._annotation_dict + ) + return filtered_annotation_dict + + def get_selected_controller(self) -> Any: + """ + Find keys in a dictionary where the associated value has a control_text attribute. + + :param annotation_dict: Dictionary with keys as strings and values as UIAWrapper objects. + :return: A dictionary containing the keys and their corresponding control_text. + """ + if self.control_text == "": + return self.application_window + for key, control in self.filtered_annotation_dict.items(): + if self._app_env.is_matched_controller(control, self.control_text): + self._control_label = key + return control + return None + + + def _log_step_message(self) -> None: + """ + Log the constructed prompt message for the PrefillAgent. + + :param step_execution_result: The execution result of the current step. + """ + step_memory = { + "Step": self.step_index, + "Subtask": self.subtask, + "Action": self.action, + "ActionType": self.app_agent.Puppeteer.get_command_types(self._operation), # 操作类型 + "Application": self.app_agent._app_root_name, + "Results": self._results, + "TimeCost": self._time_cost, + } + self._memory_data.set_values_from_dict(step_memory) + + self.app_agent.add_memory(self._memory_data) + + + def capture_screenshot(self) -> None: + """ + Capture the screenshot. + """ + + # Define the paths for the screenshots saved. + screenshot_save_path = self.log_path + f"action_step{self.step_index}.png" + annotated_screenshot_save_path = ( + self.log_path + f"action_step{self.step_index}_annotated.png" + ) + concat_screenshot_save_path = ( + self.log_path + f"action_step{self.step_index}_concat.png" + ) + self._memory_data.set_values_from_dict( + { + "CleanScreenshot": screenshot_save_path, + "AnnotatedScreenshot": annotated_screenshot_save_path, + "ConcatScreenshot": concat_screenshot_save_path, + } + ) + + self.photographer.capture_app_window_screenshot( + self.application_window, save_path=screenshot_save_path + ) + + # Capture the screenshot of the selected control items with annotation and save it. + self.photographer.capture_app_window_screenshot_with_annotation_dict( + self.application_window, + self.filtered_annotation_dict, + annotation_type="number", + save_path=annotated_screenshot_save_path, + ) + + # If the configuration is set to include the last screenshot with selected controls tagged, save the last screenshot. + if _configs["INCLUDE_LAST_SCREENSHOT"] and self.step_index > 0: + last_screenshot_save_path = ( + self.log_path + f"action_step{self.step_index - 1}.png" + ) + last_control_screenshot_save_path = ( + self.log_path + + f"action_step{self.step_index - 1}_selected_controls.png" + ) + self._image_url += [ + self.photographer.encode_image_from_path( + last_control_screenshot_save_path + if os.path.exists(last_control_screenshot_save_path) + else last_screenshot_save_path + ) + ] + + # Whether to concatenate the screenshots of clean screenshot and annotated screenshot into one image. + if _configs["CONCAT_SCREENSHOT"]: + self.photographer.concat_screenshots( + screenshot_save_path, + annotated_screenshot_save_path, + concat_screenshot_save_path, + ) + self._image_url += [ + self.photographer.encode_image_from_path(concat_screenshot_save_path) + ] + else: + screenshot_url = self.photographer.encode_image_from_path( + screenshot_save_path + ) + screenshot_annotated_url = self.photographer.encode_image_from_path( + annotated_screenshot_save_path + ) + self._image_url += [screenshot_url, screenshot_annotated_url] + + + def _init_save_folders(self): + """ + Initialize the folders for saving the execution results. + """ + instance_folder = os.path.join(_configs["TASKS_HUB"], "execute_result") + pass_folder = os.path.join(instance_folder, "execute_pass") + fail_folder = os.path.join(instance_folder, "execute_fail") + os.makedirs(pass_folder, exist_ok=True) + os.makedirs(fail_folder, exist_ok=True) + return pass_folder, fail_folder \ No newline at end of file diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index 16a447bc..bc6ee6ba 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -6,7 +6,6 @@ from instantiation.config.config import Config from instantiation.controller.agent.agent import FilterAgent - from ufo.module.basic import BaseSession _configs = Config.get_instance().config_data diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index 2fcbd536..6329f367 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -7,7 +7,6 @@ from instantiation.config.config import Config from instantiation.controller.agent.agent import PrefillAgent from instantiation.controller.env.env_manager import WindowsAppEnv - from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.automator.ui_control.inspector import ControlInspectorFacade from ufo.automator.ui_control.screenshot import PhotographerFacade @@ -120,8 +119,6 @@ def _instantiate_task( logging.exception(f"Error in prefilling task: {e}") raise - finally: - self._app_env.close() return instantiated_request, instantiated_plan def _update_state(self, file_path: str) -> None: diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index 2524092a..dca8c482 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -3,7 +3,6 @@ import sys -# Add the project root to the system path. def add_project_root_to_sys_path() -> None: """Add project root to system path if not already present.""" current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -14,7 +13,6 @@ def add_project_root_to_sys_path() -> None: def parse_arguments() -> argparse.Namespace: """Parse command-line arguments. - :return: Parsed command-line arguments. """ parser = argparse.ArgumentParser() @@ -27,11 +25,12 @@ def parse_arguments() -> argparse.Namespace: def main() -> None: """Main entry point of the script.""" # Add the project root to the system path. - add_project_root_to_sys_path() - + add_project_root_to_sys_path() + task_dir_name = parse_arguments().task.lower() from instantiation.controller.instantiation_process import InstantiationProcess + InstantiationProcess().instantiate_files(task_dir_name) diff --git a/ufo/agents/agent/app_agent.py b/ufo/agents/agent/app_agent.py index 7485a5bb..5851506b 100644 --- a/ufo/agents/agent/app_agent.py +++ b/ufo/agents/agent/app_agent.py @@ -58,8 +58,7 @@ def __init__( self.online_doc_retriever = None self.experience_retriever = None self.human_demonstration_retriever = None - - self.Puppeteer = self.create_puppteer_interface() + self.Puppeteer = self.create_puppeteer_interface() self.set_state(ContinueAppAgentState()) def get_prompter( @@ -295,7 +294,7 @@ def process(self, context: Context) -> None: self.processor.process() self.status = self.processor.status - def create_puppteer_interface(self) -> puppeteer.AppPuppeteer: + def create_puppeteer_interface(self) -> puppeteer.AppPuppeteer: """ Create the Puppeteer interface to automate the app. :return: The Puppeteer interface. diff --git a/ufo/agents/agent/basic.py b/ufo/agents/agent/basic.py index 1f54e08e..a32eca0b 100644 --- a/ufo/agents/agent/basic.py +++ b/ufo/agents/agent/basic.py @@ -98,7 +98,7 @@ def blackboard(self) -> Blackboard: """ return self.host.blackboard - def create_puppteer_interface(self) -> puppeteer.AppPuppeteer: + def create_puppeteer_interface(self) -> puppeteer.AppPuppeteer: """ Create the puppeteer interface. """ diff --git a/ufo/agents/processors/app_agent_processor.py b/ufo/agents/processors/app_agent_processor.py index bc6aa896..12c15853 100644 --- a/ufo/agents/processors/app_agent_processor.py +++ b/ufo/agents/processors/app_agent_processor.py @@ -330,13 +330,10 @@ def execute_action(self) -> None: # Save the screenshot of the tagged selected control. self.capture_control_screenshot(control_selected) - if self.status.upper() == self._agent_status_manager.SCREENSHOT.value: - self.handle_screenshot_status() - else: - self._results = self.app_agent.Puppeteer.execute_command( - self._operation, self._args - ) - self.control_reannotate = None + self._results = self.app_agent.Puppeteer.execute_command( + self._operation, self._args + ) + self.control_reannotate = None if not utils.is_json_serializable(self._results): self._results = "" @@ -439,7 +436,7 @@ def _update_image_blackboard(self) -> None: """ Save the screenshot to the blackboard if the SaveScreenshot flag is set to True by the AppAgent. """ - screenshot_saving = self._response_json.get("SaveScreenshot", {}) + screenshot_saving = self._response.get("SaveScreenshot", {}) if screenshot_saving.get("save", False): diff --git a/ufo/automator/puppeteer.py b/ufo/automator/puppeteer.py index cd92f1f8..88e15d22 100644 --- a/ufo/automator/puppeteer.py +++ b/ufo/automator/puppeteer.py @@ -236,7 +236,7 @@ def get_receiver_from_command_name(self, command_name: str) -> ReceiverBasic: :param command_name: The command name. :return: The mapped receiver. """ - receiver = self.receiver_registry.get(command_name, None) + receiver = self.receiver_registry.get(command_name, None)#select text, click input, etc. if receiver is None: raise ValueError(f"Receiver for command {command_name} is not found.") return receiver From edb6c74620560102c22b8da3da5bd020d57c7b7c Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Wed, 20 Nov 2024 22:51:56 +0800 Subject: [PATCH 20/30] Fix log and timecost errors. Revise readme. --- instantiation/README.md | 8 ++++ instantiation/config/config_dev.yaml | 2 +- .../controller/instantiation_process.py | 4 +- .../controller/workflow/execute_flow.py | 38 ++++++++++--------- instantiation/tasks/prefill/macro.json | 2 +- .../prefill/{totate.json => rotate.json} | 0 ufo/agents/processors/app_agent_processor.py | 3 +- 7 files changed, 34 insertions(+), 23 deletions(-) rename instantiation/tasks/prefill/{totate.json => rotate.json} (100%) diff --git a/instantiation/README.md b/instantiation/README.md index 54ceb6d0..cf0be7e4 100644 --- a/instantiation/README.md +++ b/instantiation/README.md @@ -212,6 +212,14 @@ The completed task will be evaluated by a filter agent, which will assess it and All encountered error messages and tracebacks are saved in `instantiation/tasks/your_folder_name_instantiated/instances_error/`. + +#### 4. Execute Task + +The instantiated plans will be executed by a execute task. In the execution process, all the implemented steps and the screenshots will be saved, which are shown in `instantiation/log/your_folder_name_instantiated/execute/`. + +If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass/`; otherwise, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_fail/`. + +All encountered error messages and tracebacks are saved in `instantiation/tasks/your_folder_name_instantiated/instances_error/`. ## Notes 1. Users should be careful to save the original files while using this project; otherwise, the files will be closed when the app is shut down. diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index 51696881..b872996b 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -36,5 +36,5 @@ EXECUTE_LOG_PATH: "instantiation/logs/{task}/execute/" # Screenshot Configuration RECTANGLE_TIME: 1 SHOW_VISUAL_OUTLINE_ON_SCREEN: False -INCLUDE_LAST_SCREENSHOT: True # Whether to include the last screenshot in the observation +INCLUDE_LAST_SCREENSHOT: False # Whether to include the last screenshot in the observation CONCAT_SCREENSHOT: False # Whether to concat the screenshot for the control item \ No newline at end of file diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py index 569af57a..a1461833 100644 --- a/instantiation/controller/instantiation_process.py +++ b/instantiation/controller/instantiation_process.py @@ -195,7 +195,7 @@ def _handle_error(self, task_file_base_name: str, error: Exception) -> None: :param error: The exception raised during processing. """ error_folder = os.path.join( - _configs["TASKS_HUB"], "prefill_instantiated", "instances_error" + _configs["TASKS_HUB"], "instantiated", "instances_error" ) os.makedirs(error_folder, exist_ok=True) @@ -229,7 +229,7 @@ def _save_instantiated_task( task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) # Define folder paths for passing and failing instances - instance_folder = os.path.join(_configs["TASKS_HUB"], "prefill_instantiated") + instance_folder = os.path.join(_configs["TASKS_HUB"], "instantiated_results") pass_folder = os.path.join(instance_folder, "instances_pass") fail_folder = os.path.join(instance_folder, "instances_fail") target_folder = pass_folder if is_quality_good else fail_folder diff --git a/instantiation/controller/workflow/execute_flow.py b/instantiation/controller/workflow/execute_flow.py index d783f918..aafc130f 100644 --- a/instantiation/controller/workflow/execute_flow.py +++ b/instantiation/controller/workflow/execute_flow.py @@ -40,11 +40,10 @@ def __init__(self, environment: WindowsAppEnv, task_file_name: str, context: Con self._task_file_name = task_file_name self._app_name = self._app_env.app_name - log_path = _configs["EXECUTE_LOG_PATH"].format(task=task_file_name) - os.makedirs(log_path, exist_ok=True) + self._log_path = _configs["EXECUTE_LOG_PATH"].format(task=task_file_name) self._initialize_logs() - self.context.set(ContextNames.LOG_PATH, log_path) + self.context.set(ContextNames.LOG_PATH, self._log_path) self.application_window = self._app_env.find_matching_window(task_file_name) self.app_agent = self._get_or_create_execute_agent() @@ -84,12 +83,11 @@ def _initialize_logs(self) -> None: """ Initialize logging for execute messages and responses. """ - os.makedirs(self.log_path, exist_ok=True) + os.makedirs(self._log_path, exist_ok=True) self._execute_message_logger = BaseSession.initialize_logger( - self.log_path, "execute_log.json", "w", _configs + self._log_path, "execute_log.json", "w", _configs ) - def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: """ Get the executed result from the execute agent. @@ -100,6 +98,7 @@ def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: self.step_index = 0 is_quality_good = True # Initialize as True + all_steps_data = {} try: # Initialize the API receiver @@ -112,6 +111,7 @@ def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: for _, step_plan in enumerate(instantiated_plan): try: + step_start_time = time.time() self.capture_screenshot() self.step_index += 1 print(f"Executing step {self.step_index}: {step_plan}") @@ -123,8 +123,13 @@ def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: control_selected = self.get_selected_controller() self.capture_control_screenshot(control_selected) self.execute_action(control_selected) + self._time_cost = round(time.time() - step_start_time, 3) self._log_step_message() - self._execute_message_logger.info(json.dumps(self.app_agent.memory.to_json(), indent=4)) + + # Log the step execution message + step_data = self._memory_data.to_dict() + all_steps_data[f"Step {self.step_index}"] = step_data + except Exception as step_error: # Handle errors specific to the step execution @@ -136,8 +141,9 @@ def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: continue # Continue with the next step print("Execution complete.") - + self._execute_message_logger.info(json.dumps(all_steps_data, indent=4)) self._app_env.close() + # Return the evaluation result if is_quality_good: return "Execution completed successfully." @@ -148,7 +154,8 @@ def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: # Log and handle unexpected errors during the entire process logging.exception(f"Unexpected error during execution: {e}") self._execute_message_logger.error(f"Execution failed: {e}") - return False, f"Execution failed: {str(e)}", "Error" + return f"Execution failed: {str(e)}", "Error" + def _parse_step_plan(self, step_plan) -> None: """ @@ -268,7 +275,6 @@ def _log_step_message(self) -> None: :param step_execution_result: The execution result of the current step. """ step_memory = { - "Step": self.step_index, "Subtask": self.subtask, "Action": self.action, "ActionType": self.app_agent.Puppeteer.get_command_types(self._operation), # 操作类型 @@ -278,8 +284,6 @@ def _log_step_message(self) -> None: } self._memory_data.set_values_from_dict(step_memory) - self.app_agent.add_memory(self._memory_data) - def capture_screenshot(self) -> None: """ @@ -287,12 +291,12 @@ def capture_screenshot(self) -> None: """ # Define the paths for the screenshots saved. - screenshot_save_path = self.log_path + f"action_step{self.step_index}.png" + screenshot_save_path = self._log_path + f"action_step{self.step_index}.png" annotated_screenshot_save_path = ( - self.log_path + f"action_step{self.step_index}_annotated.png" + self._log_path + f"action_step{self.step_index}_annotated.png" ) concat_screenshot_save_path = ( - self.log_path + f"action_step{self.step_index}_concat.png" + self._log_path + f"action_step{self.step_index}_concat.png" ) self._memory_data.set_values_from_dict( { @@ -317,10 +321,10 @@ def capture_screenshot(self) -> None: # If the configuration is set to include the last screenshot with selected controls tagged, save the last screenshot. if _configs["INCLUDE_LAST_SCREENSHOT"] and self.step_index > 0: last_screenshot_save_path = ( - self.log_path + f"action_step{self.step_index - 1}.png" + self._log_path + f"action_step{self.step_index - 1}.png" ) last_control_screenshot_save_path = ( - self.log_path + self._log_path + f"action_step{self.step_index - 1}_selected_controls.png" ) self._image_url += [ diff --git a/instantiation/tasks/prefill/macro.json b/instantiation/tasks/prefill/macro.json index a9f18a53..4715a3e6 100644 --- a/instantiation/tasks/prefill/macro.json +++ b/instantiation/tasks/prefill/macro.json @@ -3,7 +3,7 @@ "unique_id": "2", "task": "Run a macro in Word", "refined_steps": [ - "1. In the Macrio name box that appears, type the name of the macro you want to run", + "1. In the Macro name box that appears, type the name of the macro you want to run", "2. Click the Run button to execute the selected macro" ] } \ No newline at end of file diff --git a/instantiation/tasks/prefill/totate.json b/instantiation/tasks/prefill/rotate.json similarity index 100% rename from instantiation/tasks/prefill/totate.json rename to instantiation/tasks/prefill/rotate.json diff --git a/ufo/agents/processors/app_agent_processor.py b/ufo/agents/processors/app_agent_processor.py index 0e705200..9f9a1413 100644 --- a/ufo/agents/processors/app_agent_processor.py +++ b/ufo/agents/processors/app_agent_processor.py @@ -136,8 +136,7 @@ def capture_screenshot(self) -> None: ) # If the configuration is set to include the last screenshot with selected controls tagged, save the last screenshot. - # if configs["INCLUDE_LAST_SCREENSHOT"]: - if False: + if configs["INCLUDE_LAST_SCREENSHOT"]: last_screenshot_save_path = ( self.log_path + f"action_step{self.session_step - 1}.png" ) From 9bf03807b72cb1e86d2dbb10f6399de26a8ba642 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Thu, 28 Nov 2024 02:41:51 +0800 Subject: [PATCH 21/30] modify the execution code according to the comments --- .gitignore | 5 +- instantiation/.gitignore | 1 + instantiation/config/config_dev.yaml | 10 +- instantiation/controller/agent/agent.py | 71 +++- instantiation/controller/env/env_manager.py | 62 +-- .../controller/instantiation_process.py | 164 ++++--- .../controller/prompter/agent_prompter.py | 44 +- .../controller/prompts/visual/prefill.yaml | 40 +- .../controller/workflow/execute_flow.py | 399 ++++++++---------- .../controller/workflow/filter_flow.py | 26 +- .../controller/workflow/prefill_flow.py | 6 +- ufo/agents/processors/app_agent_processor.py | 36 +- 12 files changed, 400 insertions(+), 464 deletions(-) diff --git a/.gitignore b/.gitignore index 68e93709..15338e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,4 @@ scripts/* !vectordb/docs/example/ !vectordb/demonstration/example.yaml -.vscode - -# Ignore the logs -logs 3/* \ No newline at end of file +.vscode \ No newline at end of file diff --git a/instantiation/.gitignore b/instantiation/.gitignore index 9da01687..a8da84cc 100644 --- a/instantiation/.gitignore +++ b/instantiation/.gitignore @@ -7,3 +7,4 @@ templates/word/* logs/* controller/utils/ config/config.yaml +tasks/prefill \ No newline at end of file diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index b872996b..ed0432f3 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -8,13 +8,11 @@ CONTROL_BACKEND: "uia" # The backend for control action CONTROL_LIST: ["Button", "Edit", "TabItem", "Document", "ListItem", "MenuItem", "ScrollBar", "TreeItem", "Hyperlink", "ComboBox", "RadioButton", "DataItem", "Spinner"] PRINT_LOG: False # Whether to print the log LOG_LEVEL: "INFO" # The log level -MATCH_STRATEGY: "regex" # The match strategy for the control filter, support 'contains', 'fuzzy', 'regex' +MATCH_STRATEGY: "fuzzy" # The match strategy for the control filter, support 'contains', 'fuzzy', 'regex' PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill FILTER_PROMPT: "instantiation/controller/prompts/{mode}/filter.yaml" # The prompt for the filter PREFILL_EXAMPLE_PROMPT: "instantiation/controller/prompts/{mode}/prefill_example.yaml" # The prompt for the action prefill example -EXECUTE_PROMPT: "instantiation/controller/prompts/{mode}/execute.yaml" # The prompt for the action execute -EXECUTE_PROMPT: "instantiation/controller/prompts/{mode}/execute.yaml" # The prompt for the action execute API_PROMPT: "ufo/prompts/share/lite/api.yaml" # The prompt for the API # Exploration Configuration @@ -33,8 +31,4 @@ PREFILL_LOG_PATH: "instantiation/logs/{task}/prefill/" FILTER_LOG_PATH: "instantiation/logs/{task}/filter/" EXECUTE_LOG_PATH: "instantiation/logs/{task}/execute/" -# Screenshot Configuration -RECTANGLE_TIME: 1 -SHOW_VISUAL_OUTLINE_ON_SCREEN: False -INCLUDE_LAST_SCREENSHOT: False # Whether to include the last screenshot in the observation -CONCAT_SCREENSHOT: False # Whether to concat the screenshot for the control item \ No newline at end of file +MAX_STEPS: 30 # The max step for the execute_flow diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index 3de4b71a..36041009 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -1,17 +1,16 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from typing import Dict, List +from typing import Dict, List, Optional from instantiation.controller.prompter.agent_prompter import ( - ExecutePrompter, FilterPrompter, PrefillPrompter, + ExecuteEvalAgentPrompter, ) from ufo.agents.agent.app_agent import AppAgent from ufo.agents.agent.basic import BasicAgent -from ufo.agents.memory.memory import Memory - +from ufo.agents.agent.evaluation_agent import EvaluationAgent class FilterAgent(BasicAgent): """ @@ -181,10 +180,6 @@ def __init__( name: str, process_name: str, app_root_name: str, - is_visual: bool, - main_prompt: str, - example_prompt: str, - api_prompt: str, ): """ Initialize the ExecuteAgent. @@ -202,5 +197,63 @@ def __init__( self._status = None self._process_name = process_name self._app_root_name = app_root_name - self._memory = Memory() self.Puppeteer = self.create_puppeteer_interface() + +class ExecuteEvalAgent(EvaluationAgent): + """ + The Agent for task execution evaluation. + """ + + def __init__( + self, + name: str, + app_root_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the ExecuteEvalAgent. + :param name: The name of the agent. + :param app_root_name: The name of the app root. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + super().__init__( + name=name, + app_root_name=app_root_name, + is_visual=is_visual, + main_prompt=main_prompt, + example_prompt=example_prompt, + api_prompt=api_prompt, + ) + + def get_prompter( + self, + is_visual, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + root_name: Optional[str] = None, + ) -> ExecuteEvalAgentPrompter: + """ + Get the prompter for the agent. + :param is_visual: The flag indicating whether the agent is visual or not. + :param prompt_template: The prompt template. + :param example_prompt_template: The example prompt template. + :param api_prompt_template: The API prompt template. + :param root_name: The name of the root. + :return: The prompter. + """ + + return ExecuteEvalAgentPrompter( + is_visual=is_visual, + prompt_template=prompt_template, + example_prompt_template=example_prompt_template, + api_prompt_template=api_prompt_template, + root_name=root_name, + ) \ No newline at end of file diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index 71870b92..4e3c634a 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -1,7 +1,7 @@ import logging import re import time - +from typing import Optional from fuzzywuzzy import fuzz from pywinauto import Desktop from pywinauto.controls.uiawrapper import UIAWrapper @@ -28,7 +28,6 @@ def __init__(self, app_object: object) -> None: :param app_object: The app object containing information about the application. """ super().__init__() - # FIX: 私有属性修改 self.app_window = None self.app_root_name = app_object.app_root_name self.app_name = app_object.description.lower() @@ -40,7 +39,6 @@ def __init__(self, app_object: object) -> None: self.app_root_name, self.app_name ) self._control_inspector = ControlInspectorFacade(_BACKEND) - self._control_inspector = ControlInspectorFacade(_BACKEND) self._all_controls = None @@ -81,7 +79,6 @@ def find_matching_window(self, doc_name: str) -> object: """ desktop = Desktop(backend=_BACKEND) windows_list = desktop.windows() - # windows_list = self._control_inspector.get_desktop_windows() for window in windows_list: window_title = window.element_info.name.lower() if self._match_window_name(window_title, doc_name): @@ -121,63 +118,6 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") - def find_matching_controller(self, control_label: str, control_text: str) -> object: - """ - Finds a matching controller based on the control label and control text. - :param control_label: The label of the control to identify it. - :param control_text: The text content of the control for additional context. - :return: The matched controller object or None if no match is found. - """ - # self._all_controls = self._control_inspector.find_control_elements_in_descendants(self.win_com_receiver) - try: - # Retrieve controls to match against - for control in self._all_controls: - if self._match_controller(control, control_label, control_text): - return control - except Exception as e: - # Log the error or handle it as needed - logging.exception(f"Error finding matching controller: {e}") - # Assume log_error is a method for logging errors - raise - # No match found - return None - - def _match_controller( - self, control_to_match: UIAWrapper, control_label: str, control_text: str - ) -> bool: - """ - Matches the controller based on the strategy specified in the config file. - :param control_to_match: The control object to match against. - :param control_label: The label of the control to identify it. - :param control_text: The text content of the control for additional context. - :return: True if a match is found based on the strategy; False otherwise. - """ - control_name = ( - control_to_match.class_name() if control_to_match.class_name() else "" - ) # 默认空字符串 - control_content = ( - control_to_match.window_text() if control_to_match.window_text() else "" - ) # 默认空字符串 - - if _MATCH_STRATEGY == "contains": - return control_label in control_name and control_text in control_content - elif _MATCH_STRATEGY == "fuzzy": - similarity_label = fuzz.partial_ratio(control_name, control_label) - similarity_text = fuzz.partial_ratio(control_content, control_text) - return similarity_label >= 70 and similarity_text >= 70 - elif _MATCH_STRATEGY == "regex": - combined_name_1 = f"{control_label}.*{control_text}" - combined_name_2 = f"{control_text}.*{control_label}" - pattern_1 = re.compile(combined_name_1, flags=re.IGNORECASE) - pattern_2 = re.compile(combined_name_2, flags=re.IGNORECASE) - - return (re.search(pattern_1, control_name) is not None) or ( - re.search(pattern_2, control_name) is not None - ) - else: - logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") - raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") - def is_matched_controller( self, control_to_match: UIAWrapper, control_text: str ) -> bool: diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py index a1461833..725a7e5b 100644 --- a/instantiation/controller/instantiation_process.py +++ b/instantiation/controller/instantiation_process.py @@ -1,25 +1,26 @@ import glob import json -import logging import os import time +import trace import traceback from enum import Enum -from typing import Any, Dict - -from zmq import Context - -from zmq import Context - +from typing import Any, Dict, Optional +from contextlib import contextmanager from instantiation.config.config import Config -from ufo.module.basic import BaseSession # Set the environment variable for the run configuration. -os.environ["RUN_CONFIGS"] = "false" +os.environ["RUN_CONFIGS"] = "True" # Load configuration data. _configs = Config.get_instance().config_data +@contextmanager +def stage_context(stage_name): + try: + yield stage_name + except Exception as e: + raise e class AppEnum(Enum): """ @@ -96,12 +97,8 @@ def instantiate_files(self, task_dir_name: str) -> None: all_task_files = glob.glob(all_task_file_path) for index, task_file in enumerate(all_task_files, start=1): print(f"Task starts: {index} / {len(all_task_files)}") - try: - task_object = TaskObject(task_dir_name, task_file) - self.instantiate_single_file(task_object) - except Exception as e: - logging.exception(f"Error in task {index}: {str(e)}") - self._handle_error(task_object.task_file_base_name, e) + task_object = TaskObject(task_dir_name, task_file) + self.instantiate_single_file(task_object) print("All tasks have been processed.") @@ -124,106 +121,90 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: app_name = app_object.description.lower() app_env = WindowsAppEnv(app_object) task_file_name = task_object.task_file_name - task_file_base_name = task_object.task_file_base_name + + stage = None # To store which stage the error occurred at + is_quality_good = False + is_completed = "" + instantiated_task_info = { + "unique_id": task_object.unique_id, + "original_task": task_object.task, + "original_steps": task_object.refined_steps, + "instantiated_request": None, + "instantiated_plan": None, + "result": {}, + "time_cost": {} + } # Initialize with a basic structure to avoid "used before assignment" error try: start_time = time.time() # Initialize the template flow and execute it to copy the template - choose_template_flow = ChooseTemplateFlow( - app_name, app_object.file_extension, task_file_name - ) - template_copied_path = choose_template_flow.execute() + with stage_context("choose_template") as stage: + choose_template_flow = ChooseTemplateFlow( + app_name, app_object.file_extension, task_file_name + ) + template_copied_path = choose_template_flow.execute() + instantiated_task_info["time_cost"]["choose_template"] = choose_template_flow.execution_time # Initialize the prefill flow and execute it with the copied template and task details - prefill_flow = PrefillFlow(app_env, task_file_name) - instantiated_request, instantiated_plan = prefill_flow.execute( - template_copied_path, task_object.task, task_object.refined_steps - ) + with stage_context("prefill") as stage: + prefill_flow = PrefillFlow(app_env, task_file_name) + instantiated_request, instantiated_plan = prefill_flow.execute( + template_copied_path, task_object.task, task_object.refined_steps + ) + instantiated_task_info["instantiated_request"] = instantiated_request + instantiated_task_info["instantiated_plan"] = instantiated_plan + instantiated_task_info["time_cost"]["prefill"] = prefill_flow.execution_time # Initialize the filter flow to evaluate the instantiated request - filter_flow = FilterFlow(app_name, task_file_name) - is_quality_good, filter_result, request_type = filter_flow.execute( - instantiated_request - ) - context = Context() - execute_flow = ExecuteFlow(app_env, task_file_name, context) - execute_result = execute_flow.execute(instantiated_plan) + with stage_context("filter") as stage: + filter_flow = FilterFlow(app_name, task_file_name) + is_quality_good, filter_result, request_type = filter_flow.execute( + instantiated_request + ) + instantiated_task_info["result"]["filter"] = filter_result + instantiated_task_info["time_cost"]["filter"] = filter_flow.execution_time + + # Initialize the execute flow and execute it with the instantiated plan + with stage_context("execute") as stage: + context = Context() + execute_flow = ExecuteFlow(app_env, task_file_name, context) + execute_result, _ = execute_flow.execute(task_object.task, instantiated_plan) + is_completed = execute_result["complete"] + instantiated_task_info["result"]["execute"] = execute_result + instantiated_task_info["time_cost"]["execute"] = execute_flow.execution_time # Calculate total execution time for the process - total_execution_time = round(time.time() - start_time, 3) - - # Prepare a dictionary to store the execution time for each stage - execution_time = { - "choose_template": choose_template_flow.execution_time, - "prefill": prefill_flow.execution_time, - "filter": filter_flow.execution_time, - "execute": execute_flow.execution_time, - "execute": execute_flow.execution_time, - "total": total_execution_time, - } + instantiation_time = round(time.time() - start_time, 3) + instantiated_task_info["time_cost"]["total"] = instantiation_time - # Prepare the result structure to capture the filter result - result = { - "filter": filter_result, - "execute": execute_result, - } - - # Create a summary of the instantiated task information - instantiated_task_info = { - "unique_id": task_object.unique_id, - "original_task": task_object.task, - "original_steps": task_object.refined_steps, - "instantiated_request": instantiated_request, - "instantiated_plan": instantiated_plan, - "result": result, - "execution_time": execution_time, + except Exception as e: + instantiated_task_info["error"] = { + "stage": stage, + "type": str(e.__class__), + "error_message": str(e), + "traceback": traceback.format_exc(), } - # Save the instantiated task information using the designated method + finally: + app_env.close() self._save_instantiated_task( - instantiated_task_info, task_object.task_file_base_name, is_quality_good + instantiated_task_info, task_object.task_file_base_name, is_quality_good, is_completed ) - except Exception as e: - logging.exception(f"Error processing task: {str(e)}") - raise - - def _handle_error(self, task_file_base_name: str, error: Exception) -> None: - """ - Handle error logging for task processing. - :param task_file_base_name: The base name of the task file. - :param error: The exception raised during processing. - """ - error_folder = os.path.join( - _configs["TASKS_HUB"], "instantiated", "instances_error" - ) - os.makedirs(error_folder, exist_ok=True) - - err_logger = BaseSession.initialize_logger( - error_folder, task_file_base_name, "w", _configs - ) - - # Use splitlines to keep the original line breaks in traceback - formatted_traceback = traceback.format_exc() - - error_log = { - "error_message": str(error), - "traceback": formatted_traceback, # Keep original traceback line breaks - } - - err_logger.error(json.dumps(error_log, ensure_ascii=False, indent=4)) def _save_instantiated_task( self, instantiated_task_info: Dict[str, Any], task_file_base_name: str, is_quality_good: bool, + is_completed: str, ) -> None: """ Save the instantiated task information to a JSON file. :param instantiated_task_info: A dictionary containing instantiated task details. :param task_file_base_name: The base name of the task file. :param is_quality_good: Indicates whether the quality of the task is good. + :param is_completed: Indicates whether the task is completed. """ # Convert the dictionary to a JSON string task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) @@ -232,7 +213,14 @@ def _save_instantiated_task( instance_folder = os.path.join(_configs["TASKS_HUB"], "instantiated_results") pass_folder = os.path.join(instance_folder, "instances_pass") fail_folder = os.path.join(instance_folder, "instances_fail") - target_folder = pass_folder if is_quality_good else fail_folder + unsure_folder = os.path.join(instance_folder, "instances_unsure") + + if is_completed == "unsure": + target_folder = unsure_folder + elif is_completed == "yes" and is_quality_good: + target_folder = pass_folder + else: + target_folder = fail_folder new_task_path = os.path.join(target_folder, task_file_base_name) os.makedirs(os.path.dirname(new_task_path), exist_ok=True) diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index a4332364..d4806ec8 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -3,9 +3,10 @@ import json import os -from typing import Dict, List +from typing import Dict, List, Optional from ufo.prompter.basic import BasicPrompter +from ufo.prompter.eva_prompter import EvaluationAgentPrompter class FilterPrompter(BasicPrompter): @@ -333,40 +334,35 @@ def examples_prompt_helper( return self.retrived_documents_prompt_helper(header, separator, example_list) - -class ExecutePrompter(BasicPrompter): +class ExecuteEvalAgentPrompter(EvaluationAgentPrompter): """ - Load the prompt for the ExecuteAgent. + Execute the prompt for the ExecuteEvalAgent. """ - def __init__( self, is_visual: bool, prompt_template: str, example_prompt_template: str, api_prompt_template: str, + root_name: Optional[str] = None, ): """ - Initialize the ExecutePrompter. - :param is_visual: The flag indicating whether the prompter is visual or not. - :param prompt_template: The prompt template. - :param example_prompt_template: The example prompt template. - :param api_prompt_template: The API prompt template. + Initialize the CustomEvaluationAgentPrompter. + :param is_visual: Whether the request is for visual model. + :param prompt_template: The path of the prompt template. + :param example_prompt_template: The path of the example prompt template. + :param api_prompt_template: The path of the api prompt template. + :param root_name: The name of the root application. """ + super().__init__(is_visual, prompt_template, example_prompt_template, api_prompt_template, root_name) - super().__init__(is_visual, prompt_template, example_prompt_template) - self.api_prompt_template = self.load_prompt_template( - api_prompt_template, is_visual - ) - - def load_screenshots(self, log_path: str) -> str: + @staticmethod + def load_logs(log_path: str) -> List[Dict[str, str]]: """ - Load the first and last screenshots from the log path. - :param log_path: The path of the log. - :return: The screenshot URL. + Load logs from the log path. """ - from ufo.prompter.eva_prompter import EvaluationAgentPrompter - - init_image = os.path.join(log_path, "screenshot.png") - init_image_url = EvaluationAgentPrompter.load_single_screenshot(init_image) - return init_image_url + log_file_path = os.path.join(log_path, "execute_log.json") + with open(log_file_path, "r") as f: + logs = f.readlines() + logs = [json.loads(log) for log in logs] + return logs \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/prefill.yaml b/instantiation/controller/prompts/visual/prefill.yaml index 46974554..dd003161 100644 --- a/instantiation/controller/prompts/visual/prefill.yaml +++ b/instantiation/controller/prompts/visual/prefill.yaml @@ -59,33 +59,33 @@ system: |- ### Action Call Format - The action call format is the same as the available actions in the API list.You are required to provide the action call format in a JSON format: {{ - "step ": - "controlLabel": . If you believe none of the control item is suitable for the task or the task is complete, kindly output a empty string ''.> - "controlText": .The control text must match exactly with the selected control label. + "Step ": + "ControlLabel": . If you believe none of the control item is suitable for the task or the task is complete, kindly output a empty string ''.> + "ControlText": .The control text must match exactly with the selected control label. If the function to call don't need specify controlText or the task is complete,you can kindly output an empty string ''. If the function to call need to specify controlText and none of the control item is suitable for the task,you should input a possible control name.> - "function": - "args": + "Function": + "Args": }} e.g. {{ - "step 1": "change the borders", - "controlLabel": "", - "controlText": "Borders", - "function": "click_input", - "args": {{ + "Step 1": "change the borders", + "ControlLabel": "", + "ControlText": "Borders", + "Function": "click_input", + "Args": {{ "button": "left", "double": false }} }} {{ - "step 2": "change the borders", - "controlLabel": "101", - "controlText": "Borders", - "function": "click_input", - "args": {{ + "Step 2": "change the borders", + "ControlLabel": "101", + "ControlText": "Borders", + "Function": "click_input", + "Args": {{ "control_id": "101", "button": "left", "double": false @@ -93,11 +93,11 @@ system: |- }} {{ - "step 3": "select the target text", - "controlLabel": "", - "controlText": "", - "function": "select_text", - "args": {{ + "Step 3": "select the target text", + "ControlLabel": "", + "ControlText": "", + "Function": "select_text", + "Args": {{ "text": "Test For Fun" }} }} diff --git a/instantiation/controller/workflow/execute_flow.py b/instantiation/controller/workflow/execute_flow.py index aafc130f..c1e77fab 100644 --- a/instantiation/controller/workflow/execute_flow.py +++ b/instantiation/controller/workflow/execute_flow.py @@ -1,37 +1,34 @@ -import json -import logging import os -from textwrap import indent import time -from typing import Dict, Tuple, Any +from typing import Dict, Tuple, Any, List -from docx import Document -from zmq import Context - -from instantiation.config.config import Config +from instantiation.config.config import Config as InstantiationConfig from instantiation.controller.env.env_manager import WindowsAppEnv -from instantiation.controller.agent.agent import ExecuteAgent +from instantiation.controller.agent.agent import ExecuteAgent, ExecuteEvalAgent from ufo import utils +from ufo.config.config import Config as UFOConfig from ufo.agents.processors.app_agent_processor import AppAgentProcessor -from ufo.automator import puppeteer from ufo.module.basic import BaseSession, Context, ContextNames -from ufo.automator.ui_control.screenshot import PhotographerDecorator -from ufo.agents.memory.memory import Memory -_configs = Config.get_instance().config_data +_configs = InstantiationConfig.get_instance().config_data +_ufo_configs = UFOConfig.get_instance().config_data +if _configs is not None: + BACKEND = _configs["CONTROL_BACKEND"] class ExecuteFlow(AppAgentProcessor): """ - Class to refine the plan steps and prefill the file based on executeing criteria. + ExecuteFlow class for executing the task and saving the result. """ _app_execute_agent_dict: Dict[str, ExecuteAgent] = {} + _app_eval_agent_dict: Dict[str, ExecuteEvalAgent] = {} def __init__(self, environment: WindowsAppEnv, task_file_name: str, context: Context) -> None: """ Initialize the execute flow for a task. - :param app_object: Application object containing task details. + :param environment: Environment object for the application being processed. :param task_file_name: Name of the task file being processed. + :param context: Context object for the current session. """ super().__init__(agent=ExecuteAgent, context=context) @@ -40,14 +37,12 @@ def __init__(self, environment: WindowsAppEnv, task_file_name: str, context: Con self._task_file_name = task_file_name self._app_name = self._app_env.app_name - self._log_path = _configs["EXECUTE_LOG_PATH"].format(task=task_file_name) - self._initialize_logs() + log_path = _configs["EXECUTE_LOG_PATH"].format(task=task_file_name) + self._initialize_logs(log_path) - self.context.set(ContextNames.LOG_PATH, self._log_path) self.application_window = self._app_env.find_matching_window(task_file_name) self.app_agent = self._get_or_create_execute_agent() - - self.save_pass_folder, self.save_error_folder = self._init_save_folders() + self.eval_agent = self._get_or_create_evaluation_agent() def _get_or_create_execute_agent(self) -> ExecuteAgent: """ @@ -59,245 +54,189 @@ def _get_or_create_execute_agent(self) -> ExecuteAgent: "execute", self._app_name, self._app_env.app_root_name, + ) + return ExecuteFlow._app_execute_agent_dict[self._app_name] + + def _get_or_create_evaluation_agent(self) -> ExecuteEvalAgent: + """ + Retrieve or create an evaluation agent for the given application. + :return: ExecuteEvalAgent instance for the specified application. + """ + if self._app_name not in ExecuteFlow._app_eval_agent_dict: + ExecuteFlow._app_eval_agent_dict[self._app_name] = ExecuteEvalAgent( + "evaluation", + self._app_env.app_root_name, is_visual=True, - main_prompt=_configs["EXECUTE_PROMPT"], + main_prompt=_ufo_configs["EVALUATION_PROMPT"], example_prompt="", - api_prompt=_configs["API_PROMPT"], + api_prompt=_ufo_configs["API_PROMPT"], ) - return ExecuteFlow._app_execute_agent_dict[self._app_name] + return ExecuteFlow._app_eval_agent_dict[self._app_name] + + def _initialize_logs(self, log_path: str) -> None: + """ + Initialize logging for execute messages and responses. + """ + os.makedirs(log_path, exist_ok=True) + self._execute_message_logger = BaseSession.initialize_logger( + log_path, "execute_log.json", "w", _configs + ) + self.context.set(ContextNames.LOG_PATH, log_path) + self.context.set(ContextNames.LOGGER, self._execute_message_logger) - def execute(self, instantiated_plan) -> Tuple[bool, str, str]: + def execute(self, request: str, instantiated_plan: List[str]) -> Tuple[Dict[str, str], float]: """ Execute the execute flow: Execute the task and save the result. - :param instantiated_request: Request object to be executeed. + :param request: Original request to be executed. + :param instantiated_plan: Instantiated plan containing steps to execute. :return: Tuple containing task quality flag, comment, and task type. """ start_time = time.time() - is_quality_good = self._get_executeed_result( - instantiated_plan + execute_result, execute_cost = self._get_executed_result( + request, instantiated_plan ) self.execution_time = round(time.time() - start_time, 3) - return is_quality_good - - def _initialize_logs(self) -> None: - """ - Initialize logging for execute messages and responses. - """ - os.makedirs(self._log_path, exist_ok=True) - self._execute_message_logger = BaseSession.initialize_logger( - self._log_path, "execute_log.json", "w", _configs - ) + return execute_result, execute_cost - def _get_executeed_result(self, instantiated_plan) -> Tuple[bool, str, str]: + def _get_executed_result(self, request, instantiated_plan) -> Tuple[Dict[str, str], float]: """ Get the executed result from the execute agent. + :param request: Original request to be executed. :param instantiated_plan: Plan containing steps to execute. :return: Tuple containing task quality flag, request comment, and request type. """ - print("Starting execution of instantiated plan...") - - self.step_index = 0 - is_quality_good = True # Initialize as True - all_steps_data = {} - - try: - # Initialize the API receiver - self.app_agent.Puppeteer.receiver_manager.create_api_receiver( - self.app_agent._app_root_name, self.app_agent._process_name - ) - - # Get the filtered annotation dictionary for the control items. - self.filtered_annotation_dict = self.get_filtered_control_dict() - - for _, step_plan in enumerate(instantiated_plan): - try: - step_start_time = time.time() - self.capture_screenshot() - self.step_index += 1 - print(f"Executing step {self.step_index}: {step_plan}") - - # Parse the step plan - self._parse_step_plan(step_plan) - - # Get the filtered annotation dictionary for the control items - control_selected = self.get_selected_controller() - self.capture_control_screenshot(control_selected) - self.execute_action(control_selected) - self._time_cost = round(time.time() - step_start_time, 3) - self._log_step_message() - - # Log the step execution message - step_data = self._memory_data.to_dict() - all_steps_data[f"Step {self.step_index}"] = step_data - - - except Exception as step_error: - # Handle errors specific to the step execution - logging.exception(f"Error while executing step {self.step_index}: {step_error}") - self._execute_message_logger.error(f"Step {self.step_index} failed: {step_error}") - - # Mark quality as false due to failure - is_quality_good = False - continue # Continue with the next step - - print("Execution complete.") - self._execute_message_logger.info(json.dumps(all_steps_data, indent=4)) - self._app_env.close() - - # Return the evaluation result - if is_quality_good: - return "Execution completed successfully." - else: - return "Execution completed with errors." - - except Exception as e: - # Log and handle unexpected errors during the entire process - logging.exception(f"Unexpected error during execution: {e}") - self._execute_message_logger.error(f"Execution failed: {e}") - return f"Execution failed: {str(e)}", "Error" - - - def _parse_step_plan(self, step_plan) -> None: - """ - Parse the response. - """ - self.control_text = step_plan.get("controlText", "") - self._operation = step_plan.get("function", "") - self.question_list = step_plan.get("questions", []) - self._args = utils.revise_line_breaks(step_plan.get("args", "")) - - # Convert the plan from a string to a list if the plan is a string. - step_plan_key = "step "+str(self.step_index) - self.plan = self.string2list(step_plan.get(step_plan_key, "")) - - # Compose the function call and the arguments string. - self.action = self.app_agent.Puppeteer.get_command_string( - self._operation, self._args + utils.print_with_color("Starting execution of instantiated plan...", "yellow") + # Initialize the step counter and capture the initial screenshot. + self.session_step = 0 + self.capture_screenshot() + # Initialize the API receiver + self.app_agent.Puppeteer.receiver_manager.create_api_receiver( + self.app_agent._app_root_name, self.app_agent._process_name ) - - self.status = step_plan.get("status", "") - - def execute_action(self, control_selected) -> None: - """ - Execute the action. - """ - try: - - if self._operation: - - if _configs.get("SHOW_VISUAL_OUTLINE_ON_SCREEN", True): - control_selected.draw_outline(colour="red", thickness=3) - time.sleep(_configs.get("RECTANGLE_TIME", 0)) - - if control_selected: - control_coordinates = PhotographerDecorator.coordinate_adjusted( - self.application_window.rectangle(), - control_selected.rectangle(), + for _, step_plan in enumerate(instantiated_plan): + try: + self.session_step += 1 + + # Check if the maximum steps have been exceeded. + if self.session_step > _configs["MAX_STEPS"]: + raise RuntimeError( + "Maximum steps exceeded." ) - self._control_log = { - "control_class": control_selected.element_info.class_name, - "control_type": control_selected.element_info.control_type, - "control_automation_id": control_selected.element_info.automation_id, - "control_friendly_class_name": control_selected.friendly_class_name(), - "control_coordinates": { - "left": control_coordinates[0], - "top": control_coordinates[1], - "right": control_coordinates[2], - "bottom": control_coordinates[3], - }, - } - else: - self._control_log = {} - - self.app_agent.Puppeteer.receiver_manager.create_ui_control_receiver( - control_selected, self.application_window - ) + + self._parse_step_plan(step_plan) - # Save the screenshot of the tagged selected control. - self.capture_control_screenshot(control_selected) + try: + self.process() + except Exception as ControllerNotFound: + raise ControllerNotFound + + except Exception as error: + err_info = RuntimeError(f"Step {self.session_step} execution failed. {error}") + utils.print_with_color(f"{err_info}", "red") + raise err_info - self._results = self.app_agent.Puppeteer.execute_command( - self._operation, self._args - ) - self.control_reannotate = None - if not utils.is_json_serializable(self._results): - self._results = "" + print("Execution complete.") - return + utils.print_with_color("Evaluating the session...", "yellow") + result, cost = self.eval_agent.evaluate(request=request, \ + log_path=self.log_path) - except Exception: - logging.exception("Failed to execute the action.") - raise + print(result) + return result, cost - def get_filtered_control_dict(self): - # Get the control elements in the application window if the control items are not provided for reannotation. - if type(self.control_reannotate) == list and len(self.control_reannotate) > 0: - control_list = self.control_reannotate - else: - control_list = self.control_inspector.find_control_elements_in_descendants( - self.application_window, - control_type_list=_configs["CONTROL_LIST"], - class_name_list=_configs["CONTROL_LIST"], - ) - - # Get the annotation dictionary for the control items, in a format of {control_label: control_element}. - self._annotation_dict = self.photographer.get_annotation_dict( - self.application_window, control_list, annotation_type="number" - ) + def process(self) -> None: + """ + Process the current step. + """ + step_start_time = time.time() + self.print_step_info() + self.capture_screenshot() + self.select_controller() + self.execute_action() + self.time_cost = round(time.time() - step_start_time, 3) + self.log_save() - # Attempt to filter out irrelevant control items based on the previous plan. - filtered_annotation_dict = self.get_filtered_annotation_dict( - self._annotation_dict - ) - return filtered_annotation_dict - def get_selected_controller(self) -> Any: + def print_step_info(self) -> None: """ - Find keys in a dictionary where the associated value has a control_text attribute. - - :param annotation_dict: Dictionary with keys as strings and values as UIAWrapper objects. - :return: A dictionary containing the keys and their corresponding control_text. + Print the step information. """ - if self.control_text == "": - return self.application_window - for key, control in self.filtered_annotation_dict.items(): - if self._app_env.is_matched_controller(control, self.control_text): - self._control_label = key - return control - return None - + utils.print_with_color( + "Step {step}: {step_plan}".format( + step=self.session_step, + step_plan=self.plan, + ), + "magenta", + ) - def _log_step_message(self) -> None: + def log_save(self) -> None: """ Log the constructed prompt message for the PrefillAgent. - - :param step_execution_result: The execution result of the current step. """ step_memory = { + "Step": self.session_step, "Subtask": self.subtask, + "ControlLabel": self._control_label, + "ControlText": self.control_text, "Action": self.action, - "ActionType": self.app_agent.Puppeteer.get_command_types(self._operation), # 操作类型 - "Application": self.app_agent._app_root_name, + "ActionType": self.app_agent.Puppeteer.get_command_types(self._operation), + "Plan": self.plan, "Results": self._results, - "TimeCost": self._time_cost, + "Application": self.app_agent._app_root_name, + "TimeCost": self.time_cost, } self._memory_data.set_values_from_dict(step_memory) + self.log(self._memory_data.to_dict()) + + def _parse_step_plan(self, step_plan: Dict[str, Any]) -> None: + """ + Parse the response. + """ + self.control_text = step_plan.get("ControlText", "") + self._operation = step_plan.get("Function", "") + self.question_list = step_plan.get("Questions", []) + self._args = utils.revise_line_breaks(step_plan.get("Args", "")) + + # Convert the plan from a string to a list if the plan is a string. + step_plan_key = "Step "+str(self.session_step) + self.plan = step_plan.get(step_plan_key, "") + + # Compose the function call and the arguments string. + self.action = self.app_agent.Puppeteer.get_command_string( + self._operation, self._args + ) + self.status = step_plan.get("Status", "") + def select_controller(self) -> None: + """ + Select the controller. + """ + if self.control_text == "": + return + for key, control in self.filtered_annotation_dict.items(): + if self._app_env.is_matched_controller(control, self.control_text): + self._control_label = key + return + raise RuntimeError( + f"Control with text '{self.control_text}' not found." + ) + def capture_screenshot(self) -> None: """ Capture the screenshot. """ # Define the paths for the screenshots saved. - screenshot_save_path = self._log_path + f"action_step{self.step_index}.png" + screenshot_save_path = self.log_path + f"action_step{self.session_step}.png" annotated_screenshot_save_path = ( - self._log_path + f"action_step{self.step_index}_annotated.png" + self.log_path + f"action_step{self.session_step}_annotated.png" ) concat_screenshot_save_path = ( - self._log_path + f"action_step{self.step_index}_concat.png" + self.log_path + f"action_step{self.session_step}_concat.png" ) + self._memory_data.set_values_from_dict( { "CleanScreenshot": screenshot_save_path, @@ -306,6 +245,25 @@ def capture_screenshot(self) -> None: } ) + # Get the control elements in the application window if the control items are not provided for reannotation. + if type(self.control_reannotate) == list and len(self.control_reannotate) > 0: + control_list = self.control_reannotate + else: + control_list = self.control_inspector.find_control_elements_in_descendants( + self.application_window, + control_type_list=_ufo_configs["CONTROL_LIST"], + class_name_list=_ufo_configs["CONTROL_LIST"], + ) + + # Get the annotation dictionary for the control items, in a format of {control_label: control_element}. + self._annotation_dict = self.photographer.get_annotation_dict( + self.application_window, control_list, annotation_type="number" + ) + + # Attempt to filter out irrelevant control items based on the previous plan. + self.filtered_annotation_dict = self.get_filtered_annotation_dict( + self._annotation_dict + ) self.photographer.capture_app_window_screenshot( self.application_window, save_path=screenshot_save_path ) @@ -319,13 +277,13 @@ def capture_screenshot(self) -> None: ) # If the configuration is set to include the last screenshot with selected controls tagged, save the last screenshot. - if _configs["INCLUDE_LAST_SCREENSHOT"] and self.step_index > 0: + if _ufo_configs["INCLUDE_LAST_SCREENSHOT"] and self.session_step > 1: last_screenshot_save_path = ( - self._log_path + f"action_step{self.step_index - 1}.png" + self.log_path + f"action_step{self.session_step - 1}.png" ) last_control_screenshot_save_path = ( - self._log_path - + f"action_step{self.step_index - 1}_selected_controls.png" + self.log_path + + f"action_step{self.session_step - 1}_selected_controls.png" ) self._image_url += [ self.photographer.encode_image_from_path( @@ -336,7 +294,7 @@ def capture_screenshot(self) -> None: ] # Whether to concatenate the screenshots of clean screenshot and annotated screenshot into one image. - if _configs["CONCAT_SCREENSHOT"]: + if _ufo_configs["CONCAT_SCREENSHOT"]: self.photographer.concat_screenshots( screenshot_save_path, annotated_screenshot_save_path, @@ -354,14 +312,13 @@ def capture_screenshot(self) -> None: ) self._image_url += [screenshot_url, screenshot_annotated_url] + # Save the XML file for the current state. + if _ufo_configs["LOG_XML"]: + + self._save_to_xml() - def _init_save_folders(self): + def general_error_handler(self) -> None: """ - Initialize the folders for saving the execution results. + Handle general errors. """ - instance_folder = os.path.join(_configs["TASKS_HUB"], "execute_result") - pass_folder = os.path.join(instance_folder, "execute_pass") - fail_folder = os.path.join(instance_folder, "execute_fail") - os.makedirs(pass_folder, exist_ok=True) - os.makedirs(fail_folder, exist_ok=True) - return pass_folder, fail_folder \ No newline at end of file + pass diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index bc6ee6ba..ff60281b 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -3,6 +3,7 @@ import os import time from typing import Dict, Tuple +import re from instantiation.config.config import Config from instantiation.controller.agent.agent import FilterAgent @@ -91,7 +92,13 @@ def _get_filtered_result(self, instantiated_request) -> Tuple[bool, str, str]: response_string, _ = self._filter_agent.get_response( prompt_message, "filter", use_backup_engine=True, configs=_configs ) - response_json = self._filter_agent.response_to_dict(response_string) + try: + fixed_response_string = self._fix_json_commas(response_string) + response_json = self._filter_agent.response_to_dict(fixed_response_string) + except json.JSONDecodeError as e: + logging.error(f"JSONDecodeError: {e.msg} at position {e.pos}. Response: {response_string}") + raise e + execution_time = round(time.time() - start_time, 3) response_json["execution_time"] = execution_time @@ -104,8 +111,15 @@ def _get_filtered_result(self, instantiated_request) -> Tuple[bool, str, str]: ) except Exception as e: - logging.exception( - f"Error in _get_filtered_result: {str(e)} - Prompt: {prompt_message}", - exc_info=True, - ) - raise + logging.error(f"Error occurred while filtering: {e}") + raise e + + def _fix_json_commas(self, json_string: str) -> str: + """ + Function to add missing commas between key-value pairs in a JSON string + and remove newline characters for proper formatting. + """ + # Remove newline characters + json_string = json_string.replace('\n', '') + + return json_string \ No newline at end of file diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index e6d50d28..fbf47625 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -116,7 +116,7 @@ def _instantiate_task( except Exception as e: logging.exception(f"Error in prefilling task: {e}") - raise + raise e return instantiated_request, instantiated_plan @@ -203,7 +203,7 @@ def _get_prefill_actions( except Exception as e: self._status = "ERROR" logging.exception(f"Error in prefilling task: {e}") - raise + raise e finally: # Log the response and execution time self._log_response(response_json, execution_time) @@ -259,4 +259,4 @@ def _save_screenshot(self, doc_name: str, save_path: str) -> None: print(f"Screenshot saved to {save_path}") except Exception as e: logging.exception(f"Failed to save screenshot: {e}") - raise + raise e diff --git a/ufo/agents/processors/app_agent_processor.py b/ufo/agents/processors/app_agent_processor.py index 9f9a1413..d607a8d9 100644 --- a/ufo/agents/processors/app_agent_processor.py +++ b/ufo/agents/processors/app_agent_processor.py @@ -262,29 +262,29 @@ def parse_response(self) -> None: """ # Try to parse the response. If an error occurs, catch the exception and log the error. - # try: - # self._response_json = self.app_agent.response_to_dict(self._response) - - # except Exception: - # self.general_error_handler() - # FIXME:The cationing of the namefiled affect the code could not get the data from the json - self._control_label = self._response.get("controlLabel", "") - self.control_text = self._response.get("controlText", "") - self._operation = self._response.get("function", "") - self.question_list = self._response.get("questions", []) - self._args = utils.revise_line_breaks(self._response.get("args", "")) + try: + self._response_json = self.app_agent.response_to_dict(self._response) + + except Exception: + self.general_error_handler() + + self._control_label = self._response_json.get("ControlLabel", "") + self.control_text = self._response_json.get("ControlText", "") + self._operation = self._response_json.get("Function", "") + self.question_list = self._response_json.get("Questions", []) + self._args = utils.revise_line_breaks(self._response_json.get("Args", "")) # Convert the plan from a string to a list if the plan is a string. - self.plan = self.string2list(self._response.get("plan", "")) - self._response["plan"] = self.plan + self.plan = self.string2list(self._response_json.get("Plan", "")) + self._response_json["Plan"] = self.plan # Compose the function call and the arguments string. self.action = self.app_agent.Puppeteer.get_command_string( self._operation, self._args ) - self.status = self._response.get("status", "") - # self.app_agent.print_response(self._response) + self.status = self._response_json.get("Status", "") + self.app_agent.print_response(self._response_json) @BaseProcessor.method_timer def execute_action(self) -> None: @@ -293,9 +293,6 @@ def execute_action(self) -> None: """ control_selected = self._annotation_dict.get(self._control_label, None) - control_selected_2 = self._annotation_dict.get(self.control_text, None) - if control_selected is None and control_selected_2 is not None: - control_selected = control_selected_2 try: # Get the selected control item from the annotation dictionary and LLM response. @@ -404,7 +401,7 @@ def update_memory(self) -> None: "Cost": self._cost, "Results": self._results, } - self._memory_data.set_values_from_dict(self._response) + self._memory_data.set_values_from_dict(self._response_json) self._memory_data.set_values_from_dict(additional_memory) self._memory_data.set_values_from_dict(self._control_log) self._memory_data.set_values_from_dict({"time_cost": self._time_cost}) @@ -441,7 +438,6 @@ def _update_image_blackboard(self) -> None: Save the screenshot to the blackboard if the SaveScreenshot flag is set to True by the AppAgent. """ screenshot_saving = self._response.get("SaveScreenshot", {}) - screenshot_saving = self._response.get("SaveScreenshot", {}) if screenshot_saving.get("save", False): From a5c9b27a0096fbdd6e4590a1b959056a6a5aa414 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Thu, 28 Nov 2024 03:32:36 +0800 Subject: [PATCH 22/30] modify formats and the descriptions of templates --- instantiation/config/config.py | 3 + instantiation/controller/agent/agent.py | 30 ++++--- instantiation/controller/env/env_manager.py | 8 +- .../controller/instantiation_process.py | 34 +++++--- .../controller/prompter/agent_prompter.py | 15 +++- .../workflow/choose_template_flow.py | 9 ++ .../controller/workflow/execute_flow.py | 85 +++++++++++-------- .../controller/workflow/filter_flow.py | 25 ++++-- .../controller/workflow/prefill_flow.py | 8 ++ instantiation/instantiation.py | 3 +- 10 files changed, 149 insertions(+), 71 deletions(-) diff --git a/instantiation/config/config.py b/instantiation/config/config.py index 6f0bf046..d5227a2f 100644 --- a/instantiation/config/config.py +++ b/instantiation/config/config.py @@ -12,6 +12,7 @@ def __init__(self, config_path="instantiation/config/"): Initializes the Config class. :param config_path: The path to the config file. """ + self.config_data = self.load_config(config_path) @staticmethod @@ -20,6 +21,7 @@ def get_instance(): Get the instance of the Config class. :return: The instance of the Config class. """ + if Config._instance is None: Config._instance = Config() @@ -31,6 +33,7 @@ def optimize_configs(self, configs): :param configs: The configurations to optimize. :return: The optimized configurations. """ + self.update_api_base(configs, "PREFILL_AGENT") self.update_api_base(configs, "FILTER_AGENT") diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index 36041009..08a1f451 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -4,14 +4,12 @@ from typing import Dict, List, Optional from instantiation.controller.prompter.agent_prompter import ( - FilterPrompter, - PrefillPrompter, - ExecuteEvalAgentPrompter, -) + ExecuteEvalAgentPrompter, FilterPrompter, PrefillPrompter) from ufo.agents.agent.app_agent import AppAgent from ufo.agents.agent.basic import BasicAgent from ufo.agents.agent.evaluation_agent import EvaluationAgent + class FilterAgent(BasicAgent): """ The Agent to evaluate the instantiated task is correct or not. @@ -45,15 +43,20 @@ def __init__( ) self._process_name = process_name - def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: + def get_prompter( + self, + is_visual, + main_prompt: str, + example_prompt: str, + api_prompt: str + ) -> FilterPrompter: """ Get the prompt for the agent. - This is the abstract method from BasicAgent that needs to be implemented. :param is_visual: The flag indicating whether the agent is visual or not. :param main_prompt: The main prompt. :param example_prompt: The example prompt. :param api_prompt: The API prompt. - :return: The prompt. + :return: The prompt string. """ return FilterPrompter(is_visual, main_prompt, example_prompt, api_prompt) @@ -65,6 +68,7 @@ def message_constructor(self, request: str, app: str) -> List[str]: :param app: The name of the operated app. :return: The prompt message. """ + filter_agent_prompt_system_message = self.prompter.system_prompt_construction( app=app ) @@ -82,6 +86,7 @@ def process_comfirmation(self) -> None: Confirm the process. This is the abstract method from BasicAgent that needs to be implemented. """ + pass @@ -167,6 +172,7 @@ def process_comfirmation(self) -> None: Confirm the process. This is the abstract method from BasicAgent that needs to be implemented. """ + pass @@ -185,10 +191,7 @@ def __init__( Initialize the ExecuteAgent. :param name: The name of the agent. :param process_name: The name of the process. - :param is_visual: The flag indicating whether the agent is visual or not. - :param main_prompt: The main prompt. - :param example_prompt: The example prompt. - :param api_prompt: The API prompt. + :param app_root_name: The name of the app root. """ self._step = 0 @@ -199,6 +202,7 @@ def __init__( self._app_root_name = app_root_name self.Puppeteer = self.create_puppeteer_interface() + class ExecuteEvalAgent(EvaluationAgent): """ The Agent for task execution evaluation. @@ -231,7 +235,7 @@ def __init__( example_prompt=example_prompt, api_prompt=api_prompt, ) - + def get_prompter( self, is_visual, @@ -256,4 +260,4 @@ def get_prompter( example_prompt_template=example_prompt_template, api_prompt_template=api_prompt_template, root_name=root_name, - ) \ No newline at end of file + ) diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index 4e3c634a..ec29ca6e 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -1,7 +1,7 @@ import logging import re import time -from typing import Optional + from fuzzywuzzy import fuzz from pywinauto import Desktop from pywinauto.controls.uiawrapper import UIAWrapper @@ -27,6 +27,7 @@ def __init__(self, app_object: object) -> None: Initializes the Windows Application Environment. :param app_object: The app object containing information about the application. """ + super().__init__() self.app_window = None self.app_root_name = app_object.app_root_name @@ -47,6 +48,7 @@ def start(self, copied_template_path: str) -> None: Starts the Windows environment. :param copied_template_path: The file path to the copied template to start the environment. """ + from ufo.automator.ui_control import openfile file_controller = openfile.FileController(_BACKEND) @@ -62,6 +64,7 @@ def close(self) -> None: """ Closes the Windows environment. """ + try: com_object = self.win_com_receiver.get_object_from_process_name() com_object.Close() @@ -77,6 +80,7 @@ def find_matching_window(self, doc_name: str) -> object: :param doc_name: The document name associated with the application. :return: The matched window or None if no match is found. """ + desktop = Desktop(backend=_BACKEND) windows_list = desktop.windows() for window in windows_list: @@ -96,6 +100,7 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: :param doc_name: The document name associated with the application. :return: True if a match is found based on the strategy; False otherwise. """ + app_name = self.app_name doc_name = doc_name.lower() @@ -127,6 +132,7 @@ def is_matched_controller( :param control_text: The text content of the control for additional context. :return: True if a match is found based on the strategy; False otherwise. """ + control_content = ( control_to_match.window_text() if control_to_match.window_text() else "" ) # Default to empty string diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py index 725a7e5b..dec7e5cc 100644 --- a/instantiation/controller/instantiation_process.py +++ b/instantiation/controller/instantiation_process.py @@ -2,12 +2,13 @@ import json import os import time -import trace import traceback -from enum import Enum -from typing import Any, Dict, Optional from contextlib import contextmanager +from enum import Enum +from typing import Any, Dict + from instantiation.config.config import Config +from ufo.module.context import Context # Set the environment variable for the run configuration. os.environ["RUN_CONFIGS"] = "True" @@ -15,6 +16,7 @@ # Load configuration data. _configs = Config.get_instance().config_data + @contextmanager def stage_context(stage_name): try: @@ -22,6 +24,7 @@ def stage_context(stage_name): except Exception as e: raise e + class AppEnum(Enum): """ Define the apps that can be used in the instantiation. @@ -38,6 +41,7 @@ def __init__(self, id: int, description: str, file_extension: str, win_app: str) :param file_extension: The file extension of the app. :param win_app: The windows app name of the app. """ + self.id = id self.description = description self.file_extension = file_extension @@ -56,6 +60,7 @@ def __init__(self, task_dir_name: str, task_file: str) -> None: :param task_dir_name: The name of the directory containing the task. :param task_file: The task file to load from. """ + self.task_dir_name = task_dir_name self.task_file = task_file self.task_file_base_name = os.path.basename(task_file) @@ -74,6 +79,7 @@ def _choose_app_from_json(self, task_json_file: dict) -> AppEnum: :param task_json_file: The JSON file of the task. :return: The app object. """ + for app in AppEnum: if app.description.lower() == task_json_file["app"].lower(): return app @@ -91,6 +97,7 @@ def instantiate_files(self, task_dir_name: str) -> None: Instantiate all the task files. :param task_dir_name: The name of the task directory. """ + all_task_file_path: str = os.path.join( _configs["TASKS_HUB"], task_dir_name, "*" ) @@ -107,14 +114,13 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: Execute the process for one task. :param task_object: The TaskObject containing task details. """ + from instantiation.controller.env.env_manager import WindowsAppEnv - from instantiation.controller.workflow.choose_template_flow import ( - ChooseTemplateFlow, - ) - from instantiation.controller.workflow.execute_flow import Context, ExecuteFlow + from instantiation.controller.workflow.choose_template_flow import \ + ChooseTemplateFlow + from instantiation.controller.workflow.execute_flow import ExecuteFlow from instantiation.controller.workflow.filter_flow import FilterFlow from instantiation.controller.workflow.prefill_flow import PrefillFlow - from instantiation.controller.workflow.execute_flow import ExecuteFlow # Initialize the app environment and the task file name. app_object = task_object.app_object @@ -132,7 +138,7 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: "instantiated_request": None, "instantiated_plan": None, "result": {}, - "time_cost": {} + "time_cost": {}, } # Initialize with a basic structure to avoid "used before assignment" error try: @@ -169,7 +175,9 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: with stage_context("execute") as stage: context = Context() execute_flow = ExecuteFlow(app_env, task_file_name, context) - execute_result, _ = execute_flow.execute(task_object.task, instantiated_plan) + execute_result, _ = execute_flow.execute( + task_object.task, instantiated_plan + ) is_completed = execute_result["complete"] instantiated_task_info["result"]["execute"] = execute_result instantiated_task_info["time_cost"]["execute"] = execute_flow.execution_time @@ -189,7 +197,10 @@ def instantiate_single_file(self, task_object: TaskObject) -> None: finally: app_env.close() self._save_instantiated_task( - instantiated_task_info, task_object.task_file_base_name, is_quality_good, is_completed + instantiated_task_info, + task_object.task_file_base_name, + is_quality_good, + is_completed, ) def _save_instantiated_task( @@ -206,6 +217,7 @@ def _save_instantiated_task( :param is_quality_good: Indicates whether the quality of the task is good. :param is_completed: Indicates whether the task is completed. """ + # Convert the dictionary to a JSON string task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index d4806ec8..ab4e57d8 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -252,6 +252,7 @@ def load_screenshots(self, log_path: str) -> str: :param log_path: The path of the log. :return: The screenshot URL. """ + from ufo.prompter.eva_prompter import EvaluationAgentPrompter init_image = os.path.join(log_path, "screenshot.png") @@ -334,10 +335,12 @@ def examples_prompt_helper( return self.retrived_documents_prompt_helper(header, separator, example_list) + class ExecuteEvalAgentPrompter(EvaluationAgentPrompter): """ Execute the prompt for the ExecuteEvalAgent. """ + def __init__( self, is_visual: bool, @@ -354,15 +357,23 @@ def __init__( :param api_prompt_template: The path of the api prompt template. :param root_name: The name of the root application. """ - super().__init__(is_visual, prompt_template, example_prompt_template, api_prompt_template, root_name) + + super().__init__( + is_visual, + prompt_template, + example_prompt_template, + api_prompt_template, + root_name, + ) @staticmethod def load_logs(log_path: str) -> List[Dict[str, str]]: """ Load logs from the log path. """ + log_file_path = os.path.join(log_path, "execute_log.json") with open(log_file_path, "r") as f: logs = f.readlines() logs = [json.loads(log) for log in logs] - return logs \ No newline at end of file + return logs diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index 81b7805a..dc55a4a0 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -31,6 +31,7 @@ def __init__(self, app_name: str, file_extension: str, task_file_name: str): :param file_extension: The file extension of the template. :param task_file_name: The name of the task file. """ + self._app_name = app_name self._file_extension = file_extension self._task_file_name = task_file_name @@ -44,6 +45,7 @@ def execute(self) -> str: Execute the flow and return the copied template path. :return: The path to the copied template file. """ + start_time = time.time() template_copied_path = self._choose_template_and_copy() self.execution_time = round(time.time() - start_time, 3) @@ -59,6 +61,7 @@ def _create_copied_file( :param file_name: Optional; the name of the task file. :return: The path to the newly created cache file. """ + os.makedirs(copy_to_folder_path, exist_ok=True) copied_template_path = self._generate_copied_file_path( copy_to_folder_path, file_name @@ -78,6 +81,7 @@ def _generate_copied_file_path(self, folder_path: Path, file_name: str) -> str: :param file_name: Optional; the name of the task file. :return: The path to the newly created file. """ + template_extension = self._file_extension if file_name: return str(folder_path / f"{file_name}{template_extension}") @@ -89,6 +93,7 @@ def _get_chosen_file_path(self) -> str: Choose the most relevant template file based on the task. :return: The path to the most relevant template file. """ + templates_description_path = ( Path(_configs["TEMPLATE_PATH"]) / self._app_name / "description.json" ) @@ -109,6 +114,7 @@ def _choose_random_template(self) -> str: Select a random template file from the template folder. :return: The path to the randomly selected template file. """ + template_folder = Path(_configs["TEMPLATE_PATH"]) / self._app_name template_files = [f for f in template_folder.iterdir() if f.is_file()] @@ -124,6 +130,7 @@ def _choose_template_and_copy(self) -> str: Choose the template and copy it to the cache folder. :return: The path to the copied template file. """ + chosen_template_file_path = self._get_chosen_file_path() chosen_template_full_path = ( Path(_configs["TEMPLATE_PATH"]) / self._app_name / chosen_template_file_path @@ -146,6 +153,7 @@ def _choose_target_template_file( :param doc_files_description: A dictionary of template file descriptions. :return: The path to the chosen template file. """ + file_doc_map = { desc: file_name for file_name, desc in doc_files_description.items() } @@ -166,6 +174,7 @@ def _load_embedding_model(model_name: str) -> CacheBackedEmbeddings: :param model_name: The name of the embedding model to load. :return: The loaded embedding model. """ + store = LocalFileStore(_configs["CONTROL_EMBEDDING_CACHE_PATH"]) if not model_name.startswith(ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX): model_name = ChooseTemplateFlow._SENTENCE_TRANSFORMERS_PREFIX + model_name diff --git a/instantiation/controller/workflow/execute_flow.py b/instantiation/controller/workflow/execute_flow.py index c1e77fab..9cd1cff4 100644 --- a/instantiation/controller/workflow/execute_flow.py +++ b/instantiation/controller/workflow/execute_flow.py @@ -1,13 +1,13 @@ import os import time -from typing import Dict, Tuple, Any, List - +from typing import Any, Dict, List, Tuple + from instantiation.config.config import Config as InstantiationConfig -from instantiation.controller.env.env_manager import WindowsAppEnv from instantiation.controller.agent.agent import ExecuteAgent, ExecuteEvalAgent +from instantiation.controller.env.env_manager import WindowsAppEnv from ufo import utils -from ufo.config.config import Config as UFOConfig from ufo.agents.processors.app_agent_processor import AppAgentProcessor +from ufo.config.config import Config as UFOConfig from ufo.module.basic import BaseSession, Context, ContextNames _configs = InstantiationConfig.get_instance().config_data @@ -15,6 +15,7 @@ if _configs is not None: BACKEND = _configs["CONTROL_BACKEND"] + class ExecuteFlow(AppAgentProcessor): """ ExecuteFlow class for executing the task and saving the result. @@ -23,13 +24,16 @@ class ExecuteFlow(AppAgentProcessor): _app_execute_agent_dict: Dict[str, ExecuteAgent] = {} _app_eval_agent_dict: Dict[str, ExecuteEvalAgent] = {} - def __init__(self, environment: WindowsAppEnv, task_file_name: str, context: Context) -> None: + def __init__( + self, environment: WindowsAppEnv, task_file_name: str, context: Context + ) -> None: """ Initialize the execute flow for a task. :param environment: Environment object for the application being processed. :param task_file_name: Name of the task file being processed. :param context: Context object for the current session. """ + super().__init__(agent=ExecuteAgent, context=context) self.execution_time = 0 @@ -49,6 +53,7 @@ def _get_or_create_execute_agent(self) -> ExecuteAgent: Retrieve or create a execute agent for the given application. :return: ExecuteAgent instance for the specified application. """ + if self._app_name not in ExecuteFlow._app_execute_agent_dict: ExecuteFlow._app_execute_agent_dict[self._app_name] = ExecuteAgent( "execute", @@ -56,12 +61,13 @@ def _get_or_create_execute_agent(self) -> ExecuteAgent: self._app_env.app_root_name, ) return ExecuteFlow._app_execute_agent_dict[self._app_name] - + def _get_or_create_evaluation_agent(self) -> ExecuteEvalAgent: """ Retrieve or create an evaluation agent for the given application. :return: ExecuteEvalAgent instance for the specified application. """ + if self._app_name not in ExecuteFlow._app_eval_agent_dict: ExecuteFlow._app_eval_agent_dict[self._app_name] = ExecuteEvalAgent( "evaluation", @@ -72,11 +78,12 @@ def _get_or_create_evaluation_agent(self) -> ExecuteEvalAgent: api_prompt=_ufo_configs["API_PROMPT"], ) return ExecuteFlow._app_eval_agent_dict[self._app_name] - + def _initialize_logs(self, log_path: str) -> None: """ Initialize logging for execute messages and responses. """ + os.makedirs(log_path, exist_ok=True) self._execute_message_logger = BaseSession.initialize_logger( log_path, "execute_log.json", "w", _configs @@ -84,27 +91,33 @@ def _initialize_logs(self, log_path: str) -> None: self.context.set(ContextNames.LOG_PATH, log_path) self.context.set(ContextNames.LOGGER, self._execute_message_logger) - def execute(self, request: str, instantiated_plan: List[str]) -> Tuple[Dict[str, str], float]: + def execute( + self, request: str, instantiated_plan: List[str] + ) -> Tuple[Dict[str, str], float]: """ Execute the execute flow: Execute the task and save the result. :param request: Original request to be executed. :param instantiated_plan: Instantiated plan containing steps to execute. :return: Tuple containing task quality flag, comment, and task type. """ + start_time = time.time() execute_result, execute_cost = self._get_executed_result( request, instantiated_plan ) self.execution_time = round(time.time() - start_time, 3) - return execute_result, execute_cost + return execute_result, execute_cost - def _get_executed_result(self, request, instantiated_plan) -> Tuple[Dict[str, str], float]: + def _get_executed_result( + self, request, instantiated_plan + ) -> Tuple[Dict[str, str], float]: """ Get the executed result from the execute agent. :param request: Original request to be executed. :param instantiated_plan: Plan containing steps to execute. :return: Tuple containing task quality flag, request comment, and request type. """ + utils.print_with_color("Starting execution of instantiated plan...", "yellow") # Initialize the step counter and capture the initial screenshot. self.session_step = 0 @@ -119,27 +132,26 @@ def _get_executed_result(self, request, instantiated_plan) -> Tuple[Dict[str, st # Check if the maximum steps have been exceeded. if self.session_step > _configs["MAX_STEPS"]: - raise RuntimeError( - "Maximum steps exceeded." - ) - + raise RuntimeError("Maximum steps exceeded.") + self._parse_step_plan(step_plan) try: self.process() except Exception as ControllerNotFound: raise ControllerNotFound - + except Exception as error: - err_info = RuntimeError(f"Step {self.session_step} execution failed. {error}") + err_info = RuntimeError( + f"Step {self.session_step} execution failed. {error}" + ) utils.print_with_color(f"{err_info}", "red") raise err_info print("Execution complete.") utils.print_with_color("Evaluating the session...", "yellow") - result, cost = self.eval_agent.evaluate(request=request, \ - log_path=self.log_path) + result, cost = self.eval_agent.evaluate(request=request, log_path=self.log_path) print(result) @@ -149,6 +161,7 @@ def process(self) -> None: """ Process the current step. """ + step_start_time = time.time() self.print_step_info() self.capture_screenshot() @@ -157,11 +170,11 @@ def process(self) -> None: self.time_cost = round(time.time() - step_start_time, 3) self.log_save() - def print_step_info(self) -> None: """ Print the step information. """ + utils.print_with_color( "Step {step}: {step_plan}".format( step=self.session_step, @@ -174,17 +187,18 @@ def log_save(self) -> None: """ Log the constructed prompt message for the PrefillAgent. """ + step_memory = { - "Step": self.session_step, - "Subtask": self.subtask, + "Step": self.session_step, + "Subtask": self.subtask, "ControlLabel": self._control_label, "ControlText": self.control_text, - "Action": self.action, - "ActionType": self.app_agent.Puppeteer.get_command_types(self._operation), + "Action": self.action, + "ActionType": self.app_agent.Puppeteer.get_command_types(self._operation), "Plan": self.plan, - "Results": self._results, + "Results": self._results, "Application": self.app_agent._app_root_name, - "TimeCost": self.time_cost, + "TimeCost": self.time_cost, } self._memory_data.set_values_from_dict(step_memory) self.log(self._memory_data.to_dict()) @@ -193,13 +207,14 @@ def _parse_step_plan(self, step_plan: Dict[str, Any]) -> None: """ Parse the response. """ + self.control_text = step_plan.get("ControlText", "") self._operation = step_plan.get("Function", "") self.question_list = step_plan.get("Questions", []) self._args = utils.revise_line_breaks(step_plan.get("Args", "")) - + # Convert the plan from a string to a list if the plan is a string. - step_plan_key = "Step "+str(self.session_step) + step_plan_key = "Step " + str(self.session_step) self.plan = step_plan.get(step_plan_key, "") # Compose the function call and the arguments string. @@ -211,18 +226,17 @@ def _parse_step_plan(self, step_plan: Dict[str, Any]) -> None: def select_controller(self) -> None: """ - Select the controller. + Select the controller. """ + if self.control_text == "": - return + return for key, control in self.filtered_annotation_dict.items(): if self._app_env.is_matched_controller(control, self.control_text): self._control_label = key return - raise RuntimeError( - f"Control with text '{self.control_text}' not found." - ) - + raise RuntimeError(f"Control with text '{self.control_text}' not found.") + def capture_screenshot(self) -> None: """ Capture the screenshot. @@ -277,7 +291,7 @@ def capture_screenshot(self) -> None: ) # If the configuration is set to include the last screenshot with selected controls tagged, save the last screenshot. - if _ufo_configs["INCLUDE_LAST_SCREENSHOT"] and self.session_step > 1: + if _ufo_configs["INCLUDE_LAST_SCREENSHOT"] and self.session_step >= 1: last_screenshot_save_path = ( self.log_path + f"action_step{self.session_step - 1}.png" ) @@ -316,9 +330,10 @@ def capture_screenshot(self) -> None: if _ufo_configs["LOG_XML"]: self._save_to_xml() - + def general_error_handler(self) -> None: """ Handle general errors. """ + pass diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index ff60281b..f9d9fbd1 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -3,7 +3,6 @@ import os import time from typing import Dict, Tuple -import re from instantiation.config.config import Config from instantiation.controller.agent.agent import FilterAgent @@ -25,6 +24,7 @@ def __init__(self, app_name: str, task_file_name: str) -> None: :param app_object: Application object containing task details. :param task_file_name: Name of the task file being processed. """ + self.execution_time = 0 self._app_name = app_name self._log_path_configs = _configs["FILTER_LOG_PATH"].format(task=task_file_name) @@ -36,6 +36,7 @@ def _get_or_create_filter_agent(self) -> FilterAgent: Retrieve or create a filter agent for the given application. :return: FilterAgent instance for the specified application. """ + if self._app_name not in FilterFlow._app_filter_agent_dict: FilterFlow._app_filter_agent_dict[self._app_name] = FilterAgent( "filter", @@ -44,15 +45,16 @@ def _get_or_create_filter_agent(self) -> FilterAgent: main_prompt=_configs["FILTER_PROMPT"], example_prompt="", api_prompt=_configs["API_PROMPT"], - ) + ) return FilterFlow._app_filter_agent_dict[self._app_name] - def execute(self, instantiated_request) -> Tuple[bool, str, str]: + def execute(self, instantiated_request: str) -> Tuple[bool, str, str]: """ Execute the filter flow: Filter the task and save the result. :param instantiated_request: Request object to be filtered. :return: Tuple containing task quality flag, comment, and task type. """ + start_time = time.time() is_quality_good, filter_result, request_type = self._get_filtered_result( instantiated_request @@ -64,6 +66,7 @@ def _initialize_logs(self) -> None: """ Initialize logging for filter messages and responses. """ + os.makedirs(self._log_path_configs, exist_ok=True) self._filter_message_logger = BaseSession.initialize_logger( self._log_path_configs, "filter_messages.json", "w", _configs @@ -72,12 +75,13 @@ def _initialize_logs(self) -> None: self._log_path_configs, "filter_responses.json", "w", _configs ) - def _get_filtered_result(self, instantiated_request) -> Tuple[bool, str, str]: + def _get_filtered_result(self, instantiated_request: str) -> Tuple[bool, str, str]: """ Get the filtered result from the filter agent. :param instantiated_request: Request object containing task details. :return: Tuple containing task quality flag, request comment, and request type. """ + # Construct the prompt message for the filter agent prompt_message = self._filter_agent.message_constructor( instantiated_request, @@ -94,9 +98,13 @@ def _get_filtered_result(self, instantiated_request) -> Tuple[bool, str, str]: ) try: fixed_response_string = self._fix_json_commas(response_string) - response_json = self._filter_agent.response_to_dict(fixed_response_string) + response_json = self._filter_agent.response_to_dict( + fixed_response_string + ) except json.JSONDecodeError as e: - logging.error(f"JSONDecodeError: {e.msg} at position {e.pos}. Response: {response_string}") + logging.error( + f"JSONDecodeError: {e.msg} at position {e.pos}. Response: {response_string}" + ) raise e execution_time = round(time.time() - start_time, 3) @@ -119,7 +127,8 @@ def _fix_json_commas(self, json_string: str) -> str: Function to add missing commas between key-value pairs in a JSON string and remove newline characters for proper formatting. """ + # Remove newline characters - json_string = json_string.replace('\n', '') - + json_string = json_string.replace("\n", "") + return json_string \ No newline at end of file diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index fbf47625..4aa02cfc 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -35,6 +35,7 @@ def __init__( :param environment: The environment of the app. :param task_file_name: The name of the task file for logging and tracking. """ + self.execution_time = 0 self._app_env = environment self._task_file_name = task_file_name @@ -83,6 +84,7 @@ def execute( :param refined_steps: The steps to guide the refinement process. :return: The refined task and corresponding action plans. """ + start_time = time.time() instantiated_request, instantiated_plan = self._instantiate_task( template_copied_path, original_task, refined_steps @@ -101,6 +103,7 @@ def _instantiate_task( :param refined_steps: The steps to guide the refinement process. :return: The refined task and corresponding action plans. """ + self._app_env.start(template_copied_path) try: @@ -125,6 +128,7 @@ def _update_state(self, file_path: str) -> None: Update the current state of the app by inspecting UI elements. :param file_path: Path of the app file to inspect. """ + print(f"Updating the app state using the file: {file_path}") # Retrieve control elements in the app window @@ -169,6 +173,7 @@ def _get_prefill_actions( :param file_path: Path to the task template. :return: The refined task and corresponding action plans. """ + self._update_state(file_path) execution_time = 0 # Save a screenshot of the app state @@ -215,6 +220,7 @@ def _log_message(self, prompt_message: str) -> None: Log the constructed prompt message for the PrefillAgent. :param prompt_message: The message constructed for PrefillAgent. """ + messages_log_entry = { "step": self._execute_step, "messages": prompt_message, @@ -230,6 +236,7 @@ def _log_response( :param response_json: Response data from PrefillAgent. :param execution_time: Time taken for the PrefillAgent call. """ + response_log_entry = { "step": self._execute_step, "execution_time": execution_time, @@ -244,6 +251,7 @@ def _save_screenshot(self, doc_name: str, save_path: str) -> None: :param doc_name: The name or description of the document to match the window. :param save_path: The path where the screenshot will be saved. """ + try: # Find the window matching the document name matched_window = self._app_env.find_matching_window(doc_name) diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py index dca8c482..2e899690 100644 --- a/instantiation/instantiation.py +++ b/instantiation/instantiation.py @@ -29,7 +29,8 @@ def main() -> None: task_dir_name = parse_arguments().task.lower() - from instantiation.controller.instantiation_process import InstantiationProcess + from instantiation.controller.instantiation_process import \ + InstantiationProcess InstantiationProcess().instantiate_files(task_dir_name) From a2d94ffccc8db03dda97248da8b27c36019744c7 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Wed, 4 Dec 2024 00:06:14 +0800 Subject: [PATCH 23/30] Fix several bugs and provide a runnable version for instantiation and execution. --- instantiation/__main__.py | 4 +- instantiation/config/config_dev.yaml | 4 + instantiation/controller/agent/agent.py | 8 +- .../controller/data_flow_controller.py | 380 ++++++++++++++++++ instantiation/controller/env/env_manager.py | 70 ++-- .../controller/instantiation_process.py | 243 ----------- .../controller/prompter/agent_prompter.py | 2 + .../controller/prompts/visual/filter.yaml | 4 +- .../controller/prompts/visual/prefill.yaml | 26 +- .../workflow/choose_template_flow.py | 14 +- .../controller/workflow/execute_flow.py | 182 +++------ .../controller/workflow/filter_flow.py | 31 +- .../controller/workflow/prefill_flow.py | 40 +- instantiation/dataflow.py | 99 +++++ instantiation/instantiation.py | 39 -- 15 files changed, 673 insertions(+), 473 deletions(-) create mode 100644 instantiation/controller/data_flow_controller.py delete mode 100644 instantiation/controller/instantiation_process.py create mode 100644 instantiation/dataflow.py delete mode 100644 instantiation/instantiation.py diff --git a/instantiation/__main__.py b/instantiation/__main__.py index b0f9849d..b811b80b 100644 --- a/instantiation/__main__.py +++ b/instantiation/__main__.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from instantiation import instantiation +from instantiation import dataflow if __name__ == "__main__": # Execute the main script - instantiation.main() + dataflow.main() diff --git a/instantiation/config/config_dev.yaml b/instantiation/config/config_dev.yaml index ed0432f3..c51f72ce 100644 --- a/instantiation/config/config_dev.yaml +++ b/instantiation/config/config_dev.yaml @@ -19,6 +19,10 @@ API_PROMPT: "ufo/prompts/share/lite/api.yaml" # The prompt for the API TASKS_HUB: "instantiation/tasks" # The tasks hub for the exploration TEMPLATE_PATH: "instantiation/templates" # The template path for the exploration +# Result Configuration +RESULT_HUB: "instantiation/results/{mode}" # The result hub, mode is 'instantiation' or 'execution' +RESULT_SCHEMA: "instantiation/result_schema.json" # The JSON Schema for the result log + # For control filtering CONTROL_FILTER_TYPE: [] # The list of control filter type, support 'TEXT', 'SEMANTIC', 'ICON' CONTROL_FILTER_MODEL_SEMANTIC_NAME: "all-MiniLM-L6-v2" # The control filter model name of semantic similarity diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py index 08a1f451..f5a21bde 100644 --- a/instantiation/controller/agent/agent.py +++ b/instantiation/controller/agent/agent.py @@ -45,7 +45,7 @@ def __init__( def get_prompter( self, - is_visual, + is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str @@ -123,7 +123,7 @@ def __init__( ) self._process_name = process_name - def get_prompter(self, is_visual, main_prompt, example_prompt, api_prompt) -> str: + def get_prompter(self, is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str) -> str: """ Get the prompt for the agent. This is the abstract method from BasicAgent that needs to be implemented. @@ -238,7 +238,7 @@ def __init__( def get_prompter( self, - is_visual, + is_visual: bool, prompt_template: str, example_prompt_template: str, api_prompt_template: str, @@ -260,4 +260,4 @@ def get_prompter( example_prompt_template=example_prompt_template, api_prompt_template=api_prompt_template, root_name=root_name, - ) + ) \ No newline at end of file diff --git a/instantiation/controller/data_flow_controller.py b/instantiation/controller/data_flow_controller.py new file mode 100644 index 00000000..52e54de9 --- /dev/null +++ b/instantiation/controller/data_flow_controller.py @@ -0,0 +1,380 @@ +import os +import time +import traceback +from enum import Enum +from typing import Any, Dict +from jsonschema import validate, ValidationError + +from instantiation.controller.env.env_manager import WindowsAppEnv +from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow +from instantiation.controller.workflow.execute_flow import ExecuteFlow +from instantiation.controller.workflow.filter_flow import FilterFlow +from instantiation.controller.workflow.prefill_flow import PrefillFlow +from instantiation.config.config import Config + +from ufo.utils import print_with_color +from learner.utils import load_json_file, save_json_file + +from ufo.agents.processors.app_agent_processor import AppAgentProcessor +from ufo.module.context import Context + +# Set the environment variable for the run configuration. +os.environ["RUN_CONFIGS"] = "True" + +# Load configuration data. +_configs = Config.get_instance().config_data + +INSTANTIATION_RESULT_MAP = { + True: "instantiation_pass", + False: "instantiation_fail" +} + +EXECUTION_RESULT_MAP = { + "pass": "execution_pass", + "fail": "execution_fail", + "unsure": "execution_unsure" +} + +class AppEnum(Enum): + """ + Enum class for applications. + """ + + WORD = 1, "Word", ".docx", "winword" + EXCEL = 2, "Excel", ".xlsx", "excel" + POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" + + def __init__(self, id: int, description: str, file_extension: str, win_app: str): + """ + Initialize the application enum. + :param id: The ID of the application. + :param description: The description of the application. + :param file_extension: The file extension of the application. + :param win_app: The Windows application name. + """ + + self.id = id + self.description = description + self.file_extension = file_extension + self.win_app = win_app + self.app_root_name = win_app.upper() + ".EXE" + + +class TaskObject: + def __init__(self, task_file_path: str, mode: str) -> None: + """ + Initialize the task object. + :param task_file_path: The path to the task file. + :param mode: The mode of the task object (dataflow, instantiation, or execution). + """ + + self.task_file_path = task_file_path + self.task_file_base_name = os.path.basename(task_file_path) + self.task_file_name = self.task_file_base_name.split(".")[0] + + task_json_file = load_json_file(task_file_path) + self.app_object = self._choose_app_from_json(task_json_file["app"]) + # Initialize the task attributes based on the mode + self._init_attr(mode, task_json_file) + + def _choose_app_from_json(self, task_app: str) -> AppEnum: + """ + Choose the app from the task json file. + :param task_app: The app from the task json file. + :return: The app enum. + """ + + for app in AppEnum: + if app.description.lower() == task_app.lower(): + return app + raise ValueError("Not a correct App") + + def _init_attr(self, mode:str, task_json_file:Dict[str, Any]) -> None: + """ + Initialize the attributes of the task object. + :param mode: The mode of the task object (dataflow, instantiation, or execution). + :param task_json_file: The task JSON file. + """ + + if mode == "dataflow" or mode == "instantiation": + for key, value in task_json_file.items(): + setattr(self, key.lower().replace(" ", "_"), value) + elif mode == "execution": + self.app = task_json_file.get("app") + self.unique_id = task_json_file.get("unique_id") + original = task_json_file.get("original", {}) + self.task = original.get("original_task", None) + self.refined_steps = original.get("original_steps", None) + else: + raise ValueError(f"Unsupported mode: {mode}") + +class DataFlowController: + """ + Flow controller class to manage the instantiation and execution process. + """ + + def __init__(self, task_path: str, mode: str) -> None: + """ + Initialize the flow controller. + :param task_path: The path to the task file. + :param mode: The mode of the flow controller (instantiation, execution, or dataflow). + """ + + self.task_object = TaskObject(task_path, mode) + self.app_env = None + self.app_name = self.task_object.app_object.description.lower() + self.task_file_name = self.task_object.task_file_name + + self.schema = load_json_file(Config.get_instance().config_data["RESULT_SCHEMA"]) + + self.mode = mode + self.task_info = self.init_task_info() + self.result_hub = _configs["RESULT_HUB"].format(mode=mode) + + def init_task_info(self) -> dict: + """ + Initialize the task information. + :return: The initialized task information. + """ + init_task_info = None + if self.mode == "execution": + # read from the instantiated task file + init_task_info = load_json_file(self.task_object.task_file_path) + else: + init_task_info = { + "unique_id": self.task_object.unique_id, + "app": self.app_name, + "original": { + "original_task": self.task_object.task, + "original_steps": self.task_object.refined_steps, + }, + "execution_result": {"result": None, "error": None}, + "instantiation_result": { + "choose_template": {"result": None, "error": None}, + "prefill": {"result": None, "error": None}, + "instantiation_evaluation": {"result": None, "error": None}, + }, + "time_cost": {}, + } + return init_task_info + + + def execute_instantiation(self): + """ + Execute the instantiation process. + """ + + print_with_color(f"Instantiating task {self.task_object.task_file_name}...", "blue") + + template_copied_path = self.instantiation_single_flow( + ChooseTemplateFlow, "choose_template", + init_params=[self.task_object.app_object.file_extension], + execute_params=[] + ) + + if template_copied_path: + self.app_env.start(template_copied_path) + + prefill_result = self.instantiation_single_flow( + PrefillFlow, "prefill", + init_params=[self.app_env], + execute_params=[template_copied_path, self.task_object.task, self.task_object.refined_steps] + ) + + if prefill_result: + self.instantiation_single_flow( + FilterFlow, "instantiation_evaluation", + init_params=[], + execute_params=[prefill_result["instantiated_request"]] + ) + return prefill_result["instantiated_plan"] + + def execute_execution(self, request: str, plan: dict) -> None: + """ + Execute the execution process. + :param request: The task request to be executed. + :param plan: The execution plan containing detailed steps. + """ + + print_with_color("Executing the execution process...", "blue") + execute_flow = None + + try: + # Start the application and open the copied template + self.app_env.start(self.template_copied_path) + + # Initialize the execution context and flow + context = Context() + execute_flow = ExecuteFlow(self.task_file_name, context, self.app_env) + + # Execute the plan + executed_plan, execute_result = execute_flow.execute(request, plan) + + # Update the instantiated plan + self.instantiated_plan = executed_plan + # Record execution results and time metrics + self.task_info["execution_result"]["result"] = execute_result + self.task_info["time_cost"]["execute"] = execute_flow.execution_time + self.task_info["time_cost"]["execute_eval"] = execute_flow.eval_time + + except Exception as e: + # Handle and log any exceptions that occur during execution + error_traceback = traceback.format_exc() + self.task_info["execution_result"]["error"] = { + "type": str(type(e).__name__), + "message": str(e), + "traceback": error_traceback, + } + print_with_color(f"Error in Execution: {e}", "red") + finally: + # Record the total time cost of the execution process + if execute_flow and hasattr(execute_flow, "execution_time"): + self.task_info["time_cost"]["execute"] = execute_flow.execution_time + else: + self.task_info["time_cost"]["execute"] = None + if execute_flow and hasattr(execute_flow, "eval_time"): + self.task_info["time_cost"]["execute_eval"] = execute_flow.eval_time + else: + self.task_info["time_cost"]["execute_eval"] = None + + + def instantiation_single_flow( + self, + flow_class: AppAgentProcessor, + flow_type: str, + init_params=None, + execute_params=None + ) -> Any: + """ + Execute a single flow process in the instantiation phase. + :param flow_class: The flow class to instantiate. + :param flow_type: The type of the flow. + :param init_params: The initialization parameters for the flow. + :param execute_params: The execution parameters for the flow. + :return: The result of the flow process. + """ + + flow_instance = None + try: + flow_instance = flow_class(self.app_name, self.task_file_name, *init_params) + result = flow_instance.execute(*execute_params) + self.task_info["instantiation_result"][flow_type]["result"] = result + return result + except Exception as e: + self.task_info["instantiation_result"][flow_type]["error"] = { + "type": str(e.__class__), + "error_message": str(e), + "traceback": traceback.format_exc(), + } + print_with_color(f"Error in {flow_type}: {e} {traceback.format_exc()}") + finally: + if flow_instance and hasattr(flow_instance, "execution_time"): + self.task_info["time_cost"][flow_type] = flow_instance.execution_time + else: + self.task_info["time_cost"][flow_type] = None + + def save_result(self) -> None: + """ + Validate and save the instantiated task result. + """ + + validation_error = None + + # Validate the result against the schema + try: + validate(instance=self.task_info, schema=self.schema) + except ValidationError as e: + # Record the validation error but allow the process to continue + validation_error = str(e.message) + print_with_color(f"Warning: Schema Validation Warning: {validation_error}", "yellow") + + # Determine the target directory based on mode and quality/completeness + target_file = None + + if self.mode == "instantiation": + # Determine the quality of the instantiation + if not self.task_info["instantiation_result"]["instantiation_evaluation"]["result"]: + target_file = INSTANTIATION_RESULT_MAP[False] + else: + is_quality_good = self.task_info["instantiation_result"]["instantiation_evaluation"]["result"]["judge"] + target_file = INSTANTIATION_RESULT_MAP.get(is_quality_good, INSTANTIATION_RESULT_MAP[False]) + + else: + # Determine the completion status of the execution + if not self.task_info["execution_result"]["result"]: + target_file = EXECUTION_RESULT_MAP["fail"] + else: + is_completed = self.task_info["execution_result"]["result"]["complete"] + target_file = EXECUTION_RESULT_MAP.get(is_completed, EXECUTION_RESULT_MAP["fail"]) + + # Construct the full path to save the result + new_task_path = os.path.join(self.result_hub, target_file, self.task_object.task_file_base_name) + os.makedirs(os.path.dirname(new_task_path), exist_ok=True) + save_json_file(new_task_path, self.task_info) + + print(f"Task saved to {new_task_path}") + + # If validation failed, indicate that the saved result may need further inspection + if validation_error: + print("The saved task result does not conform to the expected schema and may require review.") + + @property + def template_copied_path(self) -> str: + """ + Get the copied template path from the task information. + :return: The copied template path. + """ + + return self.task_info["instantiation_result"]["choose_template"]["result"] + + @property + def instantiated_plan(self) -> list[dict[str, Any]]: + """ + Get the instantiated plan from the task information. + :return: The instantiated plan. + """ + + return self.task_info["instantiation_result"]["prefill"]["result"]["instantiated_plan"] + + @instantiated_plan.setter + def instantiated_plan(self, value: list[dict[str, Any]]) -> None: + """ + Set the instantiated plan in the task information. + :param value: New value for the instantiated plan. + """ + + self.task_info.setdefault("instantiation_result", {}).setdefault("prefill", {}).setdefault("result", {}) + self.task_info["instantiation_result"]["prefill"]["result"]["instantiated_plan"] = value + + def run(self) -> None: + """ + Run the instantiation and execution process. + """ + + start_time = time.time() + + try: + self.app_env = WindowsAppEnv(self.task_object.app_object) + + if self.mode == "dataflow": + plan = self.execute_instantiation() + self.execute_execution(self.task_object.task, plan) + elif self.mode == "instantiation": + self.execute_instantiation() + elif self.mode == "execution": + plan = self.instantiated_plan + self.execute_execution(self.task_object.task, plan) + else: + raise ValueError(f"Unsupported mode: {self.mode}") + except Exception as e: + raise e + + finally: + if self.app_env: + self.app_env.close() + # Update or record the total time cost of the process + total_time = round(time.time() - start_time, 3) + new_total_time = self.task_info.get("time_cost", {}).get("total", 0) + total_time + self.task_info["time_cost"]["total"] = round(new_total_time, 3) + + self.save_result() \ No newline at end of file diff --git a/instantiation/controller/env/env_manager.py b/instantiation/controller/env/env_manager.py index ec29ca6e..dec34b0c 100644 --- a/instantiation/controller/env/env_manager.py +++ b/instantiation/controller/env/env_manager.py @@ -1,14 +1,13 @@ import logging import re -import time +from typing import Optional +import psutil from fuzzywuzzy import fuzz from pywinauto import Desktop from pywinauto.controls.uiawrapper import UIAWrapper from instantiation.config.config import Config -from ufo.automator.puppeteer import ReceiverManager -from ufo.automator.ui_control.inspector import ControlInspectorFacade # Load configuration settings _configs = Config.get_instance().config_data @@ -28,20 +27,10 @@ def __init__(self, app_object: object) -> None: :param app_object: The app object containing information about the application. """ - super().__init__() self.app_window = None self.app_root_name = app_object.app_root_name self.app_name = app_object.description.lower() self.win_app = app_object.win_app - self._receive_factory = ReceiverManager._receiver_factory_registry["COM"][ - "factory" - ] - self.win_com_receiver = self._receive_factory.create_receiver( - self.app_root_name, self.app_name - ) - self._control_inspector = ControlInspectorFacade(_BACKEND) - - self._all_controls = None def start(self, copied_template_path: str) -> None: """ @@ -62,19 +51,54 @@ def start(self, copied_template_path: str) -> None: def close(self) -> None: """ - Closes the Windows environment. + Tries to gracefully close the application; if it fails or is not closed, forcefully terminates the process. """ try: - com_object = self.win_com_receiver.get_object_from_process_name() - com_object.Close() - self.win_com_receiver.client.Quit() - time.sleep(1) + # Attempt to close gracefully + if self.app_window: + self.app_window.close() + + # Check if process is still running + if self._is_window_open(): + logging.warning("Application is still running after graceful close. Attempting to forcefully terminate.") + self._force_kill() + else: + logging.info("Application closed gracefully.") except Exception as e: - logging.exception(f"Failed to close the application: {e}") - raise + logging.warning(f"Graceful close failed: {e}. Attempting to forcefully terminate the process.") + self._force_kill() + + def _is_window_open(self) -> bool: + """ + Checks if the specific application window is still open. + """ + + try: + # Ensure the app_window object is still valid and visible + if self.app_window.is_enabled(): + return True + return False + except Exception as e: + logging.error(f"Error while checking window status: {e}") + return False + + def _force_kill(self) -> None: + """ + Forcefully terminates the application process by its name. + """ - def find_matching_window(self, doc_name: str) -> object: + for proc in psutil.process_iter(['pid', 'name']): + if self.win_app.lower() in proc.info['name'].lower(): + try: + proc.kill() + logging.info(f"Process {self.win_app} (PID: {proc.info['pid']}) forcefully terminated.") + return + except Exception as kill_exception: + logging.error(f"Failed to kill process {proc.info['name']} (PID: {proc.info['pid']}): {kill_exception}") + logging.error(f"No matching process found for {self.win_app}.") + + def find_matching_window(self, doc_name: str) -> Optional[UIAWrapper]: """ Finds a matching window based on the process name and the configured matching strategy. :param doc_name: The document name associated with the application. @@ -86,8 +110,6 @@ def find_matching_window(self, doc_name: str) -> object: for window in windows_list: window_title = window.element_info.name.lower() if self._match_window_name(window_title, doc_name): - # Cache all controls for the window - self._all_controls = window.children() self.app_window = window self.app_window = window return window @@ -123,7 +145,7 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") - def is_matched_controller( + def is_matched_controller( self, control_to_match: UIAWrapper, control_text: str ) -> bool: """ diff --git a/instantiation/controller/instantiation_process.py b/instantiation/controller/instantiation_process.py deleted file mode 100644 index dec7e5cc..00000000 --- a/instantiation/controller/instantiation_process.py +++ /dev/null @@ -1,243 +0,0 @@ -import glob -import json -import os -import time -import traceback -from contextlib import contextmanager -from enum import Enum -from typing import Any, Dict - -from instantiation.config.config import Config -from ufo.module.context import Context - -# Set the environment variable for the run configuration. -os.environ["RUN_CONFIGS"] = "True" - -# Load configuration data. -_configs = Config.get_instance().config_data - - -@contextmanager -def stage_context(stage_name): - try: - yield stage_name - except Exception as e: - raise e - - -class AppEnum(Enum): - """ - Define the apps that can be used in the instantiation. - """ - - WORD = 1, "Word", ".docx", "winword" - EXCEL = 2, "Excel", ".xlsx", "excel" - POWERPOINT = 3, "PowerPoint", ".pptx", "powerpnt" - - def __init__(self, id: int, description: str, file_extension: str, win_app: str): - """ - :param id: The unique id of the app. - :param description: The description of the app. - :param file_extension: The file extension of the app. - :param win_app: The windows app name of the app. - """ - - self.id = id - self.description = description - self.file_extension = file_extension - self.win_app = win_app - self.app_root_name = win_app.upper() + ".EXE" - - -class TaskObject: - """ - The task object from the json file. - """ - - def __init__(self, task_dir_name: str, task_file: str) -> None: - """ - Initialize the task object from the json file. - :param task_dir_name: The name of the directory containing the task. - :param task_file: The task file to load from. - """ - - self.task_dir_name = task_dir_name - self.task_file = task_file - self.task_file_base_name = os.path.basename(task_file) - self.task_file_name = self.task_file_base_name.split(".")[0] - - with open(task_file, "r") as f: - task_json_file = json.load(f) - self.app_object = self._choose_app_from_json(task_json_file) - - for key, value in task_json_file.items(): - setattr(self, key.lower().replace(" ", "_"), value) - - def _choose_app_from_json(self, task_json_file: dict) -> AppEnum: - """ - Generate an app object by traversing AppEnum based on the app specified in the JSON. - :param task_json_file: The JSON file of the task. - :return: The app object. - """ - - for app in AppEnum: - if app.description.lower() == task_json_file["app"].lower(): - return app - raise ValueError("Not a correct App") - - -class InstantiationProcess: - """ - Key process to instantiate the task. - Control the overall process. - """ - - def instantiate_files(self, task_dir_name: str) -> None: - """ - Instantiate all the task files. - :param task_dir_name: The name of the task directory. - """ - - all_task_file_path: str = os.path.join( - _configs["TASKS_HUB"], task_dir_name, "*" - ) - all_task_files = glob.glob(all_task_file_path) - for index, task_file in enumerate(all_task_files, start=1): - print(f"Task starts: {index} / {len(all_task_files)}") - task_object = TaskObject(task_dir_name, task_file) - self.instantiate_single_file(task_object) - - print("All tasks have been processed.") - - def instantiate_single_file(self, task_object: TaskObject) -> None: - """ - Execute the process for one task. - :param task_object: The TaskObject containing task details. - """ - - from instantiation.controller.env.env_manager import WindowsAppEnv - from instantiation.controller.workflow.choose_template_flow import \ - ChooseTemplateFlow - from instantiation.controller.workflow.execute_flow import ExecuteFlow - from instantiation.controller.workflow.filter_flow import FilterFlow - from instantiation.controller.workflow.prefill_flow import PrefillFlow - - # Initialize the app environment and the task file name. - app_object = task_object.app_object - app_name = app_object.description.lower() - app_env = WindowsAppEnv(app_object) - task_file_name = task_object.task_file_name - - stage = None # To store which stage the error occurred at - is_quality_good = False - is_completed = "" - instantiated_task_info = { - "unique_id": task_object.unique_id, - "original_task": task_object.task, - "original_steps": task_object.refined_steps, - "instantiated_request": None, - "instantiated_plan": None, - "result": {}, - "time_cost": {}, - } # Initialize with a basic structure to avoid "used before assignment" error - - try: - start_time = time.time() - - # Initialize the template flow and execute it to copy the template - with stage_context("choose_template") as stage: - choose_template_flow = ChooseTemplateFlow( - app_name, app_object.file_extension, task_file_name - ) - template_copied_path = choose_template_flow.execute() - instantiated_task_info["time_cost"]["choose_template"] = choose_template_flow.execution_time - - # Initialize the prefill flow and execute it with the copied template and task details - with stage_context("prefill") as stage: - prefill_flow = PrefillFlow(app_env, task_file_name) - instantiated_request, instantiated_plan = prefill_flow.execute( - template_copied_path, task_object.task, task_object.refined_steps - ) - instantiated_task_info["instantiated_request"] = instantiated_request - instantiated_task_info["instantiated_plan"] = instantiated_plan - instantiated_task_info["time_cost"]["prefill"] = prefill_flow.execution_time - - # Initialize the filter flow to evaluate the instantiated request - with stage_context("filter") as stage: - filter_flow = FilterFlow(app_name, task_file_name) - is_quality_good, filter_result, request_type = filter_flow.execute( - instantiated_request - ) - instantiated_task_info["result"]["filter"] = filter_result - instantiated_task_info["time_cost"]["filter"] = filter_flow.execution_time - - # Initialize the execute flow and execute it with the instantiated plan - with stage_context("execute") as stage: - context = Context() - execute_flow = ExecuteFlow(app_env, task_file_name, context) - execute_result, _ = execute_flow.execute( - task_object.task, instantiated_plan - ) - is_completed = execute_result["complete"] - instantiated_task_info["result"]["execute"] = execute_result - instantiated_task_info["time_cost"]["execute"] = execute_flow.execution_time - - # Calculate total execution time for the process - instantiation_time = round(time.time() - start_time, 3) - instantiated_task_info["time_cost"]["total"] = instantiation_time - - except Exception as e: - instantiated_task_info["error"] = { - "stage": stage, - "type": str(e.__class__), - "error_message": str(e), - "traceback": traceback.format_exc(), - } - - finally: - app_env.close() - self._save_instantiated_task( - instantiated_task_info, - task_object.task_file_base_name, - is_quality_good, - is_completed, - ) - - def _save_instantiated_task( - self, - instantiated_task_info: Dict[str, Any], - task_file_base_name: str, - is_quality_good: bool, - is_completed: str, - ) -> None: - """ - Save the instantiated task information to a JSON file. - :param instantiated_task_info: A dictionary containing instantiated task details. - :param task_file_base_name: The base name of the task file. - :param is_quality_good: Indicates whether the quality of the task is good. - :param is_completed: Indicates whether the task is completed. - """ - - # Convert the dictionary to a JSON string - task_json = json.dumps(instantiated_task_info, ensure_ascii=False, indent=4) - - # Define folder paths for passing and failing instances - instance_folder = os.path.join(_configs["TASKS_HUB"], "instantiated_results") - pass_folder = os.path.join(instance_folder, "instances_pass") - fail_folder = os.path.join(instance_folder, "instances_fail") - unsure_folder = os.path.join(instance_folder, "instances_unsure") - - if is_completed == "unsure": - target_folder = unsure_folder - elif is_completed == "yes" and is_quality_good: - target_folder = pass_folder - else: - target_folder = fail_folder - - new_task_path = os.path.join(target_folder, task_file_base_name) - os.makedirs(os.path.dirname(new_task_path), exist_ok=True) - - with open(new_task_path, "w", encoding="utf-8") as f: - f.write(task_json) - - print(f"Task saved to {new_task_path}") diff --git a/instantiation/controller/prompter/agent_prompter.py b/instantiation/controller/prompter/agent_prompter.py index ab4e57d8..e24e228b 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/instantiation/controller/prompter/agent_prompter.py @@ -102,6 +102,7 @@ def user_prompt_construction(self, request: str) -> str: :param request: The user request. :return: The prompt for the user. """ + prompt = self.prompt_template["user"].format(request=request) return prompt @@ -370,6 +371,7 @@ def __init__( def load_logs(log_path: str) -> List[Dict[str, str]]: """ Load logs from the log path. + :param log_path: The path of the log. """ log_file_path = os.path.join(log_path, "execute_log.json") diff --git a/instantiation/controller/prompts/visual/filter.yaml b/instantiation/controller/prompts/visual/filter.yaml index 7d25195f..fd4cfd3a 100644 --- a/instantiation/controller/prompts/visual/filter.yaml +++ b/instantiation/controller/prompts/visual/filter.yaml @@ -14,8 +14,8 @@ system: |- ## Response Format Your response should be strictly structured in a JSON format, consisting of three distinct parts with the following keys and corresponding content: {{ - "judge": true or false depends on you think this task whether can be performed. - "thought": "Outline the reason why you give the judgement." + "judge": true or false depends on you think this task whether can be performed, + "thought": "Outline the reason why you give the judgement.", "type": "None/Non_task/App_involve/Env/Others" }} Make sure you answer must be strictly in JSON format only, without other redundant text such as json header. Otherwise it will crash the system. diff --git a/instantiation/controller/prompts/visual/prefill.yaml b/instantiation/controller/prompts/visual/prefill.yaml index dd003161..3f310eaa 100644 --- a/instantiation/controller/prompts/visual/prefill.yaml +++ b/instantiation/controller/prompts/visual/prefill.yaml @@ -49,17 +49,18 @@ system: |- ## Response Format - You are required to response in a JSON format, consisting of several distinct parts with the following keys and corresponding content: {{ - "observation": , - "thought": , - "new_task":, - "actions_plan":, + "Thought": , + "New_task":, + "Actions_plan": }} ### Action Call Format - The action call format is the same as the available actions in the API list.You are required to provide the action call format in a JSON format: {{ - "Step ": + "Step": + "Subtask": "ControlLabel": . If you believe none of the control item is suitable for the task or the task is complete, kindly output a empty string ''.> "ControlText": .The control text must match exactly with the selected control label. If the function to call don't need specify controlText or the task is complete,you can kindly output an empty string ''. @@ -70,7 +71,8 @@ system: |- e.g. {{ - "Step 1": "change the borders", + "Step": 1 + "Subtask": "change the borders", "ControlLabel": "", "ControlText": "Borders", "Function": "click_input", @@ -81,7 +83,8 @@ system: |- }} {{ - "Step 2": "change the borders", + "Step": 2, + "Subtask": "change the borders", "ControlLabel": "101", "ControlText": "Borders", "Function": "click_input", @@ -93,7 +96,8 @@ system: |- }} {{ - "Step 3": "select the target text", + "Step": 3, + "Subtask": "select the target text", "ControlLabel": "", "ControlText": "", "Function": "select_text", @@ -102,12 +106,12 @@ system: |- }} }} - - The field must be strictly in a format separated each action call by "\n". The list format should be like this: + - The field must be strictly in a format separated each action call by "\n". The list format should be like this: "action call 1\naction call 2\naction call 3" - - If you think the original task don't need to be detailed, you can directly copy the original task to the "new_task". + - If you think the original task don't need to be detailed, you can directly copy the original task to the "New_task". - You should review the apis function carefully and if the function to call need to specify target control,the 'controlText' field cannot be set empty. - - The "step" description should be consistent with the action and also the thought. + - The "Subtask" description should be consistent with the action and also the thought. ## Here are some examples for you to complete the user request: {examples} diff --git a/instantiation/controller/workflow/choose_template_flow.py b/instantiation/controller/workflow/choose_template_flow.py index dc55a4a0..41ba136c 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/instantiation/controller/workflow/choose_template_flow.py @@ -24,7 +24,7 @@ class ChooseTemplateFlow: _SENTENCE_TRANSFORMERS_PREFIX = "sentence-transformers/" - def __init__(self, app_name: str, file_extension: str, task_file_name: str): + def __init__(self, app_name: str, task_file_name: str, file_extension: str): """ Initialize the flow with the given task context. :param app_name: The name of the application. @@ -35,7 +35,7 @@ def __init__(self, app_name: str, file_extension: str, task_file_name: str): self._app_name = app_name self._file_extension = file_extension self._task_file_name = task_file_name - self.execution_time = 0 + self.execution_time = None self._embedding_model = self._load_embedding_model( model_name=_configs["CONTROL_FILTER_MODEL_SEMANTIC_NAME"] ) @@ -47,8 +47,12 @@ def execute(self) -> str: """ start_time = time.time() - template_copied_path = self._choose_template_and_copy() - self.execution_time = round(time.time() - start_time, 3) + try: + template_copied_path = self._choose_template_and_copy() + except Exception as e: + raise e + finally: + self.execution_time = round(time.time() - start_time, 3) return template_copied_path def _create_copied_file( @@ -181,4 +185,4 @@ def _load_embedding_model(model_name: str) -> CacheBackedEmbeddings: embedding_model = HuggingFaceEmbeddings(model_name=model_name) return CacheBackedEmbeddings.from_bytes_store( embedding_model, store, namespace=model_name - ) + ) \ No newline at end of file diff --git a/instantiation/controller/workflow/execute_flow.py b/instantiation/controller/workflow/execute_flow.py index 9cd1cff4..01499484 100644 --- a/instantiation/controller/workflow/execute_flow.py +++ b/instantiation/controller/workflow/execute_flow.py @@ -25,18 +25,19 @@ class ExecuteFlow(AppAgentProcessor): _app_eval_agent_dict: Dict[str, ExecuteEvalAgent] = {} def __init__( - self, environment: WindowsAppEnv, task_file_name: str, context: Context + self, task_file_name: str, context: Context, environment: WindowsAppEnv ) -> None: """ Initialize the execute flow for a task. - :param environment: Environment object for the application being processed. :param task_file_name: Name of the task file being processed. :param context: Context object for the current session. + :param environment: Environment object for the application being processed. """ super().__init__(agent=ExecuteAgent, context=context) - self.execution_time = 0 + self.execution_time = None + self.eval_time = None self._app_env = environment self._task_file_name = task_file_name self._app_name = self._app_env.app_name @@ -48,6 +49,8 @@ def __init__( self.app_agent = self._get_or_create_execute_agent() self.eval_agent = self._get_or_create_evaluation_agent() + self._matched_control = None # Matched control for the current step. + def _get_or_create_execute_agent(self) -> ExecuteAgent: """ Retrieve or create a execute agent for the given application. @@ -82,6 +85,7 @@ def _get_or_create_evaluation_agent(self) -> ExecuteEvalAgent: def _initialize_logs(self, log_path: str) -> None: """ Initialize logging for execute messages and responses. + :param log_path: Path to save the logs. """ os.makedirs(log_path, exist_ok=True) @@ -92,41 +96,60 @@ def _initialize_logs(self, log_path: str) -> None: self.context.set(ContextNames.LOGGER, self._execute_message_logger) def execute( - self, request: str, instantiated_plan: List[str] - ) -> Tuple[Dict[str, str], float]: + self, request: str, instantiated_plan: List[Dict[str, Any]] + ) -> Tuple[List[Dict[str, Any]], Dict[str, str]]: """ Execute the execute flow: Execute the task and save the result. :param request: Original request to be executed. :param instantiated_plan: Instantiated plan containing steps to execute. :return: Tuple containing task quality flag, comment, and task type. """ + + start_time = time.time() + try: + executed_plan = self.execute_plan(instantiated_plan) + except Exception as error: + raise RuntimeError(f"Execution failed. {error}") + finally: + self.execution_time = round(time.time() - start_time, 3) start_time = time.time() - execute_result, execute_cost = self._get_executed_result( - request, instantiated_plan - ) - self.execution_time = round(time.time() - start_time, 3) - return execute_result, execute_cost + try: + result, _ = self.eval_agent.evaluate(request=request, log_path=self.log_path) + utils.print_with_color(f"Result: {result}", "green") + except Exception as error: + raise RuntimeError(f"Evaluation failed. {error}") + finally: + self.eval_time = round(time.time() - start_time, 3) + + return executed_plan, result - def _get_executed_result( - self, request, instantiated_plan - ) -> Tuple[Dict[str, str], float]: + def execute_plan( + self, instantiated_plan: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """ Get the executed result from the execute agent. - :param request: Original request to be executed. :param instantiated_plan: Plan containing steps to execute. - :return: Tuple containing task quality flag, request comment, and request type. + :return: List of executed steps. """ - utils.print_with_color("Starting execution of instantiated plan...", "yellow") # Initialize the step counter and capture the initial screenshot. self.session_step = 0 - self.capture_screenshot() - # Initialize the API receiver - self.app_agent.Puppeteer.receiver_manager.create_api_receiver( - self.app_agent._app_root_name, self.app_agent._process_name - ) - for _, step_plan in enumerate(instantiated_plan): + try: + # Initialize the API receiver + self.app_agent.Puppeteer.receiver_manager.create_api_receiver( + self.app_agent._app_root_name, self.app_agent._process_name + ) + self.init_capture_screenshot() + except Exception as error: + raise RuntimeError(f"Execution initialization failed. {error}") + + # Initialize the success flag for each step. + for index, step_plan in enumerate(instantiated_plan): + instantiated_plan[index]["Success"] = None + instantiated_plan[index]["MatchedControlText"] = None + + for index, step_plan in enumerate(instantiated_plan): try: self.session_step += 1 @@ -138,24 +161,23 @@ def _get_executed_result( try: self.process() - except Exception as ControllerNotFound: - raise ControllerNotFound + instantiated_plan[index]["Success"] = True + instantiated_plan[index]["ControlLabel"] = self._control_label + instantiated_plan[index]["MatchedControlText"] = self._matched_control + + except Exception as ControllerNotFoundError: + instantiated_plan[index]["Success"] = False + raise ControllerNotFoundError except Exception as error: err_info = RuntimeError( f"Step {self.session_step} execution failed. {error}" ) - utils.print_with_color(f"{err_info}", "red") raise err_info print("Execution complete.") - utils.print_with_color("Evaluating the session...", "yellow") - result, cost = self.eval_agent.evaluate(request=request, log_path=self.log_path) - - print(result) - - return result, cost + return instantiated_plan def process(self) -> None: """ @@ -176,9 +198,9 @@ def print_step_info(self) -> None: """ utils.print_with_color( - "Step {step}: {step_plan}".format( + "Step {step}: {subtask}".format( step=self.session_step, - step_plan=self.plan, + subtask=self.subtask, ), "magenta", ) @@ -195,7 +217,6 @@ def log_save(self) -> None: "ControlText": self.control_text, "Action": self.action, "ActionType": self.app_agent.Puppeteer.get_command_types(self._operation), - "Plan": self.plan, "Results": self._results, "Application": self.app_agent._app_root_name, "TimeCost": self.time_cost, @@ -206,17 +227,16 @@ def log_save(self) -> None: def _parse_step_plan(self, step_plan: Dict[str, Any]) -> None: """ Parse the response. + :param step_plan: The step plan. """ + self._matched_control = None + self.subtask = step_plan.get("Subtask", "") self.control_text = step_plan.get("ControlText", "") self._operation = step_plan.get("Function", "") self.question_list = step_plan.get("Questions", []) self._args = utils.revise_line_breaks(step_plan.get("Args", "")) - # Convert the plan from a string to a list if the plan is a string. - step_plan_key = "Step " + str(self.session_step) - self.plan = step_plan.get(step_plan_key, "") - # Compose the function call and the arguments string. self.action = self.app_agent.Puppeteer.get_command_string( self._operation, self._args @@ -230,110 +250,40 @@ def select_controller(self) -> None: """ if self.control_text == "": - return + return for key, control in self.filtered_annotation_dict.items(): if self._app_env.is_matched_controller(control, self.control_text): self._control_label = key + self._matched_control = control.window_text() return + # If the control is not found, raise an error. raise RuntimeError(f"Control with text '{self.control_text}' not found.") - def capture_screenshot(self) -> None: + def init_capture_screenshot(self) -> None: """ Capture the screenshot. """ # Define the paths for the screenshots saved. screenshot_save_path = self.log_path + f"action_step{self.session_step}.png" - annotated_screenshot_save_path = ( - self.log_path + f"action_step{self.session_step}_annotated.png" - ) - concat_screenshot_save_path = ( - self.log_path + f"action_step{self.session_step}_concat.png" - ) self._memory_data.set_values_from_dict( { "CleanScreenshot": screenshot_save_path, - "AnnotatedScreenshot": annotated_screenshot_save_path, - "ConcatScreenshot": concat_screenshot_save_path, } ) - # Get the control elements in the application window if the control items are not provided for reannotation. - if type(self.control_reannotate) == list and len(self.control_reannotate) > 0: - control_list = self.control_reannotate - else: - control_list = self.control_inspector.find_control_elements_in_descendants( - self.application_window, - control_type_list=_ufo_configs["CONTROL_LIST"], - class_name_list=_ufo_configs["CONTROL_LIST"], - ) - - # Get the annotation dictionary for the control items, in a format of {control_label: control_element}. - self._annotation_dict = self.photographer.get_annotation_dict( - self.application_window, control_list, annotation_type="number" - ) - - # Attempt to filter out irrelevant control items based on the previous plan. - self.filtered_annotation_dict = self.get_filtered_annotation_dict( - self._annotation_dict - ) self.photographer.capture_app_window_screenshot( self.application_window, save_path=screenshot_save_path ) + # Capture the control screenshot. + control_selected = self._app_env.app_window + self.capture_control_screenshot(control_selected) - # Capture the screenshot of the selected control items with annotation and save it. - self.photographer.capture_app_window_screenshot_with_annotation_dict( - self.application_window, - self.filtered_annotation_dict, - annotation_type="number", - save_path=annotated_screenshot_save_path, - ) - - # If the configuration is set to include the last screenshot with selected controls tagged, save the last screenshot. - if _ufo_configs["INCLUDE_LAST_SCREENSHOT"] and self.session_step >= 1: - last_screenshot_save_path = ( - self.log_path + f"action_step{self.session_step - 1}.png" - ) - last_control_screenshot_save_path = ( - self.log_path - + f"action_step{self.session_step - 1}_selected_controls.png" - ) - self._image_url += [ - self.photographer.encode_image_from_path( - last_control_screenshot_save_path - if os.path.exists(last_control_screenshot_save_path) - else last_screenshot_save_path - ) - ] - - # Whether to concatenate the screenshots of clean screenshot and annotated screenshot into one image. - if _ufo_configs["CONCAT_SCREENSHOT"]: - self.photographer.concat_screenshots( - screenshot_save_path, - annotated_screenshot_save_path, - concat_screenshot_save_path, - ) - self._image_url += [ - self.photographer.encode_image_from_path(concat_screenshot_save_path) - ] - else: - screenshot_url = self.photographer.encode_image_from_path( - screenshot_save_path - ) - screenshot_annotated_url = self.photographer.encode_image_from_path( - annotated_screenshot_save_path - ) - self._image_url += [screenshot_url, screenshot_annotated_url] - - # Save the XML file for the current state. - if _ufo_configs["LOG_XML"]: - - self._save_to_xml() def general_error_handler(self) -> None: """ Handle general errors. """ - + pass diff --git a/instantiation/controller/workflow/filter_flow.py b/instantiation/controller/workflow/filter_flow.py index f9d9fbd1..1499df73 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/instantiation/controller/workflow/filter_flow.py @@ -2,7 +2,7 @@ import logging import os import time -from typing import Dict, Tuple +from typing import Dict, Tuple, Any from instantiation.config.config import Config from instantiation.controller.agent.agent import FilterAgent @@ -21,11 +21,11 @@ class FilterFlow: def __init__(self, app_name: str, task_file_name: str) -> None: """ Initialize the filter flow for a task. - :param app_object: Application object containing task details. + :param app_name: Name of the application being processed. :param task_file_name: Name of the task file being processed. """ - self.execution_time = 0 + self.execution_time = None self._app_name = app_name self._log_path_configs = _configs["FILTER_LOG_PATH"].format(task=task_file_name) self._filter_agent = self._get_or_create_filter_agent() @@ -48,7 +48,7 @@ def _get_or_create_filter_agent(self) -> FilterAgent: ) return FilterFlow._app_filter_agent_dict[self._app_name] - def execute(self, instantiated_request: str) -> Tuple[bool, str, str]: + def execute(self, instantiated_request: str) -> Dict[str, Any]: """ Execute the filter flow: Filter the task and save the result. :param instantiated_request: Request object to be filtered. @@ -56,12 +56,20 @@ def execute(self, instantiated_request: str) -> Tuple[bool, str, str]: """ start_time = time.time() - is_quality_good, filter_result, request_type = self._get_filtered_result( - instantiated_request - ) - self.execution_time = round(time.time() - start_time, 3) - return is_quality_good, filter_result, request_type - + try: + judge, thought, request_type = self._get_filtered_result( + instantiated_request + ) + except Exception as e: + raise e + finally: + self.execution_time = round(time.time() - start_time, 3) + return { + "judge": judge, + "thought": thought, + "request_type": request_type, + } + def _initialize_logs(self) -> None: """ Initialize logging for filter messages and responses. @@ -117,7 +125,6 @@ def _get_filtered_result(self, instantiated_request: str) -> Tuple[bool, str, st response_json["thought"], response_json["type"], ) - except Exception as e: logging.error(f"Error occurred while filtering: {e}") raise e @@ -126,6 +133,8 @@ def _fix_json_commas(self, json_string: str) -> str: """ Function to add missing commas between key-value pairs in a JSON string and remove newline characters for proper formatting. + :param json_string: The JSON string to be fixed. + :return: The fixed JSON string. """ # Remove newline characters diff --git a/instantiation/controller/workflow/prefill_flow.py b/instantiation/controller/workflow/prefill_flow.py index 4aa02cfc..6a277023 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/instantiation/controller/workflow/prefill_flow.py @@ -27,19 +27,21 @@ class PrefillFlow(AppAgentProcessor): def __init__( self, - environment: WindowsAppEnv, + app_name: str, task_file_name: str, + environment: WindowsAppEnv, ) -> None: """ Initialize the prefill flow with the application context. - :param environment: The environment of the app. + :param app_name: The name of the application. :param task_file_name: The name of the task file for logging and tracking. + :param environment: The environment of the app. """ - self.execution_time = 0 - self._app_env = environment + self.execution_time = None + self._app_name = app_name self._task_file_name = task_file_name - self._app_name = self._app_env.app_name + self._app_env = environment # Create or reuse a PrefillAgent for the app if self._app_name not in PrefillFlow._app_prefill_agent_dict: PrefillFlow._app_prefill_agent_dict[self._app_name] = PrefillAgent( @@ -76,7 +78,7 @@ def __init__( def execute( self, template_copied_path: str, original_task: str, refined_steps: List[str] - ) -> Tuple[str, List[str]]: + ) -> Dict[str, Any]: """ Start the execution by retrieving the instantiated result. :param template_copied_path: The path of the copied template to use. @@ -86,11 +88,19 @@ def execute( """ start_time = time.time() - instantiated_request, instantiated_plan = self._instantiate_task( - template_copied_path, original_task, refined_steps - ) - self.execution_time = round(time.time() - start_time, 3) - return instantiated_request, instantiated_plan + try: + instantiated_request, instantiated_plan = self._instantiate_task( + template_copied_path, original_task, refined_steps + ) + except Exception as e: + raise e + finally: + self.execution_time = round(time.time() - start_time, 3) + + return { + "instantiated_request": instantiated_request, + "instantiated_plan": instantiated_plan, + } def _instantiate_task( self, template_copied_path: str, original_task: str, refined_steps: List[str] @@ -104,8 +114,6 @@ def _instantiate_task( :return: The refined task and corresponding action plans. """ - self._app_env.start(template_copied_path) - try: # Retrieve prefill actions and task plan instantiated_request, instantiated_plan = self._get_prefill_actions( @@ -122,7 +130,7 @@ def _instantiate_task( raise e return instantiated_request, instantiated_plan - + def _update_state(self, file_path: str) -> None: """ Update the current state of the app by inspecting UI elements. @@ -202,8 +210,8 @@ def _get_prefill_actions( # Parse and log the response response_json = self._prefill_agent.response_to_dict(response_string) - instantiated_request = response_json["new_task"] - instantiated_plan = response_json["actions_plan"] + instantiated_request = response_json["New_task"] + instantiated_plan = response_json["Actions_plan"] except Exception as e: self._status = "ERROR" diff --git a/instantiation/dataflow.py b/instantiation/dataflow.py new file mode 100644 index 00000000..0ba1d835 --- /dev/null +++ b/instantiation/dataflow.py @@ -0,0 +1,99 @@ +import argparse +import os +import traceback + +from ufo.utils import print_with_color + + +def parse_args() -> argparse.Namespace: + """ + Parse command-line arguments. + """ + + parser = argparse.ArgumentParser(description="Run task with different execution modes.") + + # Make "mode" optional, with a default value + parser.add_argument( + "--mode", + default="dataflow", + choices=["dataflow", "instantiation", "execution"], + help="Execution mode." + ) + + # Use `--task_path` as an optional argument with a default value + parser.add_argument( + "--task_path", + default="instantiation/tasks/prefill", + help="Path to the task file or directory." + ) + + # Optional flag for batch mode + parser.add_argument( + "--batch", + action="store_true", + help="Run tasks in batch mode (process all files in directory)." + ) + + return parser.parse_args() + + +def process_single_task(task_path: str, mode: str) -> None: + """ + Single task processing. + :param task_path: The path to the task file. + :param mode: The execution mode. + """ + + from instantiation.controller.data_flow_controller import DataFlowController, TaskObject + + try: + flow_controller = DataFlowController(task_path, mode) + flow_controller.run() + except Exception as e: + # Catch exceptions and continue to the next task + print_with_color(f"Error processing {task_path}: {e}", "red") + traceback.print_exc() + raise e + +def process_batch_tasks(task_dir: str, mode: str) -> None: + """ + Batch tasks processing. + :param task_dir: The path to the task directory. + :param mode: The execution mode + """ + + # Get all task files in the directory + task_files = [os.path.join(task_dir, f) for f in os.listdir(task_dir) if os.path.isfile(os.path.join(task_dir, f))] + + for task_file in task_files: + try: + print_with_color(f"Processing {task_file}...", "blue") + + # Process each individual task + process_single_task(task_file, mode) + except Exception: + continue + +def main(): + """ + The main function to run the task. + You can use dataflow, instantiation, and execution modes to process the task. + Also, you can run tasks in batch mode by providing the path to the task directory. + See README to read the detailed usage. + """ + + args = parse_args() + + if args.batch: + if os.path.isdir(args.task_path): + process_batch_tasks(args.task_path, args.mode) + else: + print(f"{args.task_path} is not a valid directory for batch processing.") + else: + if os.path.isfile(args.task_path): + process_single_task(args.task_path, args.mode) + else: + print(f"{args.task_path} is not a valid file.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/instantiation/instantiation.py b/instantiation/instantiation.py deleted file mode 100644 index 2e899690..00000000 --- a/instantiation/instantiation.py +++ /dev/null @@ -1,39 +0,0 @@ -import argparse -import os -import sys - - -def add_project_root_to_sys_path() -> None: - """Add project root to system path if not already present.""" - current_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.abspath(os.path.join(current_dir, "..")) - if project_root not in sys.path: - sys.path.append(project_root) - - -def parse_arguments() -> argparse.Namespace: - """Parse command-line arguments. - :return: Parsed command-line arguments. - """ - parser = argparse.ArgumentParser() - parser.add_argument( - "--task", help="The name of the task.", type=str, default="prefill" - ) - return parser.parse_args() - - -def main() -> None: - """Main entry point of the script.""" - # Add the project root to the system path. - add_project_root_to_sys_path() - - task_dir_name = parse_arguments().task.lower() - - from instantiation.controller.instantiation_process import \ - InstantiationProcess - - InstantiationProcess().instantiate_files(task_dir_name) - - -if __name__ == "__main__": - main() From 25fb37e9a39f147cf8ec646fc8df8de8253ff449 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Thu, 5 Dec 2024 04:35:39 +0800 Subject: [PATCH 24/30] 1. Fix bugs. 2. Split the code into instantiation process and execution process. --- {instantiation => dataflow}/.gitignore | 0 dataflow/README.md | 303 ++++++++++++++++++ {instantiation => dataflow}/__main__.py | 2 +- {instantiation => dataflow}/config/config.py | 2 +- .../config/config_dev.yaml | 27 +- .../data_flow_controller.py | 83 ++--- dataflow/dataflow.py | 137 ++++++++ .../env/env_manager.py | 45 +-- dataflow/execution/agent/execute_agent.py | 33 ++ .../execution/agent/execute_eval_agent.py | 66 ++++ .../execution}/workflow/execute_flow.py | 13 +- dataflow/instantiation/agent/filter_agent.py | 86 +++++ dataflow/instantiation/agent/prefill_agent.py | 94 ++++++ .../workflow/choose_template_flow.py | 6 +- .../instantiation}/workflow/filter_flow.py | 4 +- .../instantiation}/workflow/prefill_flow.py | 6 +- .../execution/execute_eval_prompter.py | 52 +++ .../prompter/instantiation/filter_prompter.py | 162 ++++++++++ .../instantiation/prefill_prompter.py | 46 +-- .../prompts/instantiation}/visual/filter.yaml | 0 .../instantiation}/visual/prefill.yaml | 0 .../visual/prefill_example.yaml | 0 dataflow/schema/execution_schema.json | 120 +++++++ dataflow/schema/instantiation_schema.json | 101 ++++++ instantiation/README.md | 227 ------------- instantiation/config/config.yaml.template | 43 --- instantiation/controller/agent/agent.py | 263 --------------- .../controller/prompts/visual/api.yaml | 66 ---- .../controller/prompts/visual/execute.yaml | 26 -- instantiation/dataflow.py | 99 ------ instantiation/tasks/prefill/bulleted.json | 9 - instantiation/tasks/prefill/delete.json | 9 - instantiation/tasks/prefill/draw.json | 10 - instantiation/tasks/prefill/macro.json | 9 - instantiation/tasks/prefill/rotate.json | 10 - 35 files changed, 1250 insertions(+), 909 deletions(-) rename {instantiation => dataflow}/.gitignore (100%) create mode 100644 dataflow/README.md rename {instantiation => dataflow}/__main__.py (81%) rename {instantiation => dataflow}/config/config.py (94%) rename {instantiation => dataflow}/config/config_dev.yaml (51%) rename {instantiation/controller => dataflow}/data_flow_controller.py (83%) create mode 100644 dataflow/dataflow.py rename {instantiation/controller => dataflow}/env/env_manager.py (81%) create mode 100644 dataflow/execution/agent/execute_agent.py create mode 100644 dataflow/execution/agent/execute_eval_agent.py rename {instantiation/controller => dataflow/execution}/workflow/execute_flow.py (94%) create mode 100644 dataflow/instantiation/agent/filter_agent.py create mode 100644 dataflow/instantiation/agent/prefill_agent.py rename {instantiation/controller => dataflow/instantiation}/workflow/choose_template_flow.py (97%) rename {instantiation/controller => dataflow/instantiation}/workflow/filter_flow.py (97%) rename {instantiation/controller => dataflow/instantiation}/workflow/prefill_flow.py (98%) create mode 100644 dataflow/prompter/execution/execute_eval_prompter.py create mode 100644 dataflow/prompter/instantiation/filter_prompter.py rename instantiation/controller/prompter/agent_prompter.py => dataflow/prompter/instantiation/prefill_prompter.py (88%) rename {instantiation/controller/prompts => dataflow/prompts/instantiation}/visual/filter.yaml (100%) rename {instantiation/controller/prompts => dataflow/prompts/instantiation}/visual/prefill.yaml (100%) rename {instantiation/controller/prompts => dataflow/prompts/instantiation}/visual/prefill_example.yaml (100%) create mode 100644 dataflow/schema/execution_schema.json create mode 100644 dataflow/schema/instantiation_schema.json delete mode 100644 instantiation/README.md delete mode 100644 instantiation/config/config.yaml.template delete mode 100644 instantiation/controller/agent/agent.py delete mode 100644 instantiation/controller/prompts/visual/api.yaml delete mode 100644 instantiation/controller/prompts/visual/execute.yaml delete mode 100644 instantiation/dataflow.py delete mode 100644 instantiation/tasks/prefill/bulleted.json delete mode 100644 instantiation/tasks/prefill/delete.json delete mode 100644 instantiation/tasks/prefill/draw.json delete mode 100644 instantiation/tasks/prefill/macro.json delete mode 100644 instantiation/tasks/prefill/rotate.json diff --git a/instantiation/.gitignore b/dataflow/.gitignore similarity index 100% rename from instantiation/.gitignore rename to dataflow/.gitignore diff --git a/dataflow/README.md b/dataflow/README.md new file mode 100644 index 00000000..058863f3 --- /dev/null +++ b/dataflow/README.md @@ -0,0 +1,303 @@ +# Dataflow + +Dataflow uses UFO to implement `instantiation`, `execution`, and `dataflow` for a given task, with options for batch processing and single processing. + +1. **Instantiation**: Instantiation refers to the process of setting up and preparing a task for execution. This step typically involves `choosing template`, `prefill` and `filter`. +2. **Execution**: Execution is the actual process of running the task. This step involves carrying out the actions or operations specified by the `Instantiation`. And after execution, an evaluate agent will evaluate the quality of the whole execution process. +3. **Dataflow**: Dataflow is the overarching process that combines **instantiation** and **execution** into a single pipeline. It provides an end-to-end solution for processing tasks, ensuring that all necessary steps (from initialization to execution) are seamlessly integrated. + +You can use `instantiation` and `execution` independently if you only need to perform one specific part of the process. When both steps are required for a task, the `dataflow` process streamlines them, allowing you to execute tasks from start to finish in a single pipeline. + +## HOW TO USE + +### 1. Install Packages + +You should install the necessary packages in the UFO root folder: + +```bash +pip install -r requirements.txt +``` + +### 2. Configure the LLMs + +Before using the instantiation section, you need to provide your LLM configurations in `config.yaml` and `config_dev.yaml` located in the dataflow `/config ` folder. + +- `config_dev.yaml` specifies the paths of relevant files and contains default settings. The match strategy for the window match and control filter supports options: `'contains'`, `'fuzzy'`, and `'regex'`, allowing flexible matching strategy for users. +- `config.yaml` stores the agent information. You should copy the `config.yaml.template` file and fill it out according to the provided hints. + +You will configure the prefill agent and the filter agent individually. The prefill agent is used to prepare the task, while the filter agent evaluates the quality of the prefilled task. You can choose different LLMs for each. + +**BE CAREFUL!** If you are using GitHub or other open-source tools, do not expose your `config.yaml` online, as it contains your private keys. + +Once you have filled out the template, rename it to `config.yaml` to complete the LLM configuration. + +### 3. Prepare Files + +Certain files need to be prepared before running the task. + +#### 3.1. Tasks as JSON + +The tasks that need to be instantiated should be organized in a folder of JSON files, with the default folder path set to dataflow `/tasks `. This path can be changed in the `dataflow/config/config.yaml` file, or you can specify it in the terminal, as mentioned in **4. Start Running**. For example, a task stored in `dataflow/tasks/prefill/` may look like this: + +```json +{ + // The app you want to use + "app": "word", + // A unique ID to distinguish different tasks + "unique_id": "1", + // The task and steps to be instantiated + "task": "Type 'hello' and set the font type to Arial", + "refined_steps": [ + "Type 'hello'", + "Set the font to Arial" + ] +} +``` + +#### 3.2. Templates and Descriptions + +You should place an app file as a reference for instantiation in a folder named after the app. + +For example, if you have `template1.docx` for Word, it should be located at `dataflow/templates/word/template1.docx`. + +Additionally, for each app folder, there should be a `description.json` file located at `dataflow/templates/word/description.json`, which describes each template file in detail. It may look like this: + +```json +{ + "template1.docx": "A document with a rectangle shape", + "template2.docx": "A document with a line of text", + "template3.docx": "A document with a chart" +} +``` + +If a `description.json` file is not present, one template file will be selected at random. + +#### 3.3. Final Structure + +Ensure the following files are in place: + +- [X] JSON files to be instantiated +- [X] Templates as references for instantiation +- [X] Description file in JSON format + +The structure of the files can be: + +```bash +dataflow/ +| +├── tasks +│ └── prefill +│ ├── bulleted.json +│ ├── delete.json +│ ├── draw.json +│ ├── macro.json +│ └── rotate.json +├── templates +│ └── word +│ ├── description.json +│ ├── template1.docx +│ ├── template2.docx +│ ├── template3.docx +│ ├── template4.docx +│ ├── template5.docx +│ ├── template6.docx +│ └── template7.docx +└── ... +``` + +### 4. How To Use + +After finishing the previous steps, you can use the following commands in the command line. We provide single / batch process, for which you need to give the single file path / folder path. + +Also, you can choose to use `instantiation` / `execution` sections individually, or use them as a whole section, which is named as `dataflow`. + +1. **Single Task Processing** + +- Dataflow Task: + ```bash + python -m dataflow single dataflow /task_dir/task_file_name + ``` + +* Instantiation Task: + ```bash + python -m dataflow single instantiation /task_dir/task_file_name + ``` +* Execution Task: + ```bash + python -m dataflow single execution /task_dir/task_file_name + ``` + +2. **Batch Task Processing** + +* Dataflow Task Batch: + ```bash + python -m dataflow batch dataflow /path/to/task_dir/ + ``` +* Instantiation Task Batch: + ```bash + python -m dataflow batch instantiation /path/to/task_dir/ + ``` +* Execution Task Batch: + ```bash + python -m dataflow batch execution /path/to/task_dir/ + ``` + +## Workflow + +### Instantiation + +There are four key steps in the instantiation process: + +1. `Choose a template` file according to the specified app and instruction. +2. `Prefill` the task using the current screenshot. +3. `Filter` the established task. + +#### 1. Choose Template File + +Templates for your app must be defined and described in `dataflow/templates/app`. For instance, if you want to instantiate tasks for the Word application, place the relevant `.docx` files in dataflow `/templates/word `, along with a `description.json` file. + +The appropriate template will be selected based on how well its description matches the instruction. + +#### 2. Prefill the Task + +After selecting the template file, it will be opened, and a screenshot will be taken. If the template file is currently in use, errors may occur. + +The screenshot will be sent to the action prefill agent, which will return a modified task. + +#### 3. Filter Task + +The completed task will be evaluated by a filter agent, which will assess it and provide feedback. + +### Execution + +The instantiated plans will be executed by a execute task. After execution, evalution agent will evaluation the quality of the entire execution process. + +## Result + +The structure of the results of the task is as below: + +``` +UFO/ +├── dataflow/ # Root folder for dataflow +│ └── results/ # Directory for storing task processing results +│ ├── saved_document/ # Directory for final document results +│ ├── instantiation/ # Directory for instantiation results +│ │ ├── instantiation_pass/ # Tasks successfully instantiated +│ │ └── instantiation_fail/ # Tasks that failed instantiation +│ ├── execution/ # Directory for execution results +│ │ ├── execution_pass/ # Tasks successfully executed +│ │ ├── execution_fail/ # Tasks that failed execution +│ │ └── execution_unsure/ # Tasks with uncertain execution results +│ ├── dataflow/ # Directory for dataflow results +│ │ ├── execution_pass/ # Tasks successfully executed +│ │ ├── execution_fail/ # Tasks that failed execution +│ │ └── execution_unsure/ # Tasks with uncertain execution results +│ └── ... +└── ... +``` + +1. **General Description:** + + This directory structure organizes the results of task processing into specific categories, including instantiation, execution, and dataflow outcomes. +2. **Instantiation:** + + The `instantiation` directory contains subfolders for tasks that were successfully instantiated (`instantiation_pass`) and those that failed during instantiation (`instantiation_fail`). +3. **Execution:** + + Results of task execution are stored under the `execution` directory, categorized into successful tasks (`execution_pass`), failed tasks (`execution_fail`), and tasks with uncertain outcomes (`execution_unsure`). +4. **Dataflow Results:** + + The `dataflow` directory similarly holds results of tasks based on execution success, failure, or uncertainty, providing a comprehensive view of the data processing pipeline. +5. **Saved Documents:** + + Instantiated results are separately stored in the `saved_document` directory for easy access and reference. + +### Description + +his section illustrates the structure of the result of the task, organized in a hierarchical format to describe the various fields and their purposes. The result data include `unique_id`,``app``, `original`, `execution_result`, `instantiation_result`, `time_cost`. + +#### 1. **Field Descriptions** + +- **Hierarchy**: The data is presented in a hierarchical manner to allow for a clearer understanding of field relationships. +- **Type Description**: The type of each field (e.g., `string`, `array`, `object`) clearly specifies the format of the data. +- **Field Purpose**: Each field has a brief description outlining its function. + +#### 2. **Execution Results and Errors** + +- **execution_result**: Contains the results of task execution, including subtask performance, completion status, and any encountered errors. +- **instantiation_result**: Describes the process of task instantiation, including template selection, prefilled tasks, and instantiation evaluation. +- **error**: If an error occurs during task execution, this field will contain the relevant error information. + +#### 3. **Time Consumption** + +- **time_cost**: The time spent on each phase of the task, from template selection to task execution, is recorded to analyze task efficiency. + +### Example Data + +```json +{ + "unique_id": "102", + "app": "word", + "original": { + "original_task": "Find which Compatibility Mode you are in for Word", + "original_steps": [ + "1.Click the **File** tab.", + "2.Click **Info**.", + "3.Check the **Compatibility Mode** indicator at the bottom of the document preview pane." + ] + }, + "execution_result": { + "result": { + "reason": "The agent successfully identified the compatibility mode of the Word document.", + "sub_scores": { + "correct identification of compatibility mode": "yes" + }, + "complete": "yes" + }, + "error": null + }, + "instantiation_result": { + "choose_template": { + "result": "dataflow\\results\\saved_document\\102.docx", + "error": null + }, + "prefill": { + "result": { + "instantiated_request": "Identify the Compatibility Mode of the Word document.", + "instantiated_plan": [ + { + "Step": 1, + "Subtask": "Identify the Compatibility Mode", + "Function": "summary", + "Args": { + "text": "The document is in '102 - Compatibility Mode'." + }, + "Success": true + } + ] + }, + "error": null + }, + "instantiation_evaluation": { + "result": { + "judge": true, + "thought": "Identifying the Compatibility Mode of a Word document is a task that can be executed locally within Word." + }, + "error": null + } + }, + "time_cost": { + "choose_template": 0.017, + "prefill": 11.304, + "instantiation_evaluation": 2.38, + "total": 34.584, + "execute": 0.946, + "execute_eval": 10.381 + } +} +``` + +## Notes + +1. Users should be careful to save the original files while using this project; otherwise, the files will be closed when the app is shut down. +2. After starting the project, users should not close the app window while the program is taking screenshots. diff --git a/instantiation/__main__.py b/dataflow/__main__.py similarity index 81% rename from instantiation/__main__.py rename to dataflow/__main__.py index b811b80b..36abd01a 100644 --- a/instantiation/__main__.py +++ b/dataflow/__main__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from instantiation import dataflow +from dataflow import dataflow if __name__ == "__main__": # Execute the main script diff --git a/instantiation/config/config.py b/dataflow/config/config.py similarity index 94% rename from instantiation/config/config.py rename to dataflow/config/config.py index d5227a2f..4760ad0c 100644 --- a/instantiation/config/config.py +++ b/dataflow/config/config.py @@ -7,7 +7,7 @@ class Config(Config): _instance = None - def __init__(self, config_path="instantiation/config/"): + def __init__(self, config_path="dataflow/config/"): """ Initializes the Config class. :param config_path: The path to the config file. diff --git a/instantiation/config/config_dev.yaml b/dataflow/config/config_dev.yaml similarity index 51% rename from instantiation/config/config_dev.yaml rename to dataflow/config/config_dev.yaml index c51f72ce..ec0707a2 100644 --- a/instantiation/config/config_dev.yaml +++ b/dataflow/config/config_dev.yaml @@ -10,29 +10,30 @@ PRINT_LOG: False # Whether to print the log LOG_LEVEL: "INFO" # The log level MATCH_STRATEGY: "fuzzy" # The match strategy for the control filter, support 'contains', 'fuzzy', 'regex' -PREFILL_PROMPT: "instantiation/controller/prompts/{mode}/prefill.yaml" # The prompt for the action prefill -FILTER_PROMPT: "instantiation/controller/prompts/{mode}/filter.yaml" # The prompt for the filter -PREFILL_EXAMPLE_PROMPT: "instantiation/controller/prompts/{mode}/prefill_example.yaml" # The prompt for the action prefill example +PREFILL_PROMPT: "dataflow/prompts/instantiation/{mode}/prefill.yaml" # The prompt for the action prefill +FILTER_PROMPT: "dataflow/prompts/instantiation/{mode}/filter.yaml" # The prompt for the filter +PREFILL_EXAMPLE_PROMPT: "dataflow/prompts/instantiation/{mode}/prefill_example.yaml" # The prompt for the action prefill example API_PROMPT: "ufo/prompts/share/lite/api.yaml" # The prompt for the API -# Exploration Configuration -TASKS_HUB: "instantiation/tasks" # The tasks hub for the exploration -TEMPLATE_PATH: "instantiation/templates" # The template path for the exploration +# Default Task Configuration +TASKS_HUB: "dataflow/tasks/prefill" # The default tasks hub for batch dataflow +TEMPLATE_PATH: "dataflow/templates" # The template path for the exploration # Result Configuration -RESULT_HUB: "instantiation/results/{mode}" # The result hub, mode is 'instantiation' or 'execution' -RESULT_SCHEMA: "instantiation/result_schema.json" # The JSON Schema for the result log +RESULT_HUB: "dataflow/results/{task_type}" # The result hub, task_type is 'instantiation' or 'execution' +INSTANTIATION_RESULT_SCHEMA: "dataflow/schema/instantiation_schema.json" # The JSON Schema for the result log +EXECUTION_RESULT_SCHEMA: "dataflow/schema/execution_schema.json" # For control filtering CONTROL_FILTER_TYPE: [] # The list of control filter type, support 'TEXT', 'SEMANTIC', 'ICON' CONTROL_FILTER_MODEL_SEMANTIC_NAME: "all-MiniLM-L6-v2" # The control filter model name of semantic similarity -CONTROL_EMBEDDING_CACHE_PATH: "instantiation/cache/" # The cache path for the control filter +CONTROL_EMBEDDING_CACHE_PATH: "dataflow/cache/" # The cache path for the control filter CONTROL_FILTER_TOP_K_PLAN: 2 # The control filter effect on top k plans from UFO, default is 2 # log path -LOG_PATH: "instantiation/logs/{task}" -PREFILL_LOG_PATH: "instantiation/logs/{task}/prefill/" -FILTER_LOG_PATH: "instantiation/logs/{task}/filter/" -EXECUTE_LOG_PATH: "instantiation/logs/{task}/execute/" +LOG_PATH: "dataflow/logs/{task}" +PREFILL_LOG_PATH: "dataflow/logs/{task}/prefill/" +FILTER_LOG_PATH: "dataflow/logs/{task}/filter/" +EXECUTE_LOG_PATH: "dataflow/logs/{task}/execute/" MAX_STEPS: 30 # The max step for the execute_flow diff --git a/instantiation/controller/data_flow_controller.py b/dataflow/data_flow_controller.py similarity index 83% rename from instantiation/controller/data_flow_controller.py rename to dataflow/data_flow_controller.py index 52e54de9..fb5fb923 100644 --- a/instantiation/controller/data_flow_controller.py +++ b/dataflow/data_flow_controller.py @@ -5,12 +5,12 @@ from typing import Any, Dict from jsonschema import validate, ValidationError -from instantiation.controller.env.env_manager import WindowsAppEnv -from instantiation.controller.workflow.choose_template_flow import ChooseTemplateFlow -from instantiation.controller.workflow.execute_flow import ExecuteFlow -from instantiation.controller.workflow.filter_flow import FilterFlow -from instantiation.controller.workflow.prefill_flow import PrefillFlow -from instantiation.config.config import Config +from dataflow.env.env_manager import WindowsAppEnv +from dataflow.instantiation.workflow.choose_template_flow import ChooseTemplateFlow +from dataflow.instantiation.workflow.prefill_flow import PrefillFlow +from dataflow.instantiation.workflow.filter_flow import FilterFlow +from dataflow.execution.workflow.execute_flow import ExecuteFlow +from dataflow.config.config import Config from ufo.utils import print_with_color from learner.utils import load_json_file, save_json_file @@ -30,8 +30,8 @@ } EXECUTION_RESULT_MAP = { - "pass": "execution_pass", - "fail": "execution_fail", + "yes": "execution_pass", + "no": "execution_fail", "unsure": "execution_unsure" } @@ -61,11 +61,11 @@ def __init__(self, id: int, description: str, file_extension: str, win_app: str) class TaskObject: - def __init__(self, task_file_path: str, mode: str) -> None: + def __init__(self, task_file_path: str, task_type: str) -> None: """ Initialize the task object. :param task_file_path: The path to the task file. - :param mode: The mode of the task object (dataflow, instantiation, or execution). + :param task_type: The task_type of the task object (dataflow, instantiation, or execution). """ self.task_file_path = task_file_path @@ -74,8 +74,8 @@ def __init__(self, task_file_path: str, mode: str) -> None: task_json_file = load_json_file(task_file_path) self.app_object = self._choose_app_from_json(task_json_file["app"]) - # Initialize the task attributes based on the mode - self._init_attr(mode, task_json_file) + # Initialize the task attributes based on the task_type + self._init_attr(task_type, task_json_file) def _choose_app_from_json(self, task_app: str) -> AppEnum: """ @@ -89,47 +89,47 @@ def _choose_app_from_json(self, task_app: str) -> AppEnum: return app raise ValueError("Not a correct App") - def _init_attr(self, mode:str, task_json_file:Dict[str, Any]) -> None: + def _init_attr(self, task_type:str, task_json_file:Dict[str, Any]) -> None: """ Initialize the attributes of the task object. - :param mode: The mode of the task object (dataflow, instantiation, or execution). + :param task_type: The task_type of the task object (dataflow, instantiation, or execution). :param task_json_file: The task JSON file. """ - if mode == "dataflow" or mode == "instantiation": + if task_type == "dataflow" or task_type == "instantiation": for key, value in task_json_file.items(): setattr(self, key.lower().replace(" ", "_"), value) - elif mode == "execution": + elif task_type == "execution": self.app = task_json_file.get("app") self.unique_id = task_json_file.get("unique_id") original = task_json_file.get("original", {}) self.task = original.get("original_task", None) self.refined_steps = original.get("original_steps", None) else: - raise ValueError(f"Unsupported mode: {mode}") + raise ValueError(f"Unsupported task_type: {task_type}") class DataFlowController: """ Flow controller class to manage the instantiation and execution process. """ - def __init__(self, task_path: str, mode: str) -> None: + def __init__(self, task_path: str, task_type: str) -> None: """ Initialize the flow controller. :param task_path: The path to the task file. - :param mode: The mode of the flow controller (instantiation, execution, or dataflow). + :param task_type: The task_type of the flow controller (instantiation, execution, or dataflow). """ - self.task_object = TaskObject(task_path, mode) + self.task_object = TaskObject(task_path, task_type) self.app_env = None self.app_name = self.task_object.app_object.description.lower() self.task_file_name = self.task_object.task_file_name - self.schema = load_json_file(Config.get_instance().config_data["RESULT_SCHEMA"]) + self.schema = self._load_schema(task_type) - self.mode = mode + self.task_type = task_type self.task_info = self.init_task_info() - self.result_hub = _configs["RESULT_HUB"].format(mode=mode) + self.result_hub = _configs["RESULT_HUB"].format(task_type=task_type) def init_task_info(self) -> dict: """ @@ -137,7 +137,7 @@ def init_task_info(self) -> dict: :return: The initialized task information. """ init_task_info = None - if self.mode == "execution": + if self.task_type == "execution": # read from the instantiated task file init_task_info = load_json_file(self.task_object.task_file_path) else: @@ -158,6 +158,17 @@ def init_task_info(self) -> dict: } return init_task_info + def _load_schema(self, task_type: str) -> dict: + """ + load the schema based on the task_type. + :param task_type: The task_type of the task object (dataflow, instantiation, or execution). + :return: The schema for the task_type. + """ + + if task_type == "instantiation": + return load_json_file(_configs["INSTANTIATION_RESULT_SCHEMA"]) + elif task_type == "execution" or task_type == "dataflow": + return load_json_file(_configs["EXECUTION_RESULT_SCHEMA"]) def execute_instantiation(self): """ @@ -200,9 +211,7 @@ def execute_execution(self, request: str, plan: dict) -> None: execute_flow = None try: - # Start the application and open the copied template self.app_env.start(self.template_copied_path) - # Initialize the execution context and flow context = Context() execute_flow = ExecuteFlow(self.task_file_name, context, self.app_env) @@ -219,13 +228,13 @@ def execute_execution(self, request: str, plan: dict) -> None: except Exception as e: # Handle and log any exceptions that occur during execution - error_traceback = traceback.format_exc() self.task_info["execution_result"]["error"] = { "type": str(type(e).__name__), "message": str(e), - "traceback": error_traceback, + "traceback": traceback.format_exc(), } print_with_color(f"Error in Execution: {e}", "red") + raise e finally: # Record the total time cost of the execution process if execute_flow and hasattr(execute_flow, "execution_time"): @@ -286,12 +295,12 @@ def save_result(self) -> None: except ValidationError as e: # Record the validation error but allow the process to continue validation_error = str(e.message) - print_with_color(f"Warning: Schema Validation Warning: {validation_error}", "yellow") + print_with_color(f"Validation Error: {e.message}", "yellow") - # Determine the target directory based on mode and quality/completeness + # Determine the target directory based on task_type and quality/completeness target_file = None - if self.mode == "instantiation": + if self.task_type == "instantiation": # Determine the quality of the instantiation if not self.task_info["instantiation_result"]["instantiation_evaluation"]["result"]: target_file = INSTANTIATION_RESULT_MAP[False] @@ -302,10 +311,10 @@ def save_result(self) -> None: else: # Determine the completion status of the execution if not self.task_info["execution_result"]["result"]: - target_file = EXECUTION_RESULT_MAP["fail"] + target_file = EXECUTION_RESULT_MAP["no"] else: is_completed = self.task_info["execution_result"]["result"]["complete"] - target_file = EXECUTION_RESULT_MAP.get(is_completed, EXECUTION_RESULT_MAP["fail"]) + target_file = EXECUTION_RESULT_MAP.get(is_completed, EXECUTION_RESULT_MAP["no"]) # Construct the full path to save the result new_task_path = os.path.join(self.result_hub, target_file, self.task_object.task_file_base_name) @@ -356,16 +365,16 @@ def run(self) -> None: try: self.app_env = WindowsAppEnv(self.task_object.app_object) - if self.mode == "dataflow": + if self.task_type == "dataflow": plan = self.execute_instantiation() self.execute_execution(self.task_object.task, plan) - elif self.mode == "instantiation": + elif self.task_type == "instantiation": self.execute_instantiation() - elif self.mode == "execution": + elif self.task_type == "execution": plan = self.instantiated_plan self.execute_execution(self.task_object.task, plan) else: - raise ValueError(f"Unsupported mode: {self.mode}") + raise ValueError(f"Unsupported task_type: {self.task_type}") except Exception as e: raise e diff --git a/dataflow/dataflow.py b/dataflow/dataflow.py new file mode 100644 index 00000000..f2935f83 --- /dev/null +++ b/dataflow/dataflow.py @@ -0,0 +1,137 @@ +import argparse +import os +import traceback +from ufo.utils import print_with_color +from dataflow.config.config import Config +_configs = Config.get_instance().config_data + + +def parse_args() -> argparse.Namespace: + """ + Parse command-line arguments for single and batch task processing. + :return: Namespace of argparse + """ + parser = argparse.ArgumentParser( + description="Run task with different execution modes." + ) + + # Subparsers for different modes + subparsers = parser.add_subparsers( + title="commands", + description="Choose between single or batch task processing modes.", + dest="command", + required=True, # Force the user to choose one subcommand + ) + + # Single task processing + single_parser = subparsers.add_parser( + "single", help="Process a single task file." + ) + single_parser.add_argument( + "task_type", + choices=["dataflow", "instantiation", "execution"], + help="Execution task_type for the task.", + ) + single_parser.add_argument( + "task_path", + type=str, + help="Path to the task file.", + ) + + # Batch task processing + batch_parser = subparsers.add_parser( + "batch", help="Process all tasks in a directory." + ) + batch_parser.add_argument( + "task_type", + default="dataflow", + choices=["dataflow", "instantiation", "execution"], + help="Execution task_type for the tasks.", + ) + batch_parser.add_argument( + "task_dir", + default=_configs["TASKS_HUB"], + type=str, + help="Path to the directory containing task files.", + ) + + return parser.parse_args() + + +def validate_path(path: str, path_type: str) -> bool: + """ + Validate the given path based on type. + :param path: The path to validate. + :param path_type: Type of path: "file" or "directory". + :return: True if the path is valid, otherwise False. + """ + if path_type == "file": + if not os.path.isfile(path): + print_with_color(f"Invalid file path: {path}", "red") + return False + elif path_type == "directory": + if not os.path.isdir(path): + print_with_color(f"Invalid directory path: {path}", "red") + return False + return True + + +def process_single_task(task_path: str, task_type: str) -> None: + """ + Single task processing. + :param task_path: The path to the task file. + :param task_type: The type of task to process. + """ + + from dataflow.data_flow_controller import DataFlowController + + try: + print_with_color(f"Starting processing for task: {task_path}", "green") + flow_controller = DataFlowController(task_path, task_type) + flow_controller.run() + print_with_color(f"Task {task_path} completed successfully!", "green") + except Exception as e: + print_with_color(f"Error processing {task_path}: {e}", "red") + +def process_batch_tasks(task_dir: str, task_type: str) -> None: + """ + Batch tasks processing. + :param task_dir: The path to the task directory. + :param task_type: The type of task to process. + """ + + task_files = [ + os.path.join(task_dir, f) + for f in os.listdir(task_dir) + if os.path.isfile(os.path.join(task_dir, f)) + ] + + if not task_files: + print_with_color(f"No task files found in directory: {task_dir}", "yellow") + return + + print_with_color(f"Found {len(task_files)} tasks in {task_dir}.", "blue") + for task_file in task_files: + print_with_color(f"Processing {task_file}...", "blue") + process_single_task(task_file, task_type) + + +def main(): + """ + Main function to run tasks based on the provided arguments. + You can use dataflow, instantiation, and execution modes to process the task. + Also, you can run tasks in batch mode by providing the path to the task directory. + See README to read the detailed usage. + """ + args = parse_args() + + if args.command == "single": + if validate_path(args.task_path, "file"): + process_single_task(args.task_path, args.task_type) + elif args.command == "batch": + if validate_path(args.task_dir, "directory"): + process_batch_tasks(args.task_dir, args.task_type) + + +if __name__ == "__main__": + main() diff --git a/instantiation/controller/env/env_manager.py b/dataflow/env/env_manager.py similarity index 81% rename from instantiation/controller/env/env_manager.py rename to dataflow/env/env_manager.py index dec34b0c..e0e186f7 100644 --- a/instantiation/controller/env/env_manager.py +++ b/dataflow/env/env_manager.py @@ -1,5 +1,7 @@ import logging +from multiprocessing import process import re +from time import sleep from typing import Optional import psutil @@ -7,7 +9,7 @@ from pywinauto import Desktop from pywinauto.controls.uiawrapper import UIAWrapper -from instantiation.config.config import Config +from dataflow.config.config import Config # Load configuration settings _configs = Config.get_instance().config_data @@ -59,44 +61,28 @@ def close(self) -> None: if self.app_window: self.app_window.close() - # Check if process is still running - if self._is_window_open(): - logging.warning("Application is still running after graceful close. Attempting to forcefully terminate.") - self._force_kill() - else: - logging.info("Application closed gracefully.") + self._check_and_kill_process() + sleep(1) except Exception as e: logging.warning(f"Graceful close failed: {e}. Attempting to forcefully terminate the process.") - self._force_kill() + self._check_and_kill_process() + raise e - def _is_window_open(self) -> bool: + def _check_and_kill_process(self) -> None: """ - Checks if the specific application window is still open. + Checks if the process is still running and kills it if it is. """ try: # Ensure the app_window object is still valid and visible - if self.app_window.is_enabled(): - return True - return False + if self.app_window and not self.app_window.is_visible(): + process = psutil.Process(self.app_window.process_id) + if process.is_running(): + print(f"Killing process: {self.app_window.process_id}") + process.terminate() except Exception as e: logging.error(f"Error while checking window status: {e}") - return False - - def _force_kill(self) -> None: - """ - Forcefully terminates the application process by its name. - """ - - for proc in psutil.process_iter(['pid', 'name']): - if self.win_app.lower() in proc.info['name'].lower(): - try: - proc.kill() - logging.info(f"Process {self.win_app} (PID: {proc.info['pid']}) forcefully terminated.") - return - except Exception as kill_exception: - logging.error(f"Failed to kill process {proc.info['name']} (PID: {proc.info['pid']}): {kill_exception}") - logging.error(f"No matching process found for {self.win_app}.") + raise e def find_matching_window(self, doc_name: str) -> Optional[UIAWrapper]: """ @@ -110,7 +96,6 @@ def find_matching_window(self, doc_name: str) -> Optional[UIAWrapper]: for window in windows_list: window_title = window.element_info.name.lower() if self._match_window_name(window_title, doc_name): - self.app_window = window self.app_window = window return window return None diff --git a/dataflow/execution/agent/execute_agent.py b/dataflow/execution/agent/execute_agent.py new file mode 100644 index 00000000..ff95ec9e --- /dev/null +++ b/dataflow/execution/agent/execute_agent.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Dict, List, Optional + +from ufo.agents.agent.app_agent import AppAgent + + +class ExecuteAgent(AppAgent): + """ + The Agent for task execution. + """ + + def __init__( + self, + name: str, + process_name: str, + app_root_name: str, + ): + """ + Initialize the ExecuteAgent. + :param name: The name of the agent. + :param process_name: The name of the process. + :param app_root_name: The name of the app root. + """ + + self._step = 0 + self._complete = False + self._name = name + self._status = None + self._process_name = process_name + self._app_root_name = app_root_name + self.Puppeteer = self.create_puppeteer_interface() \ No newline at end of file diff --git a/dataflow/execution/agent/execute_eval_agent.py b/dataflow/execution/agent/execute_eval_agent.py new file mode 100644 index 00000000..0044ad16 --- /dev/null +++ b/dataflow/execution/agent/execute_eval_agent.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Optional + +from dataflow.prompter.execution.execute_eval_prompter import ExecuteEvalAgentPrompter +from ufo.agents.agent.evaluation_agent import EvaluationAgent + +class ExecuteEvalAgent(EvaluationAgent): + """ + The Agent for task execution evaluation. + """ + + def __init__( + self, + name: str, + app_root_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the ExecuteEvalAgent. + :param name: The name of the agent. + :param app_root_name: The name of the app root. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + super().__init__( + name=name, + app_root_name=app_root_name, + is_visual=is_visual, + main_prompt=main_prompt, + example_prompt=example_prompt, + api_prompt=api_prompt, + ) + + def get_prompter( + self, + is_visual: bool, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + root_name: Optional[str] = None, + ) -> ExecuteEvalAgentPrompter: + """ + Get the prompter for the agent. + :param is_visual: The flag indicating whether the agent is visual or not. + :param prompt_template: The prompt template. + :param example_prompt_template: The example prompt template. + :param api_prompt_template: The API prompt template. + :param root_name: The name of the root. + :return: The prompter. + """ + + return ExecuteEvalAgentPrompter( + is_visual=is_visual, + prompt_template=prompt_template, + example_prompt_template=example_prompt_template, + api_prompt_template=api_prompt_template, + root_name=root_name, + ) \ No newline at end of file diff --git a/instantiation/controller/workflow/execute_flow.py b/dataflow/execution/workflow/execute_flow.py similarity index 94% rename from instantiation/controller/workflow/execute_flow.py rename to dataflow/execution/workflow/execute_flow.py index 01499484..8a3c8ad5 100644 --- a/instantiation/controller/workflow/execute_flow.py +++ b/dataflow/execution/workflow/execute_flow.py @@ -2,9 +2,10 @@ import time from typing import Any, Dict, List, Tuple -from instantiation.config.config import Config as InstantiationConfig -from instantiation.controller.agent.agent import ExecuteAgent, ExecuteEvalAgent -from instantiation.controller.env.env_manager import WindowsAppEnv +from dataflow.config.config import Config as InstantiationConfig +from dataflow.env.env_manager import WindowsAppEnv +from dataflow.execution.agent.execute_agent import ExecuteAgent +from dataflow.execution.agent.execute_eval_agent import ExecuteEvalAgent from ufo import utils from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.config.config import Config as UFOConfig @@ -140,6 +141,12 @@ def execute_plan( self.app_agent.Puppeteer.receiver_manager.create_api_receiver( self.app_agent._app_root_name, self.app_agent._process_name ) + # Initialize the control receiver + current_receiver = self.app_agent.Puppeteer.receiver_manager.receiver_list[0] + if current_receiver is not None: + self.application_window = self._app_env.find_matching_window(self._task_file_name) + current_receiver.com_object = current_receiver.get_object_from_process_name() + self.init_capture_screenshot() except Exception as error: raise RuntimeError(f"Execution initialization failed. {error}") diff --git a/dataflow/instantiation/agent/filter_agent.py b/dataflow/instantiation/agent/filter_agent.py new file mode 100644 index 00000000..f8649ec0 --- /dev/null +++ b/dataflow/instantiation/agent/filter_agent.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import List, Optional + +from dataflow.prompter.instantiation.filter_prompter import FilterPrompter +from ufo.agents.agent.basic import BasicAgent + +class FilterAgent(BasicAgent): + """ + The Agent to evaluate the instantiated task is correct or not. + """ + + def __init__( + self, + name: str, + process_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the FilterAgent. + :param name: The name of the agent. + :param process_name: The name of the process. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + self._step = 0 + self._complete = False + self._name = name + self._status = None + self.prompter: FilterPrompter = self.get_prompter( + is_visual, main_prompt, example_prompt, api_prompt + ) + self._process_name = process_name + + def get_prompter( + self, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str + ) -> FilterPrompter: + """ + Get the prompt for the agent. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + :return: The prompt string. + """ + + return FilterPrompter(is_visual, main_prompt, example_prompt, api_prompt) + + def message_constructor(self, request: str, app: str) -> List[str]: + """ + Construct the prompt message for the FilterAgent. + :param request: The request sentence. + :param app: The name of the operated app. + :return: The prompt message. + """ + + filter_agent_prompt_system_message = self.prompter.system_prompt_construction( + app=app + ) + filter_agent_prompt_user_message = self.prompter.user_content_construction( + request + ) + filter_agent_prompt_message = self.prompter.prompt_construction( + filter_agent_prompt_system_message, filter_agent_prompt_user_message + ) + + return filter_agent_prompt_message + + def process_comfirmation(self) -> None: + """ + Confirm the process. + This is the abstract method from BasicAgent that needs to be implemented. + """ + + pass \ No newline at end of file diff --git a/dataflow/instantiation/agent/prefill_agent.py b/dataflow/instantiation/agent/prefill_agent.py new file mode 100644 index 00000000..139244f2 --- /dev/null +++ b/dataflow/instantiation/agent/prefill_agent.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Dict, List + +from dataflow.prompter.instantiation.prefill_prompter import PrefillPrompter + +from ufo.agents.agent.basic import BasicAgent + + +class PrefillAgent(BasicAgent): + """ + The Agent for task instantialization and action sequence generation. + """ + + def __init__( + self, + name: str, + process_name: str, + is_visual: bool, + main_prompt: str, + example_prompt: str, + api_prompt: str, + ): + """ + Initialize the PrefillAgent. + :param name: The name of the agent. + :param process_name: The name of the process. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + """ + + self._step = 0 + self._complete = False + self._name = name + self._status = None + self.prompter: PrefillPrompter = self.get_prompter( + is_visual, main_prompt, example_prompt, api_prompt + ) + self._process_name = process_name + + def get_prompter(self, is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str) -> str: + """ + Get the prompt for the agent. + This is the abstract method from BasicAgent that needs to be implemented. + :param is_visual: The flag indicating whether the agent is visual or not. + :param main_prompt: The main prompt. + :param example_prompt: The example prompt. + :param api_prompt: The API prompt. + :return: The prompt string. + """ + + return PrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) + + def message_constructor( + self, + dynamic_examples: str, + given_task: str, + reference_steps: List[str], + doc_control_state: Dict[str, str], + log_path: str, + ) -> List[str]: + """ + Construct the prompt message for the PrefillAgent. + :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. + :param given_task: The given task. + :param reference_steps: The reference steps. + :param doc_control_state: The document control state. + :param log_path: The path of the log. + :return: The prompt message. + """ + + prefill_agent_prompt_system_message = self.prompter.system_prompt_construction( + dynamic_examples + ) + prefill_agent_prompt_user_message = self.prompter.user_content_construction( + given_task, reference_steps, doc_control_state, log_path + ) + appagent_prompt_message = self.prompter.prompt_construction( + prefill_agent_prompt_system_message, + prefill_agent_prompt_user_message, + ) + + return appagent_prompt_message + + def process_comfirmation(self) -> None: + """ + Confirm the process. + This is the abstract method from BasicAgent that needs to be implemented. + """ + + pass \ No newline at end of file diff --git a/instantiation/controller/workflow/choose_template_flow.py b/dataflow/instantiation/workflow/choose_template_flow.py similarity index 97% rename from instantiation/controller/workflow/choose_template_flow.py rename to dataflow/instantiation/workflow/choose_template_flow.py index 41ba136c..639e5e73 100644 --- a/instantiation/controller/workflow/choose_template_flow.py +++ b/dataflow/instantiation/workflow/choose_template_flow.py @@ -12,7 +12,7 @@ from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS -from instantiation.config.config import Config +from dataflow.config.config import Config _configs = Config.get_instance().config_data @@ -140,8 +140,8 @@ def _choose_template_and_copy(self) -> str: Path(_configs["TEMPLATE_PATH"]) / self._app_name / chosen_template_file_path ) - target_template_folder_path = Path(_configs["TASKS_HUB"]) / ( - os.path.dirname(os.path.dirname(self._task_file_name)) + "_templates" + target_template_folder_path = Path(_configs["RESULT_HUB"].format(task_type = "saved_document")) / ( + os.path.dirname(os.path.dirname(self._task_file_name)) ) return self._create_copied_file( diff --git a/instantiation/controller/workflow/filter_flow.py b/dataflow/instantiation/workflow/filter_flow.py similarity index 97% rename from instantiation/controller/workflow/filter_flow.py rename to dataflow/instantiation/workflow/filter_flow.py index 1499df73..7aa55d1d 100644 --- a/instantiation/controller/workflow/filter_flow.py +++ b/dataflow/instantiation/workflow/filter_flow.py @@ -4,8 +4,8 @@ import time from typing import Dict, Tuple, Any -from instantiation.config.config import Config -from instantiation.controller.agent.agent import FilterAgent +from dataflow.config.config import Config +from dataflow.instantiation.agent.filter_agent import FilterAgent from ufo.module.basic import BaseSession _configs = Config.get_instance().config_data diff --git a/instantiation/controller/workflow/prefill_flow.py b/dataflow/instantiation/workflow/prefill_flow.py similarity index 98% rename from instantiation/controller/workflow/prefill_flow.py rename to dataflow/instantiation/workflow/prefill_flow.py index 6a277023..c03109a5 100644 --- a/instantiation/controller/workflow/prefill_flow.py +++ b/dataflow/instantiation/workflow/prefill_flow.py @@ -4,9 +4,9 @@ import time from typing import Any, Dict, List, Tuple -from instantiation.config.config import Config -from instantiation.controller.agent.agent import PrefillAgent -from instantiation.controller.env.env_manager import WindowsAppEnv +from dataflow.config.config import Config +from dataflow.instantiation.agent.prefill_agent import PrefillAgent +from dataflow.env.env_manager import WindowsAppEnv from ufo.agents.processors.app_agent_processor import AppAgentProcessor from ufo.automator.ui_control.inspector import ControlInspectorFacade from ufo.automator.ui_control.screenshot import PhotographerFacade diff --git a/dataflow/prompter/execution/execute_eval_prompter.py b/dataflow/prompter/execution/execute_eval_prompter.py new file mode 100644 index 00000000..2d35ce09 --- /dev/null +++ b/dataflow/prompter/execution/execute_eval_prompter.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +import os +from typing import Dict, List, Optional + +from ufo.prompter.basic import BasicPrompter +from ufo.prompter.eva_prompter import EvaluationAgentPrompter + +class ExecuteEvalAgentPrompter(EvaluationAgentPrompter): + """ + Execute the prompt for the ExecuteEvalAgent. + """ + + def __init__( + self, + is_visual: bool, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + root_name: Optional[str] = None, + ): + """ + Initialize the CustomEvaluationAgentPrompter. + :param is_visual: Whether the request is for visual model. + :param prompt_template: The path of the prompt template. + :param example_prompt_template: The path of the example prompt template. + :param api_prompt_template: The path of the api prompt template. + :param root_name: The name of the root application. + """ + + super().__init__( + is_visual, + prompt_template, + example_prompt_template, + api_prompt_template, + root_name, + ) + + @staticmethod + def load_logs(log_path: str) -> List[Dict[str, str]]: + """ + Load logs from the log path. + :param log_path: The path of the log. + """ + + log_file_path = os.path.join(log_path, "execute_log.json") + with open(log_file_path, "r") as f: + logs = f.readlines() + logs = [json.loads(log) for log in logs] + return logs diff --git a/dataflow/prompter/instantiation/filter_prompter.py b/dataflow/prompter/instantiation/filter_prompter.py new file mode 100644 index 00000000..cec5658d --- /dev/null +++ b/dataflow/prompter/instantiation/filter_prompter.py @@ -0,0 +1,162 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +import os +from typing import Dict, List, Optional + +from ufo.prompter.basic import BasicPrompter +from ufo.prompter.eva_prompter import EvaluationAgentPrompter + + +class FilterPrompter(BasicPrompter): + """ + Load the prompt for the FilterAgent. + """ + + def __init__( + self, + is_visual: bool, + prompt_template: str, + example_prompt_template: str, + api_prompt_template: str, + ): + """ + Initialize the FilterPrompter. + :param is_visual: The flag indicating whether the prompter is visual or not. + :param prompt_template: The prompt template. + :param example_prompt_template: The example prompt template. + :param api_prompt_template: The API prompt template. + """ + + super().__init__(is_visual, prompt_template, example_prompt_template) + self.api_prompt_template = self.load_prompt_template( + api_prompt_template, is_visual + ) + + def api_prompt_helper(self, apis: Dict = {}, verbose: int = 1) -> str: + """ + Construct the prompt for APIs. + :param apis: The APIs. + :param verbose: The verbosity level. + :return: The prompt for APIs. + """ + + # Construct the prompt for APIs + if len(apis) == 0: + api_list = [ + "- The action type are limited to {actions}.".format( + actions=list(self.api_prompt_template.keys()) + ) + ] + + # Construct the prompt for each API + for key in self.api_prompt_template.keys(): + api = self.api_prompt_template[key] + if verbose > 0: + api_text = "{summary}\n{usage}".format( + summary=api["summary"], usage=api["usage"] + ) + else: + api_text = api["summary"] + + api_list.append(api_text) + + api_prompt = self.retrived_documents_prompt_helper("", "", api_list) + else: + api_list = [ + "- The action type are limited to {actions}.".format( + actions=list(apis.keys()) + ) + ] + + # Construct the prompt for each API + for key in apis.keys(): + api = apis[key] + api_text = "{description}\n{example}".format( + description=api["description"], example=api["example"] + ) + api_list.append(api_text) + + api_prompt = self.retrived_documents_prompt_helper("", "", api_list) + + return api_prompt + + def system_prompt_construction(self, app: str = "") -> str: + """ + Construct the prompt for the system. + :param app: The app name. + :return: The prompt for the system. + """ + + try: + ans = self.prompt_template["system"] + ans = ans.format(app=app) + return ans + except Exception as e: + print(e) + + def user_prompt_construction(self, request: str) -> str: + """ + Construct the prompt for the user. + :param request: The user request. + :return: The prompt for the user. + """ + + prompt = self.prompt_template["user"].format(request=request) + return prompt + + def user_content_construction(self, request: str) -> List[Dict]: + """ + Construct the prompt for LLMs. + :param request: The user request. + :return: The prompt for LLMs. + """ + + user_content = [] + + user_content.append( + {"type": "text", "text": self.user_prompt_construction(request)} + ) + + return user_content + + def examples_prompt_helper( + self, + header: str = "## Response Examples", + separator: str = "Example", + additional_examples: List[str] = [], + ) -> str: + """ + Construct the prompt for examples. + :param header: The header of the prompt. + :param separator: The separator of the prompt. + :param additional_examples: The additional examples. + :return: The prompt for examples. + """ + + template = """ + [User Request]: + {request} + [Response]: + {response} + [Tip] + {tip} + """ + + example_list = [] + + for key in self.example_prompt_template.keys(): + if key.startswith("example"): + example = template.format( + request=self.example_prompt_template[key].get("Request"), + response=json.dumps( + self.example_prompt_template[key].get("Response") + ), + tip=self.example_prompt_template[key].get("Tips", ""), + ) + example_list.append(example) + + example_list += [json.dumps(example) for example in additional_examples] + + return self.retrived_documents_prompt_helper(header, separator, example_list) diff --git a/instantiation/controller/prompter/agent_prompter.py b/dataflow/prompter/instantiation/prefill_prompter.py similarity index 88% rename from instantiation/controller/prompter/agent_prompter.py rename to dataflow/prompter/instantiation/prefill_prompter.py index e24e228b..5f979553 100644 --- a/instantiation/controller/prompter/agent_prompter.py +++ b/dataflow/prompter/instantiation/prefill_prompter.py @@ -334,48 +334,4 @@ def examples_prompt_helper( example_list += [json.dumps(example) for example in additional_examples] - return self.retrived_documents_prompt_helper(header, separator, example_list) - - -class ExecuteEvalAgentPrompter(EvaluationAgentPrompter): - """ - Execute the prompt for the ExecuteEvalAgent. - """ - - def __init__( - self, - is_visual: bool, - prompt_template: str, - example_prompt_template: str, - api_prompt_template: str, - root_name: Optional[str] = None, - ): - """ - Initialize the CustomEvaluationAgentPrompter. - :param is_visual: Whether the request is for visual model. - :param prompt_template: The path of the prompt template. - :param example_prompt_template: The path of the example prompt template. - :param api_prompt_template: The path of the api prompt template. - :param root_name: The name of the root application. - """ - - super().__init__( - is_visual, - prompt_template, - example_prompt_template, - api_prompt_template, - root_name, - ) - - @staticmethod - def load_logs(log_path: str) -> List[Dict[str, str]]: - """ - Load logs from the log path. - :param log_path: The path of the log. - """ - - log_file_path = os.path.join(log_path, "execute_log.json") - with open(log_file_path, "r") as f: - logs = f.readlines() - logs = [json.loads(log) for log in logs] - return logs + return self.retrived_documents_prompt_helper(header, separator, example_list) \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/filter.yaml b/dataflow/prompts/instantiation/visual/filter.yaml similarity index 100% rename from instantiation/controller/prompts/visual/filter.yaml rename to dataflow/prompts/instantiation/visual/filter.yaml diff --git a/instantiation/controller/prompts/visual/prefill.yaml b/dataflow/prompts/instantiation/visual/prefill.yaml similarity index 100% rename from instantiation/controller/prompts/visual/prefill.yaml rename to dataflow/prompts/instantiation/visual/prefill.yaml diff --git a/instantiation/controller/prompts/visual/prefill_example.yaml b/dataflow/prompts/instantiation/visual/prefill_example.yaml similarity index 100% rename from instantiation/controller/prompts/visual/prefill_example.yaml rename to dataflow/prompts/instantiation/visual/prefill_example.yaml diff --git a/dataflow/schema/execution_schema.json b/dataflow/schema/execution_schema.json new file mode 100644 index 00000000..21d58a78 --- /dev/null +++ b/dataflow/schema/execution_schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "unique_id": { "type": "string" }, + "app": { "type": "string" }, + "original": { + "type": "object", + "properties": { + "original_task": { "type": "string" }, + "original_steps": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["original_task", "original_steps"] + }, + "execution_result": { + "type": ["object", "null"], + "properties": { + "result": { + "type": ["object", "null"], + "properties": { + "reason": { "type": "string" }, + "sub_scores": { + "type": "object", + "patternProperties": { + ".*": { "type": "string" } + } + }, + "complete": { "type": "string" } + }, + "required": ["reason", "sub_scores", "complete"] + }, + "error": { + "type": ["null", "object"], + "properties": { + "type": { "type": "string" }, + "message": { "type": "string" }, + "traceback": { "type": "string" } + }, + "required": ["type", "message", "traceback"] + } + } + }, + "instantiation_result": { + "type": "object", + "properties": { + "choose_template": { + "type": "object", + "properties": { + "result": { "type": ["string", "null"] }, + "error": { "type": ["null", "string"] } + }, + "required": ["result", "error"] + }, + "prefill": { + "type": ["object", "null"], + "properties": { + "result": { + "type": ["object", "null"], + "properties": { + "instantiated_request": { "type": "string" }, + "instantiated_plan": { + "type":["array", "null"], + "items": { + "type": "object", + "properties": { + "Step": { "type": "integer" }, + "Subtask": { "type": "string" }, + "ControlLabel": { "type": ["string", "null"] }, + "ControlText": { "type": "string" }, + "Function": { "type": "string" }, + "Args": { "type": "object", "additionalProperties": true }, + "Success": { "type": ["boolean", "null"] }, + "MatchedControlText": { "type": ["string", "null"] } + }, + "required": ["Step", "Subtask", "Function", "Args", "Success", "MatchedControlText"] + } + } + }, + "required": ["instantiated_request", "instantiated_plan"] + }, + "error": { "type": ["null", "string"] } + }, + "required": ["result", "error"] + }, + "instantiation_evaluation": { + "type": "object", + "properties": { + "result": { + "type": ["object", "null"], + "properties": { + "judge": { "type": "boolean" }, + "thought": { "type": "string" }, + "request_type": { "type": "string" } + }, + "required": ["judge", "thought", "request_type"] + }, + "error": { "type": ["null", "string"] } + }, + "required": ["result", "error"] + } + } + }, + "time_cost": { + "type": "object", + "properties": { + "choose_template": { "type": ["number", "null"] }, + "prefill":{ "type": ["number", "null"] }, + "instantiation_evaluation": { "type": ["number", "null"] }, + "total": { "type": ["number", "null"] }, + "execute": { "type": ["number", "null"] }, + "execute_eval": { "type": ["number", "null"] } + }, + "required": ["choose_template", "prefill", "instantiation_evaluation", "total", "execute", "execute_eval"] + } + }, + "required": ["unique_id", "app", "original", "execution_result", "instantiation_result", "time_cost"] +} diff --git a/dataflow/schema/instantiation_schema.json b/dataflow/schema/instantiation_schema.json new file mode 100644 index 00000000..9e29dcbc --- /dev/null +++ b/dataflow/schema/instantiation_schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "unique_id": { "type": "string" }, + "app": { "type": "string" }, + "original": { + "type": "object", + "properties": { + "original_task": { "type": "string" }, + "original_steps": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["original_task", "original_steps"] + }, + "execution_result": { + "type": ["object", "null"], + "properties": { + "result": { + "type":"null" + }, + "error": { + "type":"null" + } + } + }, + "instantiation_result": { + "type": "object", + "properties": { + "choose_template": { + "type": "object", + "properties": { + "result": { "type": ["string", "null"] }, + "error": { "type": ["null", "string"] } + }, + "required": ["result", "error"] + }, + "prefill": { + "type": ["object", "null"], + "properties": { + "result": { + "type": ["object", "null"], + "properties": { + "instantiated_request": { "type": "string" }, + "instantiated_plan": { + "type":["array", "null"], + "items": { + "type": "object", + "properties": { + "Step": { "type": "integer" }, + "Subtask": { "type": "string" }, + "ControlLabel": { "type": ["string", "null"] }, + "ControlText": { "type": "string" }, + "Function": { "type": "string" }, + "Args": { "type": "object", "additionalProperties": true } + }, + "required": ["Step", "Subtask", "Function", "Args"] + } + } + }, + "required": ["instantiated_request", "instantiated_plan"] + }, + "error": { "type": ["null", "string"] } + }, + "required": ["result", "error"] + }, + "instantiation_evaluation": { + "type": "object", + "properties": { + "result": { + "type": ["object", "null"], + "properties": { + "judge": { "type": "boolean" }, + "thought": { "type": "string" }, + "request_type": { "type": "string" } + }, + "required": ["judge", "thought", "request_type"] + }, + "error": { "type": ["null", "string"] } + }, + "required": ["result", "error"] + } + } + }, + "time_cost": { + "type": "object", + "properties": { + "choose_template": { "type": ["number", "null"] }, + "prefill":{ "type": ["number", "null"] }, + "instantiation_evaluation": { "type": ["number", "null"] }, + "total": { "type": ["number", "null"] }, + "execute": { "type": ["number", "null"] }, + "execute_eval": { "type": ["number", "null"] } + }, + "required": ["choose_template", "prefill", "instantiation_evaluation", "total", "execute", "execute_eval"] + } + }, + "required": ["unique_id", "app", "original", "execution_result", "instantiation_result", "time_cost"] +} diff --git a/instantiation/README.md b/instantiation/README.md deleted file mode 100644 index cf0be7e4..00000000 --- a/instantiation/README.md +++ /dev/null @@ -1,227 +0,0 @@ -## Introduction of Instantiation - -**The instantiation process aims to filter and modify instructions according to the current environment.** - -By using this process, we can obtain clearer and more specific instructions, making them more suitable for the execution of the UFO. - -## How to Use - -### 1. Install Packages - -You should install the necessary packages in the UFO root folder: - -```bash -pip install -r requirements.txt -``` - -### 2. Configure the LLMs - -Before using the instantiation section, you need to provide your LLM configurations in `config.yaml` and `config_dev.yaml` located in the `instantiation/config` folder. - -- `config_dev.yaml` specifies the paths of relevant files and contains default settings. The match strategy for the control filter supports options: `'contains'`, `'fuzzy'`, and `'regex'`, allowing flexible matching between application windows and target files. - -- `config.yaml` stores the agent information. You should copy the `config.yaml.template` file and fill it out according to the provided hints. - -You will configure the prefill agent and the filter agent individually. The prefill agent is used to prepare the task, while the filter agent evaluates the quality of the prefilled task. You can choose different LLMs for each. - -**BE CAREFUL!** If you are using GitHub or other open-source tools, do not expose your `config.yaml` online, as it contains your private keys. - -Once you have filled out the template, rename it to `config.yaml` to complete the LLM configuration. - -### 3. Prepare Files - -Certain files need to be prepared before running the task. - -#### 3.1. Tasks as JSON - -The tasks that need to be instantiated should be organized in a folder of JSON files, with the default folder path set to `instantiation/tasks`. This path can be changed in the `instantiation/config/config.yaml` file, or you can specify it in the terminal, as mentioned in **4. Start Running**. For example, a task stored in `instantiation/tasks/prefill/` may look like this: - -```json -{ - // The app you want to use - "app": "word", - // A unique ID to distinguish different tasks - "unique_id": "1", - // The task and steps to be instantiated - "task": "Type 'hello' and set the font type to Arial", - "refined_steps": [ - "Type 'hello'", - "Set the font to Arial" - ] -} -``` - -#### 3.2. Templates and Descriptions - -You should place an app file as a reference for instantiation in a folder named after the app. - -For example, if you have `template1.docx` for Word, it should be located at `instantiation/templates/word/template1.docx`. - -Additionally, for each app folder, there should be a `description.json` file located at `instantiation/templates/word/description.json`, which describes each template file in detail. It may look like this: - -```json -{ - "template1.docx": "A document with a rectangle shape", - "template2.docx": "A document with a line of text", - "template3.docx": "A document with a chart" -} -``` - -If a `description.json` file is not present, one template file will be selected at random. - -#### 3.3. Final Structure - -Ensure the following files are in place: - -- [X] JSON files to be instantiated -- [X] Templates as references for instantiation -- [X] Description file in JSON format - -The structure of the files can be: - -```bash -instantiation/ -| -├── tasks/ -│ ├── action_prefill/ -│ │ ├── task1.json -│ │ ├── task2.json -│ │ └── task3.json -│ └── ... -| -├── templates/ -│ ├── word/ -│ │ ├── template1.docx -│ │ ├── template2.docx -│ │ ├── template3.docx -│ │ └── description.json -│ └── ... -└── ... -``` - -### 4. Start Running - -Run the `instantiation/action_prefill.py` file in module mode. You can do this by typing the following command in the terminal: - -```bash -python -m instantiation -``` - -You can use `--task` to specify the task folder you want to use; the default is `action_prefill`: - -```bash -python -m instantiation --task your_task_folder_name -``` - -After the process is completed, a new folder named `prefill_instantiated` will be created alongside the original one. This folder will contain the instantiated task, which will look like: - -```json -{ - // A unique ID to distinguish different tasks - "unique_id": "1", - // The chosen template path - "instantial_template_path": "copied template file path", - // The instantiated task and steps - "instantiated_request": "Type 'hello' and set the font type to Arial in the Word document.", - "instantiated_plan": [ - { - "step 1": "Select the target text 'text to edit'", - "controlLabel": "", - "controlText": "", - "function": "select_text", - "args": { - "text": "text to edit" - } - }, - { - "step 2": "Type 'hello'", - "controlLabel": "101", - "controlText": "Edit", - "function": "type_keys", - "args": { - "text": "hello" - } - }, - { - "step 3": "Select the typed text 'hello'", - "controlLabel": "", - "controlText": "", - "function": "select_text", - "args": { - "text": "hello" - } - }, - { - "step 4": "Click the font dropdown", - "controlLabel": "", - "controlText": "Consolas", - "function": "click_input", - "args": { - "button": "left", - "double": false - } - }, - { - "step 5": "Set the font to Arial", - "controlLabel": "", - "controlText": "Arial", - "function": "click_input", - "args": { - "button": "left", - "double": false - } - } - ], - "result": { - "filter": "Drawing or writing a signature using the drawing tools in the Word desktop app is a task that can be executed locally within the application." - }, - "execution_time": { - "choose_template": 10.650701761245728, - "prefill": 44.23913502693176, - "filter": 3.746831178665161, - "total": 58.63666796684265 - } -} -``` - -Additionally, a `prefill_templates` folder will be created, which stores the copied chosen templates for each task. - -## Workflow - -There are three key steps in the instantiation process: - -1. Choose a template file according to the specified app and instruction. -2. Prefill the task using the current screenshot. -3. Filter the established task. - -#### 1. Choose Template File - -Templates for your app must be defined and described in `instantiation/templates/app`. For instance, if you want to instantiate tasks for the Word application, place the relevant `.docx` files in `instantiation/templates/word`, along with a `description.json` file. - -The appropriate template will be selected based on how well its description matches the instruction. - -#### 2. Prefill the Task - -After selecting the template file, it will be opened, and a screenshot will be taken. If the template file is currently in use, errors may occur. - -The screenshot will be sent to the action prefill agent, which will return a modified task. - -#### 3. Filter Task - -The completed task will be evaluated by a filter agent, which will assess it and provide feedback. If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass/`; otherwise, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_fail/`. - -All encountered error messages and tracebacks are saved in `instantiation/tasks/your_folder_name_instantiated/instances_error/`. - - -#### 4. Execute Task - -The instantiated plans will be executed by a execute task. In the execution process, all the implemented steps and the screenshots will be saved, which are shown in `instantiation/log/your_folder_name_instantiated/execute/`. - -If the task is deemed a good instance, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_pass/`; otherwise, it will be saved in `instantiation/tasks/your_folder_name_instantiated/instances_fail/`. - -All encountered error messages and tracebacks are saved in `instantiation/tasks/your_folder_name_instantiated/instances_error/`. -## Notes - -1. Users should be careful to save the original files while using this project; otherwise, the files will be closed when the app is shut down. - -2. After starting the project, users should not close the app window while the program is taking screenshots. diff --git a/instantiation/config/config.yaml.template b/instantiation/config/config.yaml.template deleted file mode 100644 index ecbac7e1..00000000 --- a/instantiation/config/config.yaml.template +++ /dev/null @@ -1,43 +0,0 @@ -# You will configure for the prefill agent and filter agent individualy. -# Prefill agent is used to prefill the task. -# Filter agent is to evaluate the prefill quality. - -PREFILL_AGENT: { - VISUAL_MODE: True, # Whether to use the visual mode - - API_TYPE: "azure_ad" , # The API type, "openai" for the OpenAI API, "aoai" for the AOAI API, 'azure_ad' for the ad authority of the AOAI API. - API_BASE: "https://cloudgpt-openai.azure-api.net/", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. As for the AAD, it should be your endpoints. - API_KEY: "YOUR_API_KEY", # The OpenAI API key - API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default - API_MODEL: "gpt-4o-20240513", # The only OpenAI model by now that accepts visual input - - ###For the AOAI - API_DEPLOYMENT_ID: "gpt-4-0125-preview", # The deployment id for the AOAI API - ### For Azure_AD - AAD_TENANT_ID: "YOUR_AAD_ID", # Set the value to your tenant id for the llm model - AAD_API_SCOPE: "openai", # Set the value to your scope for the llm model - AAD_API_SCOPE_BASE: "YOUR_AAD_API_SCOPE_BASE" # Set the value to your scope base for the llm model, whose format is API://YOUR_SCOPE_BASE, and the only need is the YOUR_SCOPE_BASE -} - -FILTER_AGENT: { - VISUAL_MODE: False, # Whether to use the visual mode - - API_TYPE: "azure_ad" , # The API type, "openai" for the OpenAI API, "aoai" for the Azure OpenAI. - API_BASE: "https://cloudgpt-openai.azure-api.net/", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. As for the aoai, it should be https://{your-resource-name}.openai.azure.com - API_KEY: "YOUR_API_KEY", # The aoai API key - API_VERSION: "2024-04-01-preview", # "2024-02-15-preview" by default - API_MODEL: "gpt-4o-20240513", # The only OpenAI model by now that accepts visual input - API_DEPLOYMENT_ID: "gpt-4o-20240513-preview", # The deployment id for the AOAI API - - ### For Azure_AD - AAD_TENANT_ID: "YOUR_AAD_ID", - AAD_API_SCOPE: "openai", #"openai" - AAD_API_SCOPE_BASE: "YOUR_AAD_API_SCOPE_BASE", #API://YOUR_SCOPE_BASE -} - -# For parameters -MAX_TOKENS: 2000 # The max token limit for the response completion -MAX_RETRY: 3 # The max retry limit for the response completion -TEMPERATURE: 0.0 # The temperature of the model: the lower the value, the more consistent the output of the model -TOP_P: 0.0 # The top_p of the model: the lower the value, the more conservative the output of the model -TIMEOUT: 60 # The call timeout(s), default is 10 minss \ No newline at end of file diff --git a/instantiation/controller/agent/agent.py b/instantiation/controller/agent/agent.py deleted file mode 100644 index f5a21bde..00000000 --- a/instantiation/controller/agent/agent.py +++ /dev/null @@ -1,263 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from typing import Dict, List, Optional - -from instantiation.controller.prompter.agent_prompter import ( - ExecuteEvalAgentPrompter, FilterPrompter, PrefillPrompter) -from ufo.agents.agent.app_agent import AppAgent -from ufo.agents.agent.basic import BasicAgent -from ufo.agents.agent.evaluation_agent import EvaluationAgent - - -class FilterAgent(BasicAgent): - """ - The Agent to evaluate the instantiated task is correct or not. - """ - - def __init__( - self, - name: str, - process_name: str, - is_visual: bool, - main_prompt: str, - example_prompt: str, - api_prompt: str, - ): - """ - Initialize the FilterAgent. - :param name: The name of the agent. - :param process_name: The name of the process. - :param is_visual: The flag indicating whether the agent is visual or not. - :param main_prompt: The main prompt. - :param example_prompt: The example prompt. - :param api_prompt: The API prompt. - """ - - self._step = 0 - self._complete = False - self._name = name - self._status = None - self.prompter: FilterPrompter = self.get_prompter( - is_visual, main_prompt, example_prompt, api_prompt - ) - self._process_name = process_name - - def get_prompter( - self, - is_visual: bool, - main_prompt: str, - example_prompt: str, - api_prompt: str - ) -> FilterPrompter: - """ - Get the prompt for the agent. - :param is_visual: The flag indicating whether the agent is visual or not. - :param main_prompt: The main prompt. - :param example_prompt: The example prompt. - :param api_prompt: The API prompt. - :return: The prompt string. - """ - - return FilterPrompter(is_visual, main_prompt, example_prompt, api_prompt) - - def message_constructor(self, request: str, app: str) -> List[str]: - """ - Construct the prompt message for the FilterAgent. - :param request: The request sentence. - :param app: The name of the operated app. - :return: The prompt message. - """ - - filter_agent_prompt_system_message = self.prompter.system_prompt_construction( - app=app - ) - filter_agent_prompt_user_message = self.prompter.user_content_construction( - request - ) - filter_agent_prompt_message = self.prompter.prompt_construction( - filter_agent_prompt_system_message, filter_agent_prompt_user_message - ) - - return filter_agent_prompt_message - - def process_comfirmation(self) -> None: - """ - Confirm the process. - This is the abstract method from BasicAgent that needs to be implemented. - """ - - pass - - -class PrefillAgent(BasicAgent): - """ - The Agent for task instantialization and action sequence generation. - """ - - def __init__( - self, - name: str, - process_name: str, - is_visual: bool, - main_prompt: str, - example_prompt: str, - api_prompt: str, - ): - """ - Initialize the PrefillAgent. - :param name: The name of the agent. - :param process_name: The name of the process. - :param is_visual: The flag indicating whether the agent is visual or not. - :param main_prompt: The main prompt. - :param example_prompt: The example prompt. - :param api_prompt: The API prompt. - """ - - self._step = 0 - self._complete = False - self._name = name - self._status = None - self.prompter: PrefillPrompter = self.get_prompter( - is_visual, main_prompt, example_prompt, api_prompt - ) - self._process_name = process_name - - def get_prompter(self, is_visual: bool, main_prompt: str, example_prompt: str, api_prompt: str) -> str: - """ - Get the prompt for the agent. - This is the abstract method from BasicAgent that needs to be implemented. - :param is_visual: The flag indicating whether the agent is visual or not. - :param main_prompt: The main prompt. - :param example_prompt: The example prompt. - :param api_prompt: The API prompt. - :return: The prompt string. - """ - - return PrefillPrompter(is_visual, main_prompt, example_prompt, api_prompt) - - def message_constructor( - self, - dynamic_examples: str, - given_task: str, - reference_steps: List[str], - doc_control_state: Dict[str, str], - log_path: str, - ) -> List[str]: - """ - Construct the prompt message for the PrefillAgent. - :param dynamic_examples: The dynamic examples retrieved from the self-demonstration and human demonstration. - :param given_task: The given task. - :param reference_steps: The reference steps. - :param doc_control_state: The document control state. - :param log_path: The path of the log. - :return: The prompt message. - """ - - prefill_agent_prompt_system_message = self.prompter.system_prompt_construction( - dynamic_examples - ) - prefill_agent_prompt_user_message = self.prompter.user_content_construction( - given_task, reference_steps, doc_control_state, log_path - ) - appagent_prompt_message = self.prompter.prompt_construction( - prefill_agent_prompt_system_message, - prefill_agent_prompt_user_message, - ) - - return appagent_prompt_message - - def process_comfirmation(self) -> None: - """ - Confirm the process. - This is the abstract method from BasicAgent that needs to be implemented. - """ - - pass - - -class ExecuteAgent(AppAgent): - """ - The Agent for task execution. - """ - - def __init__( - self, - name: str, - process_name: str, - app_root_name: str, - ): - """ - Initialize the ExecuteAgent. - :param name: The name of the agent. - :param process_name: The name of the process. - :param app_root_name: The name of the app root. - """ - - self._step = 0 - self._complete = False - self._name = name - self._status = None - self._process_name = process_name - self._app_root_name = app_root_name - self.Puppeteer = self.create_puppeteer_interface() - - -class ExecuteEvalAgent(EvaluationAgent): - """ - The Agent for task execution evaluation. - """ - - def __init__( - self, - name: str, - app_root_name: str, - is_visual: bool, - main_prompt: str, - example_prompt: str, - api_prompt: str, - ): - """ - Initialize the ExecuteEvalAgent. - :param name: The name of the agent. - :param app_root_name: The name of the app root. - :param is_visual: The flag indicating whether the agent is visual or not. - :param main_prompt: The main prompt. - :param example_prompt: The example prompt. - :param api_prompt: The API prompt. - """ - - super().__init__( - name=name, - app_root_name=app_root_name, - is_visual=is_visual, - main_prompt=main_prompt, - example_prompt=example_prompt, - api_prompt=api_prompt, - ) - - def get_prompter( - self, - is_visual: bool, - prompt_template: str, - example_prompt_template: str, - api_prompt_template: str, - root_name: Optional[str] = None, - ) -> ExecuteEvalAgentPrompter: - """ - Get the prompter for the agent. - :param is_visual: The flag indicating whether the agent is visual or not. - :param prompt_template: The prompt template. - :param example_prompt_template: The example prompt template. - :param api_prompt_template: The API prompt template. - :param root_name: The name of the root. - :return: The prompter. - """ - - return ExecuteEvalAgentPrompter( - is_visual=is_visual, - prompt_template=prompt_template, - example_prompt_template=example_prompt_template, - api_prompt_template=api_prompt_template, - root_name=root_name, - ) \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/api.yaml b/instantiation/controller/prompts/visual/api.yaml deleted file mode 100644 index e3ba3511..00000000 --- a/instantiation/controller/prompts/visual/api.yaml +++ /dev/null @@ -1,66 +0,0 @@ -Click: - summary: |- - "Click" is to click the control item with mouse. - usage: |- - [1] API call: click_input(button=, double) - [2] Args: - - button: 'The mouse button to click. One of ''left'', ''right'', ''middle'' or ''x'' (Default: ''left'')' - - double: 'Whether to perform a double click or not (Default: False)' - [3] Example: click_input(button="left", double=False) - [4] Available control item: All control items. - [5] Return: None - - -SetText: - summary: |- - "SetText" is to input text to the control item. - usage: |- - [1] API call: set_edit_text(text="") - [2] Args: - - text: The text input to the Edit control item. It will change the content of current text in the edit block. Set text ='' if you want to clear current text in the block. You must also use Double Backslash escape character to escape the single quote in the string argument. - [3] Example: set_edit_text(text="Hello World. \\n I enjoy the reading of the book 'The Lord of the Rings'. It's a great book.") - [4] Available control item: [Edit] - [5] Return: None - -Annotate: - summary: |- - "Annotate" is to take a screenshot of the current application window and annotate the control item on the screenshot. - usage: |- - [1] API call: annotation(control_labels: List[str]=[]) - [2] Args: - - control_labels: The list of annotated label of the control item. If the list is empty, it will annotate all the control items on the screenshot. - [3] Example: annotation(control_labels=["1", "2", "3", "36", "58"]) - [4] Available control item: All control items. - [5] Return: None - -Summary: - summary: |- - "Summary" is to summarize your observation of the current application window base on the clean screenshot. This usually happens when the you need to complete the user request by summarizing or describing the information on the current application window. You must use the 'text' argument to input the summarized text. - usage: |- - [1] API call: summary(text="") - [2] Args: None - [3] Example: summary(text="The image shows a workflow of a AI agent framework. \\n The framework has three components: the 'data collection', the 'data processing' and the 'data analysis'.") - [4] Available control item: All control items. - [5] Return: the summary of the image. - -GetText: - summary: |- - "GetText" is to get the text of the control item. It typical apply to Edit and Document control item when user request is to get the text of the control item. - usage: |- - [1] API call: texts() - [2] Args: None - [3] Example: texts() - [4] All control items. - [5] Return: the text content of the control item. - -Scroll: - summary: |- - "Scroll" is to scroll the control item. It typical apply to a ScrollBar type of control item when user request is to scroll the control item, or the targeted control item is not visible nor available in the control item list, but you know the control item is in the application window and you need to scroll to find it. - usage: |- - [1] API call: wheel_mouse_input() - [2] Args: - - wheel_dist: The distance to scroll. Positive values indicate upward scrolling, negative values indicate downward scrolling. - [3] Example: wheel_mouse_input(wheel_dist=-20) - [4] All control items. - [5] Return: None - \ No newline at end of file diff --git a/instantiation/controller/prompts/visual/execute.yaml b/instantiation/controller/prompts/visual/execute.yaml deleted file mode 100644 index 7d25195f..00000000 --- a/instantiation/controller/prompts/visual/execute.yaml +++ /dev/null @@ -1,26 +0,0 @@ -version: 1.0 - -system: |- - You are a task judge, will be provided with a task in the . You need to judge whether this task can be executed locally. - - ## Evaluation Dimension - The task is only related to {app}. - This task should be like a task, not subjective considerations. For example, if there are 'custom', 'you want' and other situations, they cannot be considered and should return false and be classified as Non_task. Any subjective will crash the system. - This task should specify the element, for example, if there are only 'text' without the specific string, they cannot be considered and should return false and be classified as Non_task. - This task should not involve interactions with other application plug-ins, etc., and only rely on Word. If 'Excel', 'Edge' and other interactions are involved, it should return false and be classified as App_involve. - This task should not involve version updates and other interactions that depend on the environment, but only rely on the current version, and do not want to be upgraded or downgraded. It should return false and be classified as Env. - There are other things that you think cannot be executed or are irrelevant, return False, and be classified as Others - - ## Response Format - Your response should be strictly structured in a JSON format, consisting of three distinct parts with the following keys and corresponding content: - {{ - "judge": true or false depends on you think this task whether can be performed. - "thought": "Outline the reason why you give the judgement." - "type": "None/Non_task/App_involve/Env/Others" - }} - Make sure you answer must be strictly in JSON format only, without other redundant text such as json header. Otherwise it will crash the system. - Below is only a example of the response. Do not fall in the example. - -user: |- - {request} - \ No newline at end of file diff --git a/instantiation/dataflow.py b/instantiation/dataflow.py deleted file mode 100644 index 0ba1d835..00000000 --- a/instantiation/dataflow.py +++ /dev/null @@ -1,99 +0,0 @@ -import argparse -import os -import traceback - -from ufo.utils import print_with_color - - -def parse_args() -> argparse.Namespace: - """ - Parse command-line arguments. - """ - - parser = argparse.ArgumentParser(description="Run task with different execution modes.") - - # Make "mode" optional, with a default value - parser.add_argument( - "--mode", - default="dataflow", - choices=["dataflow", "instantiation", "execution"], - help="Execution mode." - ) - - # Use `--task_path` as an optional argument with a default value - parser.add_argument( - "--task_path", - default="instantiation/tasks/prefill", - help="Path to the task file or directory." - ) - - # Optional flag for batch mode - parser.add_argument( - "--batch", - action="store_true", - help="Run tasks in batch mode (process all files in directory)." - ) - - return parser.parse_args() - - -def process_single_task(task_path: str, mode: str) -> None: - """ - Single task processing. - :param task_path: The path to the task file. - :param mode: The execution mode. - """ - - from instantiation.controller.data_flow_controller import DataFlowController, TaskObject - - try: - flow_controller = DataFlowController(task_path, mode) - flow_controller.run() - except Exception as e: - # Catch exceptions and continue to the next task - print_with_color(f"Error processing {task_path}: {e}", "red") - traceback.print_exc() - raise e - -def process_batch_tasks(task_dir: str, mode: str) -> None: - """ - Batch tasks processing. - :param task_dir: The path to the task directory. - :param mode: The execution mode - """ - - # Get all task files in the directory - task_files = [os.path.join(task_dir, f) for f in os.listdir(task_dir) if os.path.isfile(os.path.join(task_dir, f))] - - for task_file in task_files: - try: - print_with_color(f"Processing {task_file}...", "blue") - - # Process each individual task - process_single_task(task_file, mode) - except Exception: - continue - -def main(): - """ - The main function to run the task. - You can use dataflow, instantiation, and execution modes to process the task. - Also, you can run tasks in batch mode by providing the path to the task directory. - See README to read the detailed usage. - """ - - args = parse_args() - - if args.batch: - if os.path.isdir(args.task_path): - process_batch_tasks(args.task_path, args.mode) - else: - print(f"{args.task_path} is not a valid directory for batch processing.") - else: - if os.path.isfile(args.task_path): - process_single_task(args.task_path, args.mode) - else: - print(f"{args.task_path} is not a valid file.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/instantiation/tasks/prefill/bulleted.json b/instantiation/tasks/prefill/bulleted.json deleted file mode 100644 index 237b68eb..00000000 --- a/instantiation/tasks/prefill/bulleted.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "app": "word", - "unique_id": "5", - "task": "Turning lines of text into a bulleted list in Word", - "refined_steps": [ - "1. Place the cursor at the beginning of the line of text you want to turn into a bulleted list", - "2. Click the Bullets button in the Paragraph group on the Home tab and choose a bullet style" - ] -} \ No newline at end of file diff --git a/instantiation/tasks/prefill/delete.json b/instantiation/tasks/prefill/delete.json deleted file mode 100644 index 73f29eb8..00000000 --- a/instantiation/tasks/prefill/delete.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "app": "word", - "unique_id": "3", - "task": "Deleting undwanted recovered Word files", - "refined_steps": [ - "1. Open the Word document containing the items you wish to delete", - "2. Select and delete the selected text" - ] -} \ No newline at end of file diff --git a/instantiation/tasks/prefill/draw.json b/instantiation/tasks/prefill/draw.json deleted file mode 100644 index 2401260b..00000000 --- a/instantiation/tasks/prefill/draw.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "app": "word", - "unique_id": "1", - "task": "Draw or write your signature in the Word desktop app", - "refined_steps": [ - "1. Select tool", - "2. Draw or write a signature in the Word desktop app", - "3. Use your mouse, pen, or touch screen to draw or write your signature" - ] -} \ No newline at end of file diff --git a/instantiation/tasks/prefill/macro.json b/instantiation/tasks/prefill/macro.json deleted file mode 100644 index 4715a3e6..00000000 --- a/instantiation/tasks/prefill/macro.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "app": "word", - "unique_id": "2", - "task": "Run a macro in Word", - "refined_steps": [ - "1. In the Macro name box that appears, type the name of the macro you want to run", - "2. Click the Run button to execute the selected macro" - ] -} \ No newline at end of file diff --git a/instantiation/tasks/prefill/rotate.json b/instantiation/tasks/prefill/rotate.json deleted file mode 100644 index 2caa5f0b..00000000 --- a/instantiation/tasks/prefill/rotate.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "app": "word", - "unique_id": "4", - "task": "Rotate text in a SmartArt graphic in Word", - "refined_steps": [ - "1. Click the SmartArt graphic to select it", - "2. To rotate the text, click the Rotate button in the Arrange group on the Format tab", - "3. To rotate the text, select the desired rotation option from the drop-down menu" - ] -} \ No newline at end of file From 98ff6596e1102da12add0608413c472fc0dd770a Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Thu, 5 Dec 2024 21:06:07 +0800 Subject: [PATCH 25/30] Fix bugs and modify related explanation. --- README.md | 116 +++++------ assets/dataflow/execution.png | Bin 0 -> 82992 bytes assets/dataflow/instantiation.png | Bin 0 -> 169294 bytes assets/dataflow/overview.png | Bin 0 -> 119901 bytes assets/dataflow/result_example.png | Bin 0 -> 17635 bytes dataflow/.gitignore | 7 +- dataflow/README.md | 187 +++++++++++++++--- dataflow/data_flow_controller.py | 20 +- dataflow/dataflow.py | 130 ++++++------ dataflow/env/env_manager.py | 66 ++++--- dataflow/execution/agent/execute_agent.py | 2 - dataflow/execution/workflow/execute_flow.py | 111 +++++++++-- .../instantiation/workflow/prefill_flow.py | 11 +- dataflow/schema/instantiation_schema.json | 6 +- dataflow/tasks/prefill/bulleted.json | 9 + dataflow/tasks/prefill/watermark.json | 11 ++ dataflow/templates/word/description.json | 4 + dataflow/templates/word/template1.docx | Bin 0 -> 29808 bytes dataflow/templates/word/template2.docx | Bin 0 -> 27146 bytes ufo/agents/processors/app_agent_processor.py | 52 ++--- 20 files changed, 476 insertions(+), 256 deletions(-) create mode 100644 assets/dataflow/execution.png create mode 100644 assets/dataflow/instantiation.png create mode 100644 assets/dataflow/overview.png create mode 100644 assets/dataflow/result_example.png create mode 100644 dataflow/tasks/prefill/bulleted.json create mode 100644 dataflow/tasks/prefill/watermark.json create mode 100644 dataflow/templates/word/description.json create mode 100644 dataflow/templates/word/template1.docx create mode 100644 dataflow/templates/word/template2.docx diff --git a/README.md b/README.md index 60cbad65..cfac2849 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)  [![Documentation](https://img.shields.io/badge/Documentation-%230ABAB5?style=flat&logo=readthedocs&logoColor=black)](https://microsoft.github.io/UFO/)  [![YouTube](https://img.shields.io/badge/YouTube-white?logo=youtube&logoColor=%23FF0000)](https://www.youtube.com/watch?v=QT_OhygMVXU)  + - + + @@ -39,30 +41,31 @@ Both agents leverage the multi-modal capabilities of GPT-Vision to comprehend th - 📅 2024-07-06: We have a **New Release for v1.0.0!**. You can check out our [documentation](https://microsoft.github.io/UFO/). We welcome your contributions and feedback! - 📅 2024-06-28: We are thrilled to announce that our official introduction video is now available on [YouTube](https://www.youtube.com/watch?v=QT_OhygMVXU)! - 📅 2024-06-25: **New Release for v0.2.1!** We are excited to announce the release of version 0.2.1! This update includes several new features and improvements: - 1. **HostAgent Refactor:** We've refactored the HostAgent to enhance its efficiency in managing AppAgents within UFO. - 2. **Evaluation Agent:** Introducing an evaluation agent that assesses task completion and provides real-time feedback. - 3. **Google Gemini Support:** UFO now supports Google Gemini as the inference engine. Refer to our detailed guide in [documentation](https://microsoft.github.io/UFO/supported_models/gemini/). - 4. **Customized User Agents:** Users can now create customized agents by simply answering a few questions. + 1. **HostAgent Refactor:** We've refactored the HostAgent to enhance its efficiency in managing AppAgents within UFO. + 2. **Evaluation Agent:** Introducing an evaluation agent that assesses task completion and provides real-time feedback. + 3. **Google Gemini Support:** UFO now supports Google Gemini as the inference engine. Refer to our detailed guide in [documentation](https://microsoft.github.io/UFO/supported_models/gemini/). + 4. **Customized User Agents:** Users can now create customized agents by simply answering a few questions. - 📅 2024-05-21: We have reached 5K stars!✨ - 📅 2024-05-08: **New Release for v0.1.1!** We've made some significant updates! Previously known as AppAgent and ActAgent, we've rebranded them to HostAgent and AppAgent to better align with their functionalities. Explore the latest enhancements: - 1. **Learning from Human Demonstration:** UFO now supports learning from human demonstration! Utilize the [Windows Step Recorder](https://support.microsoft.com/en-us/windows/record-steps-to-reproduce-a-problem-46582a9b-620f-2e36-00c9-04e25d784e47) to record your steps and demonstrate them for UFO. Refer to our detailed guide in [README.md](https://microsoft.github.io/UFO/creating_app_agent/demonstration_provision/) for more information. - 2. **Win32 Support:** We've incorporated support for [Win32](https://learn.microsoft.com/en-us/windows/win32/controls/window-controls) as a control backend, enhancing our UI automation capabilities. - 3. **Extended Application Interaction:** UFO now goes beyond UI controls, allowing interaction with your application through keyboard inputs and native APIs! Presently, we support Word ([examples](/ufo/prompts/apps/word/api.yaml)), with more to come soon. Customize and build your own interactions. - 4. **Control Filtering:** Streamline LLM's action process by using control filters to remove irrelevant control items. Enable them in [config_dev.yaml](/ufo/config/config_dev.yaml) under the `control filtering` section at the bottom. + 1. **Learning from Human Demonstration:** UFO now supports learning from human demonstration! Utilize the [Windows Step Recorder](https://support.microsoft.com/en-us/windows/record-steps-to-reproduce-a-problem-46582a9b-620f-2e36-00c9-04e25d784e47) to record your steps and demonstrate them for UFO. Refer to our detailed guide in [README.md](https://microsoft.github.io/UFO/creating_app_agent/demonstration_provision/) for more information. + 2. **Win32 Support:** We've incorporated support for [Win32](https://learn.microsoft.com/en-us/windows/win32/controls/window-controls) as a control backend, enhancing our UI automation capabilities. + 3. **Extended Application Interaction:** UFO now goes beyond UI controls, allowing interaction with your application through keyboard inputs and native APIs! Presently, we support Word ([examples](/ufo/prompts/apps/word/api.yaml)), with more to come soon. Customize and build your own interactions. + 4. **Control Filtering:** Streamline LLM's action process by using control filters to remove irrelevant control items. Enable them in [config_dev.yaml](/ufo/config/config_dev.yaml) under the `control filtering` section at the bottom. - 📅 2024-03-25: **New Release for v0.0.1!** Check out our exciting new features. - 1. We now support creating your help documents for each Windows application to become an app expert. Check the [documentation](https://microsoft.github.io/UFO/creating_app_agent/help_document_provision/) for more details! - 2. UFO now supports RAG from offline documents and online Bing search. - 3. You can save the task completion trajectory into its memory for UFO's reference, improving its future success rate! - 4. You can customize different GPT models for HostAgent and AppAgent. Text-only models (e.g., GPT-4) are now supported! + 1. We now support creating your help documents for each Windows application to become an app expert. Check the [documentation](https://microsoft.github.io/UFO/creating_app_agent/help_document_provision/) for more details! + 2. UFO now supports RAG from offline documents and online Bing search. + 3. You can save the task completion trajectory into its memory for UFO's reference, improving its future success rate! + 4. You can customize different GPT models for HostAgent and AppAgent. Text-only models (e.g., GPT-4) are now supported! - 📅 2024-02-14: Our [technical report](https://arxiv.org/abs/2402.07939) is online! - 📅 2024-02-10: UFO is released on GitHub🎈. Happy Chinese New year🐉! -## 🌐 Media Coverage +## 🌐 Media Coverage UFO sightings have garnered attention from various media outlets, including: -- [Microsoft's UFO abducts traditional user interfaces for a smarter Windows experience](https://the-decoder.com/microsofts-ufo-abducts-traditional-user-interfaces-for-a-smarter-windows-experience/) -- [🚀 UFO & GPT-4-V: Sit back and relax, mientras GPT lo hace todo🌌](https://www.linkedin.com/posts/gutierrezfrancois_ai-ufo-microsoft-activity-7176819900399652865-pLoo?utm_source=share&utm_medium=member_desktop) + +- [Microsoft's UFO abducts traditional user interfaces for a smarter Windows experience](https://the-decoder.com/microsofts-ufo-abducts-traditional-user-interfaces-for-a-smarter-windows-experience/) +- [🚀 UFO & GPT-4-V: Sit back and relax, mientras GPT lo hace todo🌌](https://www.linkedin.com/posts/gutierrezfrancois_ai-ufo-microsoft-activity-7176819900399652865-pLoo?utm_source=share&utm_medium=member_desktop) - [The AI PC - The Future of Computers? - Microsoft UFO](https://www.youtube.com/watch?v=1k4LcffCq3E) - [下一代Windows系统曝光:基于GPT-4V,Agent跨应用调度,代号UFO](https://baijiahao.baidu.com/s?id=1790938358152188625&wfr=spider&for=pc) - [下一代智能版 Windows 要来了?微软推出首个 Windows Agent,命名为 UFO!](https://blog.csdn.net/csdnnews/article/details/136161570) @@ -71,22 +74,21 @@ UFO sightings have garnered attention from various media outlets, including: These sources provide insights into the evolving landscape of technology and the implications of UFO phenomena on various platforms. - ## 💥 Highlights -- [x] **First Windows Agent** - UFO is the pioneering agent framework capable of translating user requests in natural language into actionable operations on Windows OS. -- [x] **Agent as an Expert** - UFO is enhanced by Retrieval Augmented Generation (RAG) from heterogeneous sources, including offline help documents, online search engines, and human demonstrations, making the agent an application "expert". -- [x] **Rich Skill Set** - UFO is equipped with a diverse set of skills to support comprehensive automation, such as mouse, keyboard, native API, and "Copilot". -- [x] **Interactive Mode** - UFO facilitates multiple sub-requests from users within the same session, enabling the seamless completion of complex tasks. -- [x] **Agent Customization** - UFO allows users to customize their own agents by providing additional information. The agent will proactively query users for details when necessary to better tailor its behavior. -- [x] **Scalable AppAgent Creation** - UFO offers extensibility, allowing users and app developers to create their own AppAgents in an easy and scalable way. - +- [X] **First Windows Agent** - UFO is the pioneering agent framework capable of translating user requests in natural language into actionable operations on Windows OS. +- [X] **Agent as an Expert** - UFO is enhanced by Retrieval Augmented Generation (RAG) from heterogeneous sources, including offline help documents, online search engines, and human demonstrations, making the agent an application "expert". +- [X] **Rich Skill Set** - UFO is equipped with a diverse set of skills to support comprehensive automation, such as mouse, keyboard, native API, and "Copilot". +- [X] **Interactive Mode** - UFO facilitates multiple sub-requests from users within the same session, enabling the seamless completion of complex tasks. +- [X] **Agent Customization** - UFO allows users to customize their own agents by providing additional information. The agent will proactively query users for details when necessary to better tailor its behavior. +- [X] **Scalable AppAgent Creation** - UFO offers extensibility, allowing users and app developers to create their own AppAgents in an easy and scalable way. ## ✨ Getting Started - ### 🛠️ Step 1: Installation + UFO requires **Python >= 3.10** running on **Windows OS >= 10**. It can be installed by running the following command: + ```bash # [optional to create conda environment] # conda create -n ufo python=3.10 @@ -101,10 +103,11 @@ pip install -r requirements.txt ``` ### ⚙️ Step 2: Configure the LLMs -Before running UFO, you need to provide your LLM configurations **individually for HostAgent and AppAgent**. You can create your own config file `ufo/config/config.yaml`, by copying the `ufo/config/config.yaml.template` and editing config for **HOST_AGENT** and **APP_AGENT** as follows: +Before running UFO, you need to provide your LLM configurations **individually for HostAgent and AppAgent**. You can create your own config file `ufo/config/config.yaml`, by copying the `ufo/config/config.yaml.template` and editing config for **HOST_AGENT** and **APP_AGENT** as follows: #### OpenAI + ```bash VISUAL_MODE: True, # Whether to use the visual mode API_TYPE: "openai" , # The API type, "openai" for the OpenAI API. @@ -115,6 +118,7 @@ API_MODEL: "gpt-4-vision-preview", # The only OpenAI model ``` #### Azure OpenAI (AOAI) + ```bash VISUAL_MODE: True, # Whether to use the visual mode API_TYPE: "aoai" , # The API type, "aoai" for the Azure OpenAI. @@ -124,24 +128,28 @@ API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default API_MODEL: "gpt-4-vision-preview", # The only OpenAI model API_DEPLOYMENT_ID: "YOUR_AOAI_DEPLOYMENT", # The deployment id for the AOAI API ``` + You can also non-visial model (e.g., GPT-4) for each agent, by setting `VISUAL_MODE: False` and proper `API_MODEL` (openai) and `API_DEPLOYMENT_ID` (aoai). You can also optionally set an backup LLM engine in the field of `BACKUP_AGENT` if the above engines failed during the inference. +#### Non-Visual Model Configuration -#### Non-Visual Model Configuration You can utilize non-visual models (e.g., GPT-4) for each agent by configuring the following settings in the `config.yaml` file: -- ```VISUAL_MODE: False # To enable non-visual mode.``` +- ``VISUAL_MODE: False # To enable non-visual mode.`` - Specify the appropriate `API_MODEL` (OpenAI) and `API_DEPLOYMENT_ID` (AOAI) for each agent. Optionally, you can set a backup language model (LLM) engine in the `BACKUP_AGENT` field to handle cases where the primary engines fail during inference. Ensure you configure these settings accurately to leverage non-visual models effectively. -#### NOTE 💡 +#### NOTE 💡 + UFO also supports other LLMs and advanced configurations, such as customize your own model, please check the [documents](https://microsoft.github.io/UFO/supported_models/overview/) for more details. Because of the limitations of model input, a lite version of the prompt is provided to allow users to experience it, which is configured in `config_dev.yaml`. ### 📔 Step 3: Additional Setting for RAG (optional). -If you want to enhance UFO's ability with external knowledge, you can optionally configure it with an external database for retrieval augmented generation (RAG) in the `ufo/config/config.yaml` file. + +If you want to enhance UFO's ability with external knowledge, you can optionally configure it with an external database for retrieval augmented generation (RAG) in the `ufo/config/config.yaml` file. We provide the following options for RAG to enhance UFO's capabilities: + - [Offline Help Document](https://microsoft.github.io/UFO/advanced_usage/reinforce_appagent/learning_from_help_document/) Enable UFO to retrieve information from offline help documents. - [Online Bing Search Engine](https://microsoft.github.io/UFO/advanced_usage/reinforce_appagent/learning_from_bing_search/): Enhance UFO's capabilities by utilizing the most up-to-date online search results. - [Self-Experience](https://microsoft.github.io/UFO/advanced_usage/reinforce_appagent/experience_learning/): Save task completion trajectories into UFO's memory for future reference. @@ -196,7 +204,6 @@ RAG_DEMONSTRATION: True # Whether to use the RAG from its user demonstration. RAG_DEMONSTRATION_RETRIEVED_TOPK: 5 # The topk for the demonstration examples. ``` --> - ### 🎉 Step 4: Start UFO #### ⌨️ You can execute the following on your Windows command Line (CLI): @@ -206,7 +213,7 @@ RAG_DEMONSTRATION_RETRIEVED_TOPK: 5 # The topk for the demonstration examples. python -m ufo --task ``` -This will start the UFO process and you can interact with it through the command line interface. +This will start the UFO process and you can interact with it through the command line interface. If everything goes well, you will see the following message: ```bash @@ -218,24 +225,28 @@ Welcome to use UFO🛸, A UI-focused Agent for Windows OS Interaction. \___/ |_| \___/ Please enter your request to be completed🛸: ``` -#### ⚠️Reminder: #### + +#### ⚠️Reminder: + - Before UFO executing your request, please make sure the targeted applications are active on the system. - The GPT-V accepts screenshots of your desktop and application GUI as input. Please ensure that no sensitive or confidential information is visible or captured during the execution process. For further information, refer to [DISCLAIMER.md](./DISCLAIMER.md). - -### Step 5 🎥: Execution Logs +### Step 5 🎥: Execution Logs You can find the screenshots taken and request & response logs in the following folder: + ``` ./ufo/logs// ``` + You may use them to debug, replay, or analyze the agent output. +## ❓Get help -## ❓Get help * Please first check our our documentation [here](https://microsoft.github.io/UFO/). * ❔GitHub Issues (prefered) * For other communications, please contact [ufo-agent@microsoft.com](mailto:ufo-agent@microsoft.com). + --- ## 🎬 Demo Examples @@ -243,21 +254,17 @@ You may use them to debug, replay, or analyze the agent output. We present two demo videos that complete user request on Windows OS using UFO. For more case study, please consult our [technical report](https://arxiv.org/abs/2402.07939). #### 1️⃣🗑️ Example 1: Deleting all notes on a PowerPoint presentation. -In this example, we will demonstrate how to efficiently use UFO to delete all notes on a PowerPoint presentation with just a few simple steps. Explore this functionality to enhance your productivity and work smarter, not harder! +In this example, we will demonstrate how to efficiently use UFO to delete all notes on a PowerPoint presentation with just a few simple steps. Explore this functionality to enhance your productivity and work smarter, not harder! https://github.com/microsoft/UFO/assets/11352048/cf60c643-04f7-4180-9a55-5fb240627834 - - #### 2️⃣📧 Example 2: Composing an email using text from multiple sources. -In this example, we will demonstrate how to utilize UFO to extract text from Word documents, describe an image, compose an email, and send it seamlessly. Enjoy the versatility and efficiency of cross-application experiences with UFO! +In this example, we will demonstrate how to utilize UFO to extract text from Word documents, describe an image, compose an email, and send it seamlessly. Enjoy the versatility and efficiency of cross-application experiences with UFO! https://github.com/microsoft/UFO/assets/11352048/aa41ad47-fae7-4334-8e0b-ba71c4fc32e0 - - ## 📊 Evaluation Please consult the [WindowsBench](https://arxiv.org/pdf/2402.07939.pdf) provided in Section A of the Appendix within our technical report. Here are some tips (and requirements) to aid in completing your request: @@ -265,11 +272,11 @@ Please consult the [WindowsBench](https://arxiv.org/pdf/2402.07939.pdf) provided - Prior to UFO execution of your request, ensure that the targeted application is active (though it may be minimized). - Please note that the output of GPT-V may not consistently align with the same request. If unsuccessful with your initial attempt, consider trying again. - - ## 📚 Citation + Our technical report paper can be found [here](https://arxiv.org/abs/2402.07939). Note that previous AppAgent and ActAgent in the paper are renamed to HostAgent and AppAgent in the code base to better reflect their functions. If you use UFO in your research, please cite our paper: + ``` @article{ufo, title={{UFO: A UI-Focused Agent for Windows OS Interaction}}, @@ -280,26 +287,25 @@ If you use UFO in your research, please cite our paper: ``` ## 📝 Todo List -- [x] RAG enhanced UFO. -- [x] Support more control using Win32 API. -- [x] [Documentation](https://microsoft.github.io/UFO/). + +- [X] RAG enhanced UFO. +- [X] Support more control using Win32 API. +- [X] [Documentation](https://microsoft.github.io/UFO/). - [ ] Support local host GUI interaction model. - [ ] Chatbox GUI for UFO. - - ## 🎨 Related Project -You may also find [TaskWeaver](https://github.com/microsoft/TaskWeaver?tab=readme-ov-file) useful, a code-first LLM agent framework for seamlessly planning and executing data analytics tasks. +You may also find [TaskWeaver](https://github.com/microsoft/TaskWeaver?tab=readme-ov-file) useful, a code-first LLM agent framework for seamlessly planning and executing data analytics tasks. ## ⚠️ Disclaimer -By choosing to run the provided code, you acknowledge and agree to the following terms and conditions regarding the functionality and data handling practices in [DISCLAIMER.md](./DISCLAIMER.md) +By choosing to run the provided code, you acknowledge and agree to the following terms and conditions regarding the functionality and data handling practices in [DISCLAIMER.md](./DISCLAIMER.md) -## logo Trademarks +## `logo` Trademarks -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft -trademarks or logos is subject to and must follow -[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/assets/dataflow/execution.png b/assets/dataflow/execution.png new file mode 100644 index 0000000000000000000000000000000000000000..1d962afbae100ea731b3c55b37b520bd12863f28 GIT binary patch literal 82992 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 S4$g!y|1>os7#8U}hW-btm3=V) literal 0 HcmV?d00001 diff --git a/assets/dataflow/instantiation.png b/assets/dataflow/instantiation.png new file mode 100644 index 0000000000000000000000000000000000000000..f975178f25b0d3852b901c1a0d08c04e42c92f80 GIT binary patch literal 169294 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

@W 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

8R1=-qJzNSy!`I&47uK1wU3o3O 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 z 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 b!NzvZIQ4JR>P^k$H>+_>M>T_V_QC%FS9$7+ literal 0 HcmV?d00001 diff --git a/assets/dataflow/result_example.png b/assets/dataflow/result_example.png new file mode 100644 index 0000000000000000000000000000000000000000..accaf5bf7442860352724a2dab9815e9bcae963e GIT binary patch literal 17635 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 WoUHQ&{)7eyBrYr?R4SnR?f(HA|G4`A literal 0 HcmV?d00001 diff --git a/dataflow/.gitignore b/dataflow/.gitignore index a8da84cc..ef4cbe6b 100644 --- a/dataflow/.gitignore +++ b/dataflow/.gitignore @@ -1,10 +1,5 @@ # Ignore files cache/ controls_cache/ -tasks/* -!tasks/prefill -templates/word/* -logs/* controller/utils/ -config/config.yaml -tasks/prefill \ No newline at end of file +config/config.yaml \ No newline at end of file diff --git a/dataflow/README.md b/dataflow/README.md index 058863f3..5f83df2b 100644 --- a/dataflow/README.md +++ b/dataflow/README.md @@ -8,7 +8,11 @@ Dataflow uses UFO to implement `instantiation`, `execution`, and `dataflow` for You can use `instantiation` and `execution` independently if you only need to perform one specific part of the process. When both steps are required for a task, the `dataflow` process streamlines them, allowing you to execute tasks from start to finish in a single pipeline. -## HOW TO USE +The overall processing of dataflow is as below. Given a task-plan data, the LLMwill instantiatie the task-action data, including choosing template, prefill, filter. + +![](../assets\dataflow\overview.png) + +## How To Use ### 1. Install Packages @@ -65,8 +69,7 @@ Additionally, for each app folder, there should be a `description.json` file loc ```json { "template1.docx": "A document with a rectangle shape", - "template2.docx": "A document with a line of text", - "template3.docx": "A document with a chart" + "template2.docx": "A document with a line of text" } ``` @@ -105,53 +108,45 @@ dataflow/ └── ... ``` -### 4. How To Use +### 4. Start Running -After finishing the previous steps, you can use the following commands in the command line. We provide single / batch process, for which you need to give the single file path / folder path. +After finishing the previous steps, you can use the following commands in the command line. We provide single / batch process, for which you need to give the single file path / folder path. Determine the type of path provided by the user and automatically decide whether to process a single task or batch tasks. Also, you can choose to use `instantiation` / `execution` sections individually, or use them as a whole section, which is named as `dataflow`. -1. **Single Task Processing** +The default task hub is set to be `"TASKS_HUB"` in `dataflow/config_dev.yaml`. -- Dataflow Task: - ```bash - python -m dataflow single dataflow /task_dir/task_file_name +1. Dataflow Task: + +- ```bash + python -m dataflow -dataflow --task_path path_to_task_file ``` * Instantiation Task: + ```bash - python -m dataflow single instantiation /task_dir/task_file_name + python -m dataflow -instantiation --task_path path_to_task_file ``` * Execution Task: - ```bash - python -m dataflow single execution /task_dir/task_file_name - ``` - -2. **Batch Task Processing** -* Dataflow Task Batch: ```bash - python -m dataflow batch dataflow /path/to/task_dir/ - ``` -* Instantiation Task Batch: - ```bash - python -m dataflow batch instantiation /path/to/task_dir/ - ``` -* Execution Task Batch: - ```bash - python -m dataflow batch execution /path/to/task_dir/ + python -m dataflow -execution --task_path path_to_task_file ``` ## Workflow ### Instantiation -There are four key steps in the instantiation process: +There are three key steps in the instantiation process: 1. `Choose a template` file according to the specified app and instruction. 2. `Prefill` the task using the current screenshot. 3. `Filter` the established task. +Given the initial task, the dataflow first choose a template (`Phase 1`), the prefill the initial task based on word envrionment to obtain task-action data (`Phase 2`). Finnally, it will filter the established task to evaluate the quality of task-action data. + +![](../assets\dataflow\instantiation.png) + #### 1. Choose Template File Templates for your app must be defined and described in `dataflow/templates/app`. For instance, if you want to instantiate tasks for the Word application, place the relevant `.docx` files in dataflow `/templates/word `, along with a `description.json` file. @@ -172,6 +167,10 @@ The completed task will be evaluated by a filter agent, which will assess it and The instantiated plans will be executed by a execute task. After execution, evalution agent will evaluation the quality of the entire execution process. +In this phase, given the task-action data, the execution process will match the real controller based on word environment and execute the plan step by step. + +![](../assets\dataflow\execution.png) + ## Result The structure of the results of the task is as below: @@ -297,6 +296,142 @@ his section illustrates the structure of the result of the task, organized in a } ``` +## Quick Start + +We prepare two cases to show the dataflow, which can be found in `dataflow\tasks\prefill`. So after installing required packages, you can type the following command in the command line: + +``` +python -m dataflow -dataflow +``` + +And you can see the hints showing in the terminal, which means the dataflow is working. + +### Structure of related files + +After the two tasks are finished, the task and output files would appear as follows: + +UFO/ +├── dataflow/ +│ └── results/ +│ ├── saved_document/ # Directory for saved documents +│ │ ├── bulleted.docx # Result of the "bulleted" task +│ │ └── rotate.docx # Result of the "rotate" task +│ ├── dataflow/ # Dataflow results directory +│ │ ├── execution_pass/ # Successfully executed tasks +│ │ │ ├── bulleted.json # Execution result for the "bulleted" task +│ │ │ ├── rotate.json # Execution result for the "rotate" task +│ │ │ └── ... +└── ... + +### Result files + +The result stucture of bulleted task is shown as below. This document provides a detailed breakdown of the task execution process for turning lines of text into a bulleted list in Word. It includes the original task description, execution results, and time analysis for each step. + +* **`unique_id`** : The identifier for the task, in this case, `"5"`. +* **`app`** : The application being used, which is `"word"`. +* **`original`** : Contains the original task description and the steps. + + * **`original_task`** : Describes the task in simple terms (turning text into a bulleted list). + * **`original_steps`** : Lists the steps required to perform the task. +* **`execution_result`** : Provides the result of executing the task. + + * **`result`** : Describes the outcome of the execution, including a success message and sub-scores for each part of the task. The `complete: "yes"` means the evaluation agent think the execution process is successful! The `sub_score` is the evaluation of each subtask, corresponding to the ` instantiated_plan` in the `prefill`. + * **`error`** : If any error occurred during execution, it would be reported here, but it's `null` in this case. +* **`instantiation_result`** : Details the instantiation of the task (setting up the task for execution). + + * **`choose_template`** : Path to the template or document created during the task (in this case, the bulleted list document). + * **`prefill`** : Describes the `instantiated_request` and `instantiated_plan` and the steps involved, such as selecting text and clicking buttons, which is the result of prefill flow. The `Success` and `MatchedControlText` is added in the execution process. **`Success`** indicates whether the subtask was executed successfully. **`MatchedControlText`** refers to the control text that was matched during the execution process based on the plan. + * **`instantiation_evaluation`** : Provides feedback on the task's feasibility and the evaluation of the request, which is result of the filter flow. **`"judge": true`** : This indicates that the evaluation of the task was positive, meaning the task is considered valid or successfully judged. And the `thought ` is the detailed reason. +* **`time_cost`** : The time spent on different parts of the task, including template selection, prefill, instantiation evaluation, and execution. Total time is also given. + +This structure follows your description and provides the necessary details in a consistent format. + +```json +{ + "unique_id": "5", + "app": "word", + "original": { + "original_task": "Turning lines of text into a bulleted list in Word", + "original_steps": [ + "1. Place the cursor at the beginning of the line of text you want to turn into a bulleted list", + "2. Click the Bullets button in the Paragraph group on the Home tab and choose a bullet style" + ] + }, + "execution_result": { + "result": { + "reason": "The agent successfully selected the text 'text to edit' and then clicked on the 'Bullets' button in the Word application. The final screenshot shows that the text 'text to edit' has been converted into a bulleted list.", + "sub_scores": { + "text selection": "yes", + "bulleted list conversion": "yes" + }, + "complete": "yes" + }, + "error": null + }, + "instantiation_result": { + "choose_template": { + "result": "dataflow\\results\\saved_document\\bulleted.docx", + "error": null + }, + "prefill": { + "result": { + "instantiated_request": "Turn the line of text 'text to edit' into a bulleted list in Word.", + "instantiated_plan": [ + { + "Step": 1, + "Subtask": "Place the cursor at the beginning of the text 'text to edit'", + "ControlLabel": null, + "ControlText": "", + "Function": "select_text", + "Args": { + "text": "text to edit" + }, + "Success": true, + "MatchedControlText": null + }, + { + "Step": 2, + "Subtask": "Click the Bullets button in the Paragraph group on the Home tab", + "ControlLabel": "61", + "ControlText": "Bullets", + "Function": "click_input", + "Args": { + "button": "left", + "double": false + }, + "Success": true, + "MatchedControlText": "Bullets" + } + ] + }, + "error": null + }, + "instantiation_evaluation": { + "result": { + "judge": true, + "thought": "The task is specific and involves a basic function in Word that can be executed locally without any external dependencies.", + "request_type": "None" + }, + "error": null + } + }, + "time_cost": { + "choose_template": 0.012, + "prefill": 15.649, + "instantiation_evaluation": 2.469, + "execute": 5.824, + "execute_eval": 8.702, + "total": 43.522 + } +} +``` + +### Log files + +The corresponding logs can be found in the directories `logs/bulleted` and `logs/rotate`, as shown below. Detailed logs for each workflow are recorded, capturing every step of the execution process. + +![img](../assets\dataflow\result_example.png) + ## Notes 1. Users should be careful to save the original files while using this project; otherwise, the files will be closed when the app is shut down. diff --git a/dataflow/data_flow_controller.py b/dataflow/data_flow_controller.py index fb5fb923..16ec679c 100644 --- a/dataflow/data_flow_controller.py +++ b/dataflow/data_flow_controller.py @@ -2,7 +2,7 @@ import time import traceback from enum import Enum -from typing import Any, Dict +from typing import Any, Dict, Optional, List from jsonschema import validate, ValidationError from dataflow.env.env_manager import WindowsAppEnv @@ -131,7 +131,7 @@ def __init__(self, task_path: str, task_type: str) -> None: self.task_info = self.init_task_info() self.result_hub = _configs["RESULT_HUB"].format(task_type=task_type) - def init_task_info(self) -> dict: + def init_task_info(self) -> Dict[str, Any]: """ Initialize the task information. :return: The initialized task information. @@ -158,7 +158,7 @@ def init_task_info(self) -> dict: } return init_task_info - def _load_schema(self, task_type: str) -> dict: + def _load_schema(self, task_type: str) -> Dict[str, Any]: """ load the schema based on the task_type. :param task_type: The task_type of the task object (dataflow, instantiation, or execution). @@ -170,9 +170,10 @@ def _load_schema(self, task_type: str) -> dict: elif task_type == "execution" or task_type == "dataflow": return load_json_file(_configs["EXECUTION_RESULT_SCHEMA"]) - def execute_instantiation(self): + def execute_instantiation(self) -> Optional[List[Dict[str, Any]]]: """ Execute the instantiation process. + :return: The instantiation plan if successful. """ print_with_color(f"Instantiating task {self.task_object.task_file_name}...", "blue") @@ -191,6 +192,7 @@ def execute_instantiation(self): init_params=[self.app_env], execute_params=[template_copied_path, self.task_object.task, self.task_object.refined_steps] ) + self.app_env.close() if prefill_result: self.instantiation_single_flow( @@ -200,7 +202,7 @@ def execute_instantiation(self): ) return prefill_result["instantiated_plan"] - def execute_execution(self, request: str, plan: dict) -> None: + def execute_execution(self, request: str, plan: Dict[str, any]) -> None: """ Execute the execution process. :param request: The task request to be executed. @@ -253,7 +255,7 @@ def instantiation_single_flow( flow_type: str, init_params=None, execute_params=None - ) -> Any: + ) -> Optional[Dict[str, Any]]: """ Execute a single flow process in the instantiation phase. :param flow_class: The flow class to instantiate. @@ -337,7 +339,7 @@ def template_copied_path(self) -> str: return self.task_info["instantiation_result"]["choose_template"]["result"] @property - def instantiated_plan(self) -> list[dict[str, Any]]: + def instantiated_plan(self) -> List[Dict[str, Any]]: """ Get the instantiated plan from the task information. :return: The instantiated plan. @@ -346,7 +348,7 @@ def instantiated_plan(self) -> list[dict[str, Any]]: return self.task_info["instantiation_result"]["prefill"]["result"]["instantiated_plan"] @instantiated_plan.setter - def instantiated_plan(self, value: list[dict[str, Any]]) -> None: + def instantiated_plan(self, value: List[Dict[str, Any]]) -> None: """ Set the instantiated plan in the task information. :param value: New value for the instantiated plan. @@ -379,8 +381,6 @@ def run(self) -> None: raise e finally: - if self.app_env: - self.app_env.close() # Update or record the total time cost of the process total_time = round(time.time() - start_time, 3) new_total_time = self.task_info.get("time_cost", {}).get("total", 0) + total_time diff --git a/dataflow/dataflow.py b/dataflow/dataflow.py index f2935f83..54a24548 100644 --- a/dataflow/dataflow.py +++ b/dataflow/dataflow.py @@ -3,117 +3,90 @@ import traceback from ufo.utils import print_with_color from dataflow.config.config import Config + _configs = Config.get_instance().config_data def parse_args() -> argparse.Namespace: """ - Parse command-line arguments for single and batch task processing. - :return: Namespace of argparse + Parse command-line arguments. Automatically detect batch or single mode. """ - parser = argparse.ArgumentParser( - description="Run task with different execution modes." - ) - - # Subparsers for different modes - subparsers = parser.add_subparsers( - title="commands", - description="Choose between single or batch task processing modes.", - dest="command", - required=True, # Force the user to choose one subcommand + parser = argparse.ArgumentParser(description="Run tasks automatically in single or batch mode.") + + # Add options for -dataflow, -instantiation, and -execution + parser.add_argument( + "-dataflow", + action="store_const", const="dataflow", + help="Indicates that the task type is dataflow." ) - - # Single task processing - single_parser = subparsers.add_parser( - "single", help="Process a single task file." - ) - single_parser.add_argument( - "task_type", - choices=["dataflow", "instantiation", "execution"], - help="Execution task_type for the task.", + parser.add_argument( + "-instantiation", + action="store_const", const="instantiation", + help="Indicates that the task type is instantiation." ) - single_parser.add_argument( - "task_path", - type=str, - help="Path to the task file.", + parser.add_argument( + "-execution", + action="store_const", const="execution", + help="Indicates that the task type is execution." ) - # Batch task processing - batch_parser = subparsers.add_parser( - "batch", help="Process all tasks in a directory." - ) - batch_parser.add_argument( - "task_type", - default="dataflow", - choices=["dataflow", "instantiation", "execution"], - help="Execution task_type for the tasks.", - ) - batch_parser.add_argument( - "task_dir", - default=_configs["TASKS_HUB"], + # Task path argument + parser.add_argument( + "--task_path", type=str, - help="Path to the directory containing task files.", + default=_configs["TASKS_HUB"], + help="Path to the task file or directory.", ) return parser.parse_args() -def validate_path(path: str, path_type: str) -> bool: +def validate_path(path: str) -> str: """ - Validate the given path based on type. + Validate the given path and determine its type. :param path: The path to validate. - :param path_type: Type of path: "file" or "directory". - :return: True if the path is valid, otherwise False. + :return: "file", "directory", or raises an error if invalid. """ - if path_type == "file": - if not os.path.isfile(path): - print_with_color(f"Invalid file path: {path}", "red") - return False - elif path_type == "directory": - if not os.path.isdir(path): - print_with_color(f"Invalid directory path: {path}", "red") - return False - return True + if os.path.isfile(path): + return "file" + elif os.path.isdir(path): + return "directory" + else: + print_with_color(f"Invalid path: {path}", "red") + raise ValueError(f"Path {path} is neither a file nor a directory.") -def process_single_task(task_path: str, task_type: str) -> None: +def process_task(task_path: str, task_type: str) -> None: """ - Single task processing. - :param task_path: The path to the task file. - :param task_type: The type of task to process. + Process a single task file using the DataFlowController. """ - from dataflow.data_flow_controller import DataFlowController try: - print_with_color(f"Starting processing for task: {task_path}", "green") + print_with_color(f"Processing task: {task_path}", "green") flow_controller = DataFlowController(task_path, task_type) flow_controller.run() - print_with_color(f"Task {task_path} completed successfully!", "green") + print_with_color(f"Task {task_path} completed successfully.", "green") except Exception as e: - print_with_color(f"Error processing {task_path}: {e}", "red") + print_with_color(f"Error processing {task_path}: {traceback.format_exc()}", "red") + -def process_batch_tasks(task_dir: str, task_type: str) -> None: +def process_batch(task_dir: str, task_type: str) -> None: """ - Batch tasks processing. - :param task_dir: The path to the task directory. - :param task_type: The type of task to process. + Process all task files in a directory. """ - task_files = [ os.path.join(task_dir, f) for f in os.listdir(task_dir) if os.path.isfile(os.path.join(task_dir, f)) ] - if not task_files: - print_with_color(f"No task files found in directory: {task_dir}", "yellow") + print_with_color(f"No tasks found in directory: {task_dir}.", "yellow") return print_with_color(f"Found {len(task_files)} tasks in {task_dir}.", "blue") for task_file in task_files: - print_with_color(f"Processing {task_file}...", "blue") - process_single_task(task_file, task_type) + process_task(task_file, task_type) def main(): @@ -125,12 +98,19 @@ def main(): """ args = parse_args() - if args.command == "single": - if validate_path(args.task_path, "file"): - process_single_task(args.task_path, args.task_type) - elif args.command == "batch": - if validate_path(args.task_dir, "directory"): - process_batch_tasks(args.task_dir, args.task_type) + # Ensure that a task type has been provided; if not, raise an error + if not any([args.dataflow, args.instantiation, args.execution]): + print_with_color("Error: You must specify one of the task types (-dataflow, -instantiation, or -execution).", "red") + return + + task_type = args.dataflow or args.instantiation or args.execution + + path_type = validate_path(args.task_path) + + if path_type == "file": + process_task(args.task_path, task_type) + elif path_type == "directory": + process_batch(args.task_path, task_type) if __name__ == "__main__": diff --git a/dataflow/env/env_manager.py b/dataflow/env/env_manager.py index e0e186f7..d1ade291 100644 --- a/dataflow/env/env_manager.py +++ b/dataflow/env/env_manager.py @@ -1,8 +1,7 @@ import logging -from multiprocessing import process import re from time import sleep -from typing import Optional +from typing import Optional, Tuple, Dict import psutil from fuzzywuzzy import fuzz @@ -10,11 +9,15 @@ from pywinauto.controls.uiawrapper import UIAWrapper from dataflow.config.config import Config +from ufo.config.config import Config as UFOConfig # Load configuration settings _configs = Config.get_instance().config_data +_ufo_configs = UFOConfig.get_instance().config_data + +if _ufo_configs is not None: + _BACKEND = _ufo_configs["CONTROL_BACKEND"] if _configs is not None: - _BACKEND = _configs["CONTROL_BACKEND"] _MATCH_STRATEGY = _configs.get("MATCH_STRATEGY", "contains") @@ -130,35 +133,46 @@ def _match_window_name(self, window_title: str, doc_name: str) -> bool: logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") - def is_matched_controller( - self, control_to_match: UIAWrapper, control_text: str - ) -> bool: + def _calculate_match_score(self, control, control_text) -> int: """ - Matches the controller based on the strategy specified in the config file. - :param control_to_match: The control object to match against. - :param control_text: The text content of the control for additional context. - :return: True if a match is found based on the strategy; False otherwise. + Calculate the match score between a control and the given text. + :param control: The control object to evaluate. + :param control_text: The target text to match. + :return: An integer score representing the match quality (higher is better). """ + control_content = control.window_text() or "" - control_content = ( - control_to_match.window_text() if control_to_match.window_text() else "" - ) # Default to empty string - - # Match strategies based on the configured _MATCH_STRATEGY + # Matching strategies if _MATCH_STRATEGY == "contains": - return ( - control_text in control_content - ) # Check if the control's content contains the target text + return 100 if control_text in control_content else 0 elif _MATCH_STRATEGY == "fuzzy": - # Fuzzy matching to compare the content - similarity_text = fuzz.partial_ratio(control_content, control_text) - return similarity_text >= 70 # Set a threshold for fuzzy matching + return fuzz.partial_ratio(control_content, control_text) elif _MATCH_STRATEGY == "regex": - # Use regular expressions for more flexible matching pattern = re.compile(f"{re.escape(control_text)}", flags=re.IGNORECASE) - return ( - re.search(pattern, control_content) is not None - ) # Return True if pattern matches control content + return 100 if re.search(pattern, control_content) else 0 else: - logging.exception(f"Unknown match strategy: {_MATCH_STRATEGY}") raise ValueError(f"Unknown match strategy: {_MATCH_STRATEGY}") + + def find_matching_controller(self, filtered_annotation_dict: Dict[int, UIAWrapper], control_text: str) -> Tuple[str, UIAWrapper]: + """" + Select the best matched controller. + :param filtered_annotation_dict: The filtered annotation dictionary. + :param control_text: The text content of the control for additional context. + :return: Tuple containing the key of the selected controller and the control object.s + """ + control_selected = None + controller_key = None + highest_score = 0 + + # Iterate through the filtered annotation dictionary to find the best match + for key, control in filtered_annotation_dict.items(): + # Calculate the matching score using the match function + score = self._calculate_match_score(control, control_text) + + # Update the selected control if the score is higher + if score > highest_score: + highest_score = score + controller_key = key + control_selected = control + + return controller_key, control_selected diff --git a/dataflow/execution/agent/execute_agent.py b/dataflow/execution/agent/execute_agent.py index ff95ec9e..00e522fc 100644 --- a/dataflow/execution/agent/execute_agent.py +++ b/dataflow/execution/agent/execute_agent.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from typing import Dict, List, Optional - from ufo.agents.agent.app_agent import AppAgent diff --git a/dataflow/execution/workflow/execute_flow.py b/dataflow/execution/workflow/execute_flow.py index 8a3c8ad5..cf3928e1 100644 --- a/dataflow/execution/workflow/execute_flow.py +++ b/dataflow/execution/workflow/execute_flow.py @@ -8,13 +8,15 @@ from dataflow.execution.agent.execute_eval_agent import ExecuteEvalAgent from ufo import utils from ufo.agents.processors.app_agent_processor import AppAgentProcessor +from ufo.automator.app_apis.basic import WinCOMReceiverBasic from ufo.config.config import Config as UFOConfig from ufo.module.basic import BaseSession, Context, ContextNames +from ufo.automator.ui_control.screenshot import PhotographerDecorator _configs = InstantiationConfig.get_instance().config_data _ufo_configs = UFOConfig.get_instance().config_data -if _configs is not None: - BACKEND = _configs["CONTROL_BACKEND"] +if _ufo_configs is not None: + BACKEND = _ufo_configs["CONTROL_BACKEND"] class ExecuteFlow(AppAgentProcessor): @@ -137,17 +139,19 @@ def execute_plan( # Initialize the step counter and capture the initial screenshot. self.session_step = 0 try: + time.sleep(1) # Initialize the API receiver self.app_agent.Puppeteer.receiver_manager.create_api_receiver( self.app_agent._app_root_name, self.app_agent._process_name ) # Initialize the control receiver - current_receiver = self.app_agent.Puppeteer.receiver_manager.receiver_list[0] + current_receiver = self.app_agent.Puppeteer.receiver_manager.receiver_list[-1] + if current_receiver is not None: self.application_window = self._app_env.find_matching_window(self._task_file_name) current_receiver.com_object = current_receiver.get_object_from_process_name() - self.init_capture_screenshot() + self.init_and_final_capture_screenshot() except Exception as error: raise RuntimeError(f"Execution initialization failed. {error}") @@ -171,7 +175,6 @@ def execute_plan( instantiated_plan[index]["Success"] = True instantiated_plan[index]["ControlLabel"] = self._control_label instantiated_plan[index]["MatchedControlText"] = self._matched_control - except Exception as ControllerNotFoundError: instantiated_plan[index]["Success"] = False raise ControllerNotFoundError @@ -181,6 +184,23 @@ def execute_plan( f"Step {self.session_step} execution failed. {error}" ) raise err_info + # capture the final screenshot + self.session_step += 1 + time.sleep(1) + self.init_and_final_capture_screenshot() + # save the final state of the app + + win_com_receiver = None + for receiver in reversed(self.app_agent.Puppeteer.receiver_manager.receiver_list): + if isinstance(receiver, WinCOMReceiverBasic): + if receiver.client is not None: + win_com_receiver = receiver + break + + if win_com_receiver is not None: + win_com_receiver.save() + time.sleep(1) + win_com_receiver.client.Quit() print("Execution complete.") @@ -194,7 +214,6 @@ def process(self) -> None: step_start_time = time.time() self.print_step_info() self.capture_screenshot() - self.select_controller() self.execute_action() self.time_cost = round(time.time() - step_start_time, 3) self.log_save() @@ -251,22 +270,8 @@ def _parse_step_plan(self, step_plan: Dict[str, Any]) -> None: self.status = step_plan.get("Status", "") - def select_controller(self) -> None: - """ - Select the controller. - """ - if self.control_text == "": - return - for key, control in self.filtered_annotation_dict.items(): - if self._app_env.is_matched_controller(control, self.control_text): - self._control_label = key - self._matched_control = control.window_text() - return - # If the control is not found, raise an error. - raise RuntimeError(f"Control with text '{self.control_text}' not found.") - - def init_capture_screenshot(self) -> None: + def init_and_final_capture_screenshot(self) -> None: """ Capture the screenshot. """ @@ -286,7 +291,71 @@ def init_capture_screenshot(self) -> None: # Capture the control screenshot. control_selected = self._app_env.app_window self.capture_control_screenshot(control_selected) + + def execute_action(self) -> None: + """ + Execute the action. + """ + + control_selected = None + # Find the matching window and control. + self.application_window = self._app_env.find_matching_window(self._task_file_name) + if self.control_text == "": + control_selected = self.application_window + else: + self._control_label, control_selected = self._app_env.find_matching_controller( + self.filtered_annotation_dict, self.control_text + ) + self._matched_control = control_selected.window_text() + + if not control_selected: + # If the control is not found, raise an error. + raise RuntimeError(f"Control with text '{self.control_text}' not found.") + + try: + # Get the selected control item from the annotation dictionary and LLM response. + # The LLM response is a number index corresponding to the key in the annotation dictionary. + if control_selected: + + if _ufo_configs.get("SHOW_VISUAL_OUTLINE_ON_SCREEN", True): + control_selected.draw_outline(colour="red", thickness=3) + time.sleep(_ufo_configs.get("RECTANGLE_TIME", 0)) + + control_coordinates = PhotographerDecorator.coordinate_adjusted( + self.application_window.rectangle(), control_selected.rectangle() + ) + + self._control_log = { + "control_class": control_selected.element_info.class_name, + "control_type": control_selected.element_info.control_type, + "control_automation_id": control_selected.element_info.automation_id, + "control_friendly_class_name": control_selected.friendly_class_name(), + "control_coordinates": { + "left": control_coordinates[0], + "top": control_coordinates[1], + "right": control_coordinates[2], + "bottom": control_coordinates[3], + }, + } + + self.app_agent.Puppeteer.receiver_manager.create_ui_control_receiver( + control_selected, self.application_window + ) + + # Save the screenshot of the tagged selected control. + self.capture_control_screenshot(control_selected) + + self._results = self.app_agent.Puppeteer.execute_command( + self._operation, self._args + ) + self.control_reannotate = None + if not utils.is_json_serializable(self._results): + self._results = "" + + return + except Exception: + self.general_error_handler() def general_error_handler(self) -> None: """ diff --git a/dataflow/instantiation/workflow/prefill_flow.py b/dataflow/instantiation/workflow/prefill_flow.py index c03109a5..6ff05ba9 100644 --- a/dataflow/instantiation/workflow/prefill_flow.py +++ b/dataflow/instantiation/workflow/prefill_flow.py @@ -11,11 +11,12 @@ from ufo.automator.ui_control.inspector import ControlInspectorFacade from ufo.automator.ui_control.screenshot import PhotographerFacade from ufo.module.basic import BaseSession +from ufo.config.config import Config as UFOConfig -# Load configuration data _configs = Config.get_instance().config_data -if _configs: - _BACKEND = _configs["CONTROL_BACKEND"] +_ufo_configs = UFOConfig.get_instance().config_data +if _ufo_configs is not None: + _BACKEND = _ufo_configs["CONTROL_BACKEND"] class PrefillFlow(AppAgentProcessor): @@ -142,8 +143,8 @@ def _update_state(self, file_path: str) -> None: # Retrieve control elements in the app window control_list = self._control_inspector.find_control_elements_in_descendants( self._app_env.app_window, - control_type_list=_configs["CONTROL_LIST"], - class_name_list=_configs["CONTROL_LIST"], + control_type_list=_ufo_configs["CONTROL_LIST"], + class_name_list=_ufo_configs["CONTROL_LIST"], ) # Capture UI control annotations diff --git a/dataflow/schema/instantiation_schema.json b/dataflow/schema/instantiation_schema.json index 9e29dcbc..6a132151 100644 --- a/dataflow/schema/instantiation_schema.json +++ b/dataflow/schema/instantiation_schema.json @@ -90,11 +90,9 @@ "choose_template": { "type": ["number", "null"] }, "prefill":{ "type": ["number", "null"] }, "instantiation_evaluation": { "type": ["number", "null"] }, - "total": { "type": ["number", "null"] }, - "execute": { "type": ["number", "null"] }, - "execute_eval": { "type": ["number", "null"] } + "total": { "type": ["number", "null"] } }, - "required": ["choose_template", "prefill", "instantiation_evaluation", "total", "execute", "execute_eval"] + "required": ["choose_template", "prefill", "instantiation_evaluation", "total"] } }, "required": ["unique_id", "app", "original", "execution_result", "instantiation_result", "time_cost"] diff --git a/dataflow/tasks/prefill/bulleted.json b/dataflow/tasks/prefill/bulleted.json new file mode 100644 index 00000000..237b68eb --- /dev/null +++ b/dataflow/tasks/prefill/bulleted.json @@ -0,0 +1,9 @@ +{ + "app": "word", + "unique_id": "5", + "task": "Turning lines of text into a bulleted list in Word", + "refined_steps": [ + "1. Place the cursor at the beginning of the line of text you want to turn into a bulleted list", + "2. Click the Bullets button in the Paragraph group on the Home tab and choose a bullet style" + ] +} \ No newline at end of file diff --git a/dataflow/tasks/prefill/watermark.json b/dataflow/tasks/prefill/watermark.json new file mode 100644 index 00000000..ef14611f --- /dev/null +++ b/dataflow/tasks/prefill/watermark.json @@ -0,0 +1,11 @@ +{ + "app": "word", + "unique_id": "108", + "task": "Put a watermark on all pages in Word for Office 365", + "refined_steps": [ + "1.On the **Design** tab, select **Watermark**.", + "2.Select **Custom Watermark**.", + "3.Choose **Text watermark** and type your text in the **Text** box.", + "4.Select **OK**." + ] +} \ No newline at end of file diff --git a/dataflow/templates/word/description.json b/dataflow/templates/word/description.json new file mode 100644 index 00000000..375c17d8 --- /dev/null +++ b/dataflow/templates/word/description.json @@ -0,0 +1,4 @@ +{ + "template1.docx": "A doc with a rectangle shape. Can be moved, resized, or deleted.", + "template2.docx": "A doc with a line of text. Can be edited, deleted, or replaced." +} diff --git a/dataflow/templates/word/template1.docx b/dataflow/templates/word/template1.docx new file mode 100644 index 0000000000000000000000000000000000000000..3133c897aed2061d1ff6120caaee62302af0f19b GIT binary patch literal 29808 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 z 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 ARR910 literal 0 HcmV?d00001 diff --git a/ufo/agents/processors/app_agent_processor.py b/ufo/agents/processors/app_agent_processor.py index d607a8d9..7dffbfe1 100644 --- a/ufo/agents/processors/app_agent_processor.py +++ b/ufo/agents/processors/app_agent_processor.py @@ -292,37 +292,34 @@ def execute_action(self) -> None: Execute the action. """ - control_selected = self._annotation_dict.get(self._control_label, None) + control_selected = self._annotation_dict.get(self._control_label, "") try: # Get the selected control item from the annotation dictionary and LLM response. # The LLM response is a number index corresponding to the key in the annotation dictionary. - if self._operation: + if control_selected: if configs.get("SHOW_VISUAL_OUTLINE_ON_SCREEN", True): control_selected.draw_outline(colour="red", thickness=3) time.sleep(configs.get("RECTANGLE_TIME", 0)) - if control_selected: - control_coordinates = PhotographerDecorator.coordinate_adjusted( - self.application_window.rectangle(), - control_selected.rectangle(), - ) - self._control_log = { - "control_class": control_selected.element_info.class_name, - "control_type": control_selected.element_info.control_type, - "control_automation_id": control_selected.element_info.automation_id, - "control_friendly_class_name": control_selected.friendly_class_name(), - "control_coordinates": { - "left": control_coordinates[0], - "top": control_coordinates[1], - "right": control_coordinates[2], - "bottom": control_coordinates[3], - }, - } - else: - self._control_log = {} + control_coordinates = PhotographerDecorator.coordinate_adjusted( + self.application_window.rectangle(), control_selected.rectangle() + ) + + self._control_log = { + "control_class": control_selected.element_info.class_name, + "control_type": control_selected.element_info.control_type, + "control_automation_id": control_selected.element_info.automation_id, + "control_friendly_class_name": control_selected.friendly_class_name(), + "control_coordinates": { + "left": control_coordinates[0], + "top": control_coordinates[1], + "right": control_coordinates[2], + "bottom": control_coordinates[3], + }, + } self.app_agent.Puppeteer.receiver_manager.create_ui_control_receiver( control_selected, self.application_window @@ -331,10 +328,13 @@ def execute_action(self) -> None: # Save the screenshot of the tagged selected control. self.capture_control_screenshot(control_selected) - self._results = self.app_agent.Puppeteer.execute_command( - self._operation, self._args - ) - self.control_reannotate = None + if self.status.upper() == self._agent_status_manager.SCREENSHOT.value: + self.handle_screenshot_status() + else: + self._results = self.app_agent.Puppeteer.execute_command( + self._operation, self._args + ) + self.control_reannotate = None if not utils.is_json_serializable(self._results): self._results = "" @@ -437,7 +437,7 @@ def _update_image_blackboard(self) -> None: """ Save the screenshot to the blackboard if the SaveScreenshot flag is set to True by the AppAgent. """ - screenshot_saving = self._response.get("SaveScreenshot", {}) + screenshot_saving = self._response_json.get("SaveScreenshot", {}) if screenshot_saving.get("save", False): From b035815ef69b44fea742435056a5531c06773571 Mon Sep 17 00:00:00 2001 From: vyokky <7678676@qq.com> Date: Sun, 8 Dec 2024 18:32:47 +0800 Subject: [PATCH 26/30] merge main --- analysis/parsing.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 analysis/parsing.py diff --git a/analysis/parsing.py b/analysis/parsing.py new file mode 100644 index 00000000..0349a440 --- /dev/null +++ b/analysis/parsing.py @@ -0,0 +1 @@ +import json From 2edbbb4ba6b42e80527a47ca6a951ae915aafed8 Mon Sep 17 00:00:00 2001 From: vyokky <7678676@qq.com> Date: Sun, 8 Dec 2024 18:34:31 +0800 Subject: [PATCH 27/30] rm ignore --- analysis/parsing.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 analysis/parsing.py diff --git a/analysis/parsing.py b/analysis/parsing.py deleted file mode 100644 index 0349a440..00000000 --- a/analysis/parsing.py +++ /dev/null @@ -1 +0,0 @@ -import json From c3b88b97806ae80443e70f142ccd8d9758b7df5a Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Sun, 8 Dec 2024 20:43:19 +0800 Subject: [PATCH 28/30] cancel the revisions of ufo code --- ufo/agents/agent/app_agent.py | 6 +-- ufo/agents/agent/basic.py | 2 +- ufo/agents/processors/app_agent_processor.py | 40 ++++++++++---------- ufo/automator/puppeteer.py | 2 +- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/ufo/agents/agent/app_agent.py b/ufo/agents/agent/app_agent.py index ed84441f..cf44f0da 100644 --- a/ufo/agents/agent/app_agent.py +++ b/ufo/agents/agent/app_agent.py @@ -37,7 +37,7 @@ def __init__( skip_prompter: bool = False, ) -> None: """ - Initialize the AppAgent.TODO:simplify related init + Initialize the AppAgent. :name: The name of the agent. :param process_name: The process name of the app. :param app_root_name: The root name of the app. @@ -58,7 +58,7 @@ def __init__( self.online_doc_retriever = None self.experience_retriever = None self.human_demonstration_retriever = None - self.Puppeteer = self.create_puppeteer_interface() + self.Puppeteer = self.create_puppteer_interface() self.set_state(ContinueAppAgentState()) def get_prompter( @@ -294,7 +294,7 @@ def process(self, context: Context) -> None: self.processor.process() self.status = self.processor.status - def create_puppeteer_interface(self) -> puppeteer.AppPuppeteer: + def create_puppteer_interface(self) -> puppeteer.AppPuppeteer: """ Create the Puppeteer interface to automate the app. :return: The Puppeteer interface. diff --git a/ufo/agents/agent/basic.py b/ufo/agents/agent/basic.py index a32eca0b..1f54e08e 100644 --- a/ufo/agents/agent/basic.py +++ b/ufo/agents/agent/basic.py @@ -98,7 +98,7 @@ def blackboard(self) -> Blackboard: """ return self.host.blackboard - def create_puppeteer_interface(self) -> puppeteer.AppPuppeteer: + def create_puppteer_interface(self) -> puppeteer.AppPuppeteer: """ Create the puppeteer interface. """ diff --git a/ufo/agents/processors/app_agent_processor.py b/ufo/agents/processors/app_agent_processor.py index 7dffbfe1..bc6aa896 100644 --- a/ufo/agents/processors/app_agent_processor.py +++ b/ufo/agents/processors/app_agent_processor.py @@ -11,7 +11,6 @@ from ufo import utils from ufo.agents.processors.basic import BaseProcessor -from ufo.agents.states.basic import AgentStatus from ufo.automator.ui_control.screenshot import PhotographerDecorator from ufo.automator.ui_control.control_filter import ControlFilterFactory from ufo.config.config import Config @@ -292,34 +291,37 @@ def execute_action(self) -> None: Execute the action. """ - control_selected = self._annotation_dict.get(self._control_label, "") + control_selected = self._annotation_dict.get(self._control_label, None) try: # Get the selected control item from the annotation dictionary and LLM response. # The LLM response is a number index corresponding to the key in the annotation dictionary. - if control_selected: + if self._operation: if configs.get("SHOW_VISUAL_OUTLINE_ON_SCREEN", True): control_selected.draw_outline(colour="red", thickness=3) time.sleep(configs.get("RECTANGLE_TIME", 0)) - control_coordinates = PhotographerDecorator.coordinate_adjusted( - self.application_window.rectangle(), control_selected.rectangle() - ) - - self._control_log = { - "control_class": control_selected.element_info.class_name, - "control_type": control_selected.element_info.control_type, - "control_automation_id": control_selected.element_info.automation_id, - "control_friendly_class_name": control_selected.friendly_class_name(), - "control_coordinates": { - "left": control_coordinates[0], - "top": control_coordinates[1], - "right": control_coordinates[2], - "bottom": control_coordinates[3], - }, - } + if control_selected: + control_coordinates = PhotographerDecorator.coordinate_adjusted( + self.application_window.rectangle(), + control_selected.rectangle(), + ) + self._control_log = { + "control_class": control_selected.element_info.class_name, + "control_type": control_selected.element_info.control_type, + "control_automation_id": control_selected.element_info.automation_id, + "control_friendly_class_name": control_selected.friendly_class_name(), + "control_coordinates": { + "left": control_coordinates[0], + "top": control_coordinates[1], + "right": control_coordinates[2], + "bottom": control_coordinates[3], + }, + } + else: + self._control_log = {} self.app_agent.Puppeteer.receiver_manager.create_ui_control_receiver( control_selected, self.application_window diff --git a/ufo/automator/puppeteer.py b/ufo/automator/puppeteer.py index 88e15d22..cd92f1f8 100644 --- a/ufo/automator/puppeteer.py +++ b/ufo/automator/puppeteer.py @@ -236,7 +236,7 @@ def get_receiver_from_command_name(self, command_name: str) -> ReceiverBasic: :param command_name: The command name. :return: The mapped receiver. """ - receiver = self.receiver_registry.get(command_name, None)#select text, click input, etc. + receiver = self.receiver_registry.get(command_name, None) if receiver is None: raise ValueError(f"Receiver for command {command_name} is not found.") return receiver From f77971a8970562e85b5304717417679fe7447310 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Sun, 8 Dec 2024 21:08:46 +0800 Subject: [PATCH 29/30] Cancel format revision of readme and revise the config_dev.yaml of dataflow --- README.md | 116 +++++++++++++++----------------- dataflow/config/config_dev.yaml | 4 -- 2 files changed, 55 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index cfac2849..dc745478 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,8 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)  [![Documentation](https://img.shields.io/badge/Documentation-%230ABAB5?style=flat&logo=readthedocs&logoColor=black)](https://microsoft.github.io/UFO/)  [![YouTube](https://img.shields.io/badge/YouTube-white?logo=youtube&logoColor=%23FF0000)](https://www.youtube.com/watch?v=QT_OhygMVXU)  - - - + @@ -41,31 +39,30 @@ Both agents leverage the multi-modal capabilities of GPT-Vision to comprehend th - 📅 2024-07-06: We have a **New Release for v1.0.0!**. You can check out our [documentation](https://microsoft.github.io/UFO/). We welcome your contributions and feedback! - 📅 2024-06-28: We are thrilled to announce that our official introduction video is now available on [YouTube](https://www.youtube.com/watch?v=QT_OhygMVXU)! - 📅 2024-06-25: **New Release for v0.2.1!** We are excited to announce the release of version 0.2.1! This update includes several new features and improvements: - 1. **HostAgent Refactor:** We've refactored the HostAgent to enhance its efficiency in managing AppAgents within UFO. - 2. **Evaluation Agent:** Introducing an evaluation agent that assesses task completion and provides real-time feedback. - 3. **Google Gemini Support:** UFO now supports Google Gemini as the inference engine. Refer to our detailed guide in [documentation](https://microsoft.github.io/UFO/supported_models/gemini/). - 4. **Customized User Agents:** Users can now create customized agents by simply answering a few questions. + 1. **HostAgent Refactor:** We've refactored the HostAgent to enhance its efficiency in managing AppAgents within UFO. + 2. **Evaluation Agent:** Introducing an evaluation agent that assesses task completion and provides real-time feedback. + 3. **Google Gemini && Claude Support:** UFO now supports Google Gemini and Cluade as the inference engine. Refer to our detailed guide in [Gemini documentation](https://microsoft.github.io/UFO/supported_models/gemini/) or [Claude documentation](https://microsoft.github.io/UFO/supported_models/claude/). + 4. **Customized User Agents:** Users can now create customized agents by simply answering a few questions. - 📅 2024-05-21: We have reached 5K stars!✨ - 📅 2024-05-08: **New Release for v0.1.1!** We've made some significant updates! Previously known as AppAgent and ActAgent, we've rebranded them to HostAgent and AppAgent to better align with their functionalities. Explore the latest enhancements: - 1. **Learning from Human Demonstration:** UFO now supports learning from human demonstration! Utilize the [Windows Step Recorder](https://support.microsoft.com/en-us/windows/record-steps-to-reproduce-a-problem-46582a9b-620f-2e36-00c9-04e25d784e47) to record your steps and demonstrate them for UFO. Refer to our detailed guide in [README.md](https://microsoft.github.io/UFO/creating_app_agent/demonstration_provision/) for more information. - 2. **Win32 Support:** We've incorporated support for [Win32](https://learn.microsoft.com/en-us/windows/win32/controls/window-controls) as a control backend, enhancing our UI automation capabilities. - 3. **Extended Application Interaction:** UFO now goes beyond UI controls, allowing interaction with your application through keyboard inputs and native APIs! Presently, we support Word ([examples](/ufo/prompts/apps/word/api.yaml)), with more to come soon. Customize and build your own interactions. - 4. **Control Filtering:** Streamline LLM's action process by using control filters to remove irrelevant control items. Enable them in [config_dev.yaml](/ufo/config/config_dev.yaml) under the `control filtering` section at the bottom. + 1. **Learning from Human Demonstration:** UFO now supports learning from human demonstration! Utilize the [Windows Step Recorder](https://support.microsoft.com/en-us/windows/record-steps-to-reproduce-a-problem-46582a9b-620f-2e36-00c9-04e25d784e47) to record your steps and demonstrate them for UFO. Refer to our detailed guide in [README.md](https://microsoft.github.io/UFO/creating_app_agent/demonstration_provision/) for more information. + 2. **Win32 Support:** We've incorporated support for [Win32](https://learn.microsoft.com/en-us/windows/win32/controls/window-controls) as a control backend, enhancing our UI automation capabilities. + 3. **Extended Application Interaction:** UFO now goes beyond UI controls, allowing interaction with your application through keyboard inputs and native APIs! Presently, we support Word ([examples](/ufo/prompts/apps/word/api.yaml)), with more to come soon. Customize and build your own interactions. + 4. **Control Filtering:** Streamline LLM's action process by using control filters to remove irrelevant control items. Enable them in [config_dev.yaml](/ufo/config/config_dev.yaml) under the `control filtering` section at the bottom. - 📅 2024-03-25: **New Release for v0.0.1!** Check out our exciting new features. - 1. We now support creating your help documents for each Windows application to become an app expert. Check the [documentation](https://microsoft.github.io/UFO/creating_app_agent/help_document_provision/) for more details! - 2. UFO now supports RAG from offline documents and online Bing search. - 3. You can save the task completion trajectory into its memory for UFO's reference, improving its future success rate! - 4. You can customize different GPT models for HostAgent and AppAgent. Text-only models (e.g., GPT-4) are now supported! + 1. We now support creating your help documents for each Windows application to become an app expert. Check the [documentation](https://microsoft.github.io/UFO/creating_app_agent/help_document_provision/) for more details! + 2. UFO now supports RAG from offline documents and online Bing search. + 3. You can save the task completion trajectory into its memory for UFO's reference, improving its future success rate! + 4. You can customize different GPT models for HostAgent and AppAgent. Text-only models (e.g., GPT-4) are now supported! - 📅 2024-02-14: Our [technical report](https://arxiv.org/abs/2402.07939) is online! - 📅 2024-02-10: UFO is released on GitHub🎈. Happy Chinese New year🐉! -## 🌐 Media Coverage +## 🌐 Media Coverage UFO sightings have garnered attention from various media outlets, including: - -- [Microsoft's UFO abducts traditional user interfaces for a smarter Windows experience](https://the-decoder.com/microsofts-ufo-abducts-traditional-user-interfaces-for-a-smarter-windows-experience/) -- [🚀 UFO & GPT-4-V: Sit back and relax, mientras GPT lo hace todo🌌](https://www.linkedin.com/posts/gutierrezfrancois_ai-ufo-microsoft-activity-7176819900399652865-pLoo?utm_source=share&utm_medium=member_desktop) +- [Microsoft's UFO abducts traditional user interfaces for a smarter Windows experience](https://the-decoder.com/microsofts-ufo-abducts-traditional-user-interfaces-for-a-smarter-windows-experience/) +- [🚀 UFO & GPT-4-V: Sit back and relax, mientras GPT lo hace todo🌌](https://www.linkedin.com/posts/gutierrezfrancois_ai-ufo-microsoft-activity-7176819900399652865-pLoo?utm_source=share&utm_medium=member_desktop) - [The AI PC - The Future of Computers? - Microsoft UFO](https://www.youtube.com/watch?v=1k4LcffCq3E) - [下一代Windows系统曝光:基于GPT-4V,Agent跨应用调度,代号UFO](https://baijiahao.baidu.com/s?id=1790938358152188625&wfr=spider&for=pc) - [下一代智能版 Windows 要来了?微软推出首个 Windows Agent,命名为 UFO!](https://blog.csdn.net/csdnnews/article/details/136161570) @@ -74,21 +71,22 @@ UFO sightings have garnered attention from various media outlets, including: These sources provide insights into the evolving landscape of technology and the implications of UFO phenomena on various platforms. + ## 💥 Highlights -- [X] **First Windows Agent** - UFO is the pioneering agent framework capable of translating user requests in natural language into actionable operations on Windows OS. -- [X] **Agent as an Expert** - UFO is enhanced by Retrieval Augmented Generation (RAG) from heterogeneous sources, including offline help documents, online search engines, and human demonstrations, making the agent an application "expert". -- [X] **Rich Skill Set** - UFO is equipped with a diverse set of skills to support comprehensive automation, such as mouse, keyboard, native API, and "Copilot". -- [X] **Interactive Mode** - UFO facilitates multiple sub-requests from users within the same session, enabling the seamless completion of complex tasks. -- [X] **Agent Customization** - UFO allows users to customize their own agents by providing additional information. The agent will proactively query users for details when necessary to better tailor its behavior. -- [X] **Scalable AppAgent Creation** - UFO offers extensibility, allowing users and app developers to create their own AppAgents in an easy and scalable way. +- [x] **First Windows Agent** - UFO is the pioneering agent framework capable of translating user requests in natural language into actionable operations on Windows OS. +- [x] **Agent as an Expert** - UFO is enhanced by Retrieval Augmented Generation (RAG) from heterogeneous sources, including offline help documents, online search engines, and human demonstrations, making the agent an application "expert". +- [x] **Rich Skill Set** - UFO is equipped with a diverse set of skills to support comprehensive automation, such as mouse, keyboard, native API, and "Copilot". +- [x] **Interactive Mode** - UFO facilitates multiple sub-requests from users within the same session, enabling the seamless completion of complex tasks. +- [x] **Agent Customization** - UFO allows users to customize their own agents by providing additional information. The agent will proactively query users for details when necessary to better tailor its behavior. +- [x] **Scalable AppAgent Creation** - UFO offers extensibility, allowing users and app developers to create their own AppAgents in an easy and scalable way. + ## ✨ Getting Started -### 🛠️ Step 1: Installation +### 🛠️ Step 1: Installation UFO requires **Python >= 3.10** running on **Windows OS >= 10**. It can be installed by running the following command: - ```bash # [optional to create conda environment] # conda create -n ufo python=3.10 @@ -103,11 +101,10 @@ pip install -r requirements.txt ``` ### ⚙️ Step 2: Configure the LLMs +Before running UFO, you need to provide your LLM configurations **individually for HostAgent and AppAgent**. You can create your own config file `ufo/config/config.yaml`, by copying the `ufo/config/config.yaml.template` and editing config for **HOST_AGENT** and **APP_AGENT** as follows: -Before running UFO, you need to provide your LLM configurations **individually for HostAgent and AppAgent**. You can create your own config file `ufo/config/config.yaml`, by copying the `ufo/config/config.yaml.template` and editing config for **HOST_AGENT** and **APP_AGENT** as follows: #### OpenAI - ```bash VISUAL_MODE: True, # Whether to use the visual mode API_TYPE: "openai" , # The API type, "openai" for the OpenAI API. @@ -118,7 +115,6 @@ API_MODEL: "gpt-4-vision-preview", # The only OpenAI model ``` #### Azure OpenAI (AOAI) - ```bash VISUAL_MODE: True, # Whether to use the visual mode API_TYPE: "aoai" , # The API type, "aoai" for the Azure OpenAI. @@ -128,28 +124,24 @@ API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default API_MODEL: "gpt-4-vision-preview", # The only OpenAI model API_DEPLOYMENT_ID: "YOUR_AOAI_DEPLOYMENT", # The deployment id for the AOAI API ``` - You can also non-visial model (e.g., GPT-4) for each agent, by setting `VISUAL_MODE: False` and proper `API_MODEL` (openai) and `API_DEPLOYMENT_ID` (aoai). You can also optionally set an backup LLM engine in the field of `BACKUP_AGENT` if the above engines failed during the inference. -#### Non-Visual Model Configuration +#### Non-Visual Model Configuration You can utilize non-visual models (e.g., GPT-4) for each agent by configuring the following settings in the `config.yaml` file: -- ``VISUAL_MODE: False # To enable non-visual mode.`` +- ```VISUAL_MODE: False # To enable non-visual mode.``` - Specify the appropriate `API_MODEL` (OpenAI) and `API_DEPLOYMENT_ID` (AOAI) for each agent. Optionally, you can set a backup language model (LLM) engine in the `BACKUP_AGENT` field to handle cases where the primary engines fail during inference. Ensure you configure these settings accurately to leverage non-visual models effectively. -#### NOTE 💡 - +#### NOTE 💡 UFO also supports other LLMs and advanced configurations, such as customize your own model, please check the [documents](https://microsoft.github.io/UFO/supported_models/overview/) for more details. Because of the limitations of model input, a lite version of the prompt is provided to allow users to experience it, which is configured in `config_dev.yaml`. ### 📔 Step 3: Additional Setting for RAG (optional). - -If you want to enhance UFO's ability with external knowledge, you can optionally configure it with an external database for retrieval augmented generation (RAG) in the `ufo/config/config.yaml` file. +If you want to enhance UFO's ability with external knowledge, you can optionally configure it with an external database for retrieval augmented generation (RAG) in the `ufo/config/config.yaml` file. We provide the following options for RAG to enhance UFO's capabilities: - - [Offline Help Document](https://microsoft.github.io/UFO/advanced_usage/reinforce_appagent/learning_from_help_document/) Enable UFO to retrieve information from offline help documents. - [Online Bing Search Engine](https://microsoft.github.io/UFO/advanced_usage/reinforce_appagent/learning_from_bing_search/): Enhance UFO's capabilities by utilizing the most up-to-date online search results. - [Self-Experience](https://microsoft.github.io/UFO/advanced_usage/reinforce_appagent/experience_learning/): Save task completion trajectories into UFO's memory for future reference. @@ -204,6 +196,7 @@ RAG_DEMONSTRATION: True # Whether to use the RAG from its user demonstration. RAG_DEMONSTRATION_RETRIEVED_TOPK: 5 # The topk for the demonstration examples. ``` --> + ### 🎉 Step 4: Start UFO #### ⌨️ You can execute the following on your Windows command Line (CLI): @@ -213,7 +206,7 @@ RAG_DEMONSTRATION_RETRIEVED_TOPK: 5 # The topk for the demonstration examples. python -m ufo --task ``` -This will start the UFO process and you can interact with it through the command line interface. +This will start the UFO process and you can interact with it through the command line interface. If everything goes well, you will see the following message: ```bash @@ -225,28 +218,24 @@ Welcome to use UFO🛸, A UI-focused Agent for Windows OS Interaction. \___/ |_| \___/ Please enter your request to be completed🛸: ``` - -#### ⚠️Reminder: - +#### ⚠️Reminder: #### - Before UFO executing your request, please make sure the targeted applications are active on the system. - The GPT-V accepts screenshots of your desktop and application GUI as input. Please ensure that no sensitive or confidential information is visible or captured during the execution process. For further information, refer to [DISCLAIMER.md](./DISCLAIMER.md). -### Step 5 🎥: Execution Logs -You can find the screenshots taken and request & response logs in the following folder: +### Step 5 🎥: Execution Logs +You can find the screenshots taken and request & response logs in the following folder: ``` ./ufo/logs// ``` - You may use them to debug, replay, or analyze the agent output. -## ❓Get help +## ❓Get help * Please first check our our documentation [here](https://microsoft.github.io/UFO/). * ❔GitHub Issues (prefered) * For other communications, please contact [ufo-agent@microsoft.com](mailto:ufo-agent@microsoft.com). - --- ## 🎬 Demo Examples @@ -254,17 +243,21 @@ You may use them to debug, replay, or analyze the agent output. We present two demo videos that complete user request on Windows OS using UFO. For more case study, please consult our [technical report](https://arxiv.org/abs/2402.07939). #### 1️⃣🗑️ Example 1: Deleting all notes on a PowerPoint presentation. - In this example, we will demonstrate how to efficiently use UFO to delete all notes on a PowerPoint presentation with just a few simple steps. Explore this functionality to enhance your productivity and work smarter, not harder! + https://github.com/microsoft/UFO/assets/11352048/cf60c643-04f7-4180-9a55-5fb240627834 -#### 2️⃣📧 Example 2: Composing an email using text from multiple sources. + +#### 2️⃣📧 Example 2: Composing an email using text from multiple sources. In this example, we will demonstrate how to utilize UFO to extract text from Word documents, describe an image, compose an email, and send it seamlessly. Enjoy the versatility and efficiency of cross-application experiences with UFO! + https://github.com/microsoft/UFO/assets/11352048/aa41ad47-fae7-4334-8e0b-ba71c4fc32e0 + + ## 📊 Evaluation Please consult the [WindowsBench](https://arxiv.org/pdf/2402.07939.pdf) provided in Section A of the Appendix within our technical report. Here are some tips (and requirements) to aid in completing your request: @@ -272,11 +265,11 @@ Please consult the [WindowsBench](https://arxiv.org/pdf/2402.07939.pdf) provided - Prior to UFO execution of your request, ensure that the targeted application is active (though it may be minimized). - Please note that the output of GPT-V may not consistently align with the same request. If unsuccessful with your initial attempt, consider trying again. -## 📚 Citation + +## 📚 Citation Our technical report paper can be found [here](https://arxiv.org/abs/2402.07939). Note that previous AppAgent and ActAgent in the paper are renamed to HostAgent and AppAgent in the code base to better reflect their functions. If you use UFO in your research, please cite our paper: - ``` @article{ufo, title={{UFO: A UI-Focused Agent for Windows OS Interaction}}, @@ -287,25 +280,26 @@ If you use UFO in your research, please cite our paper: ``` ## 📝 Todo List - -- [X] RAG enhanced UFO. -- [X] Support more control using Win32 API. -- [X] [Documentation](https://microsoft.github.io/UFO/). +- [x] RAG enhanced UFO. +- [x] Support more control using Win32 API. +- [x] [Documentation](https://microsoft.github.io/UFO/). - [ ] Support local host GUI interaction model. - [ ] Chatbox GUI for UFO. -## 🎨 Related Project + +## 🎨 Related Project You may also find [TaskWeaver](https://github.com/microsoft/TaskWeaver?tab=readme-ov-file) useful, a code-first LLM agent framework for seamlessly planning and executing data analytics tasks. -## ⚠️ Disclaimer +## ⚠️ Disclaimer By choosing to run the provided code, you acknowledge and agree to the following terms and conditions regarding the functionality and data handling practices in [DISCLAIMER.md](./DISCLAIMER.md) -## `logo` Trademarks -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft -trademarks or logos is subject to and must follow -[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +## logo Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/dataflow/config/config_dev.yaml b/dataflow/config/config_dev.yaml index ec0707a2..ff0e73df 100644 --- a/dataflow/config/config_dev.yaml +++ b/dataflow/config/config_dev.yaml @@ -1,9 +1,5 @@ version: 0.1 -AOAI_DEPLOYMENT: "gpt-4-visual-preview" # Your AOAI deployment if apply -API_VERSION: "2024-02-15-preview" # "2024-02-15-preview" by default. -OPENAI_API_MODEL: "gpt-4-0125-preview" # The only OpenAI model by now that accepts visual input - CONTROL_BACKEND: "uia" # The backend for control action CONTROL_LIST: ["Button", "Edit", "TabItem", "Document", "ListItem", "MenuItem", "ScrollBar", "TreeItem", "Hyperlink", "ComboBox", "RadioButton", "DataItem", "Spinner"] PRINT_LOG: False # Whether to print the log From f22e130e60100def7ba272800a08ceecc18f1292 Mon Sep 17 00:00:00 2001 From: MightyGaga <2360494651@qq.com> Date: Sun, 8 Dec 2024 21:41:23 +0800 Subject: [PATCH 30/30] Add the config.yaml.template of dataflow and update LLM configs in readme --- dataflow/README.md | 45 ++++++++++++++++++++--- dataflow/config/config.yaml.template | 55 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 dataflow/config/config.yaml.template diff --git a/dataflow/README.md b/dataflow/README.md index 5f83df2b..5a1fe877 100644 --- a/dataflow/README.md +++ b/dataflow/README.md @@ -24,16 +24,49 @@ pip install -r requirements.txt ### 2. Configure the LLMs -Before using the instantiation section, you need to provide your LLM configurations in `config.yaml` and `config_dev.yaml` located in the dataflow `/config ` folder. +Before running dataflow, you need to provide your LLM configurations **individually for PrefillAgent and FilterAgent**. You can create your own config file `dataflow/config/config.yaml`, by copying the `dataflow/config/config.yaml.template` and editing config for **PREFILL_AGENT** and **FILTER_AGENT** as follows: -- `config_dev.yaml` specifies the paths of relevant files and contains default settings. The match strategy for the window match and control filter supports options: `'contains'`, `'fuzzy'`, and `'regex'`, allowing flexible matching strategy for users. -- `config.yaml` stores the agent information. You should copy the `config.yaml.template` file and fill it out according to the provided hints. +#### OpenAI -You will configure the prefill agent and the filter agent individually. The prefill agent is used to prepare the task, while the filter agent evaluates the quality of the prefilled task. You can choose different LLMs for each. +```bash +VISUAL_MODE: True, # Whether to use the visual mode +API_TYPE: "openai" , # The API type, "openai" for the OpenAI API. +API_BASE: "https://api.openai.com/v1/chat/completions", # The the OpenAI API endpoint. +API_KEY: "sk-", # The OpenAI API key, begin with sk- +API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default +API_MODEL: "gpt-4-vision-preview", # The only OpenAI model +``` -**BE CAREFUL!** If you are using GitHub or other open-source tools, do not expose your `config.yaml` online, as it contains your private keys. +#### Azure OpenAI (AOAI) + +```bash +VISUAL_MODE: True, # Whether to use the visual mode +API_TYPE: "aoai" , # The API type, "aoai" for the Azure OpenAI. +API_BASE: "YOUR_ENDPOINT", # The AOAI API address. Format: https://{your-resource-name}.openai.azure.com +API_KEY: "YOUR_KEY", # The aoai API key +API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default +API_MODEL: "gpt-4-vision-preview", # The only OpenAI model +API_DEPLOYMENT_ID: "YOUR_AOAI_DEPLOYMENT", # The deployment id for the AOAI API +``` + +You can also non-visial model (e.g., GPT-4) for each agent, by setting `VISUAL_MODE: False` and proper `API_MODEL` (openai) and `API_DEPLOYMENT_ID` (aoai). + +#### Non-Visual Model Configuration -Once you have filled out the template, rename it to `config.yaml` to complete the LLM configuration. +You can utilize non-visual models (e.g., GPT-4) for each agent by configuring the following settings in the `config.yaml` file: + +- ``VISUAL_MODE: False # To enable non-visual mode.`` +- Specify the appropriate `API_MODEL` (OpenAI) and `API_DEPLOYMENT_ID` (AOAI) for each agent. + +Ensure you configure these settings accurately to leverage non-visual models effectively. + +#### Other Configurations + +`config_dev.yaml` specifies the paths of relevant files and contains default settings. The match strategy for the window match and control filter supports options: `'contains'`, `'fuzzy'`, and `'regex'`, allowing flexible matching strategy for users. The `MAX_STEPS` is the max step for the execute_flow, which can be set by users. + +#### NOTE 💡 + +**BE CAREFUL!** If you are using GitHub or other open-source tools, do not expose your `config.yaml` online, as it contains your private keys. ### 3. Prepare Files diff --git a/dataflow/config/config.yaml.template b/dataflow/config/config.yaml.template new file mode 100644 index 00000000..6cd07039 --- /dev/null +++ b/dataflow/config/config.yaml.template @@ -0,0 +1,55 @@ +PREFILL_AGENT: { + VISUAL_MODE: True, # Whether to use the visual mode + + API_TYPE: "openai" , # The API type, "openai" for the OpenAI API, "aoai" for the AOAI API, 'azure_ad' for the ad authority of the AOAI API. + API_BASE: "https://api.openai.com/v1/chat/completions", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. + API_KEY: "sk-", # The OpenAI API key, begin with sk- + API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default + API_MODEL: "gpt-4-vision-preview", # The only OpenAI model by now that accepts visual input + + + ### Comment above and uncomment these if using "aoai". + # API_TYPE: "aoai" , # The API type, "openai" for the OpenAI API, "aoai" for the Azure OpenAI. + # API_BASE: "YOUR_ENDPOINT", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. As for the aoai, it should be https://{your-resource-name}.openai.azure.com + # API_KEY: "YOUR_KEY", # The aoai API key + # API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default + # API_MODEL: "YOUR_MODEL", # The only OpenAI model by now that accepts visual input + # API_DEPLOYMENT_ID: "gpt-4-visual-preview", # The deployment id for the AOAI API + + ### For Azure_AD + # AAD_TENANT_ID: "YOUR_TENANT_ID", # Set the value to your tenant id for the llm model + # AAD_API_SCOPE: "YOUR_SCOPE", # Set the value to your scope for the llm model + # AAD_API_SCOPE_BASE: "YOUR_SCOPE_BASE" # Set the value to your scope base for the llm model, whose format is API://YOUR_SCOPE_BASE, and the only need is the YOUR_SCOPE_BASE +} + +FILTER_AGENT: { + VISUAL_MODE: True, # Whether to use the visual mode + + API_TYPE: "openai" , # The API type, "openai" for the OpenAI API, "aoai" for the AOAI API, 'azure_ad' for the ad authority of the AOAI API. + API_BASE: "https://api.openai.com/v1/chat/completions", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. + API_KEY: "sk-", # The OpenAI API key, begin with sk- + API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default + API_MODEL: "gpt-4-vision-preview", # The only OpenAI model by now that accepts visual input + + + ### Comment above and uncomment these if using "aoai". + # API_TYPE: "aoai" , # The API type, "openai" for the OpenAI API, "aoai" for the Azure OpenAI. + # API_BASE: "YOUR_ENDPOINT", # The the OpenAI API endpoint, "https://api.openai.com/v1/chat/completions" for the OpenAI API. As for the aoai, it should be https://{your-resource-name}.openai.azure.com + # API_KEY: "YOUR_KEY", # The aoai API key + # API_VERSION: "2024-02-15-preview", # "2024-02-15-preview" by default + # API_MODEL: "YOUR_MODEL", # The only OpenAI model by now that accepts visual input + # API_DEPLOYMENT_ID: "gpt-4-visual-preview", # The deployment id for the AOAI API + + ### For Azure_AD + # AAD_TENANT_ID: "YOUR_TENANT_ID", # Set the value to your tenant id for the llm model + # AAD_API_SCOPE: "YOUR_SCOPE", # Set the value to your scope for the llm model + # AAD_API_SCOPE_BASE: "YOUR_SCOPE_BASE" # Set the value to your scope base for the llm model, whose format is API://YOUR_SCOPE_BASE, and the only need is the YOUR_SCOPE_BASE + } + + +### For parameters +MAX_TOKENS: 2000 # The max token limit for the response completion +MAX_RETRY: 3 # The max retry limit for the response completion +TEMPERATURE: 0.0 # The temperature of the model: the lower the value, the more consistent the output of the model +TOP_P: 0.0 # The top_p of the model: the lower the value, the more conservative the output of the model +TIMEOUT: 60 # The call timeout(s), default is 10 minss \ No newline at end of file