From d743c479b22c658ac3af9eae5f9274b1fc4b316a Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 14 Nov 2024 11:11:04 -0500 Subject: [PATCH 01/58] Do not pop message from params. --- ads/llm/autogen/client_v02.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ads/llm/autogen/client_v02.py b/ads/llm/autogen/client_v02.py index 8dd9b6c9e..300a2878e 100644 --- a/ads/llm/autogen/client_v02.py +++ b/ads/llm/autogen/client_v02.py @@ -232,7 +232,7 @@ def create(self, params) -> ModelClient.ModelClientResponseProtocol: streaming = params.get("stream", False) # TODO: num_of_responses num_of_responses = params.get("n", 1) - messages = params.pop("messages", []) + messages = params.get("messages", []) invoke_params = copy.deepcopy(self.invoke_params) @@ -241,7 +241,6 @@ def create(self, params) -> ModelClient.ModelClientResponseProtocol: model = self.model.bind_tools( [_convert_to_langchain_tool(tool) for tool in tools] ) - # invoke_params["tools"] = tools invoke_params.update(self.function_call_params) else: model = self.model From 2bd2b65384aa2be15ff5371dedcc4383504b794d Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 15 Nov 2024 15:40:55 -0500 Subject: [PATCH 02/58] Add logger for autogen. --- ads/llm/autogen/oci_logger.py | 234 ++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 ads/llm/autogen/oci_logger.py diff --git a/ads/llm/autogen/oci_logger.py b/ads/llm/autogen/oci_logger.py new file mode 100644 index 000000000..94a6cd001 --- /dev/null +++ b/ads/llm/autogen/oci_logger.py @@ -0,0 +1,234 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import json +import logging +import os +import threading +import uuid +from types import SimpleNamespace +from typing import Any, Dict, List, Optional, Union + +import fsspec +from autogen import Agent, ConversableAgent +from autogen.logger.file_logger import ( + ChatCompletion, + F, + FileLogger, + get_current_ts, + safe_serialize, + to_dict, +) + + +logger = logging.getLogger(__name__) + + +def is_json_serializable(obj): + """Checks if an object is JSON serializable.""" + try: + json.dumps(obj) + except Exception: + return False + return True + + +def serialize_response(response): + if is_json_serializable(response): + # No need to do anything + return response + elif isinstance(response, SimpleNamespace): + # Convert simpleNamespace to dict + return json.loads(json.dumps(response, default=vars)) + elif hasattr(response, "dict") and callable(response.dict): + return response.dict() + data = { + "model": response.model, + "choices": [ + {"message": {"content": choice.message.content}} + for choice in response.choices + ], + "response": str(response), + } + return data + + +class Events: + KEY = "event_name" + LLM_CALL = "llm_call" + TOOL_CALL = "tool_call" + NEW_AGENT = "new_agent" + SESSION_START = "logging_session_start" + SESSION_STOP = "logging_session_stop" + + +class OCIFileHandler(logging.FileHandler): + def __init__( + self, + filename: str | os.PathLike[str], + session_id: str, + mode: str = "a", + encoding: str | None = None, + delay: bool = False, + errors: str | None = None, + ) -> None: + super().__init__(filename, mode, encoding, delay, errors) + self.session_id = session_id + + def format(self, record: logging.LogRecord): + """Formats the log record as JSON payload and add session_id.""" + msg = record.getMessage() + try: + data = json.loads(msg) + except Exception as e: + data = {"message": msg} + + if "session_id" not in data: + data["session_id"] = self.session_id + if "thread_id" not in data: + data["thread_id"] = threading.get_ident() + + record.msg = json.dumps(data) + return super().format(record) + + +class OCIFileLogger(FileLogger): + @property + def name(self): + return "oci_file_logger" + + def __init__(self, log_dir: str, session_id: Optional[str] = None): + self.session_id = session_id or str(uuid.uuid4()) + + self.log_dir = os.path.abspath(os.path.expanduser(log_dir)) + os.makedirs(self.log_dir, exist_ok=True) + self.log_file = os.path.join(self.log_dir, f"{self.session_id}.log") + logger.info("Start logging session to file %s", self.log_file) + + try: + with open(self.log_file, "a"): + pass + except Exception as e: + logger.error(f"Failed to write logging file: {e}") + + self.logger = logging.getLogger(session_id) + self.logger.setLevel(logging.INFO) + file_handler = OCIFileHandler(self.log_file, session_id=self.session_id) + self.logger.addHandler(file_handler) + + def start(self) -> str: + """Start the logger and return the session_id.""" + self.log_event(source=self, name=Events.SESSION_START) + return self.session_id + + def stop(self) -> None: + self.log_event(source=self, name=Events.SESSION_STOP) + return super().stop() + + def log_chat_completion( + self, + invocation_id: uuid.UUID, + client_id: int, + wrapper_id: int, + source: Union[str, Agent], + request: Dict[str, Union[float, str, List[Dict[str, str]]]], + response: Union[str, ChatCompletion], + is_cached: int, + cost: float, + start_time: str, + ) -> None: + """ + Log a chat completion. + """ + thread_id = threading.get_ident() + source_name = None + if isinstance(source, str): + source_name = source + else: + source_name = source.name + + try: + log_data = json.dumps( + { + Events.KEY: Events.LLM_CALL, + "invocation_id": str(invocation_id), + "client_id": client_id, + "wrapper_id": wrapper_id, + "request": to_dict(request), + "response": serialize_response(response), + "is_cached": is_cached, + "cost": cost, + "start_time": start_time, + "end_time": get_current_ts(), + "thread_id": thread_id, + "source_name": source_name, + } + ) + + self.logger.info(log_data) + except Exception as e: + self.logger.error(f"[file_logger] Failed to log chat completion: {e}") + + def log_function_use( + self, source: Union[str, Agent], function: F, args: Dict[str, Any], returns: Any + ) -> None: + """ + Log a registered function(can be a tool) use from an agent or a string source. + """ + thread_id = threading.get_ident() + + try: + log_data = json.dumps( + { + Events.KEY: Events.TOOL_CALL, + "source_id": id(source), + "source_name": ( + str(source.name) if hasattr(source, "name") else source + ), + "agent_module": source.__module__, + "agent_class": source.__class__.__name__, + "tool_name": function.__name__, + # This is the tool call end time + "timestamp": get_current_ts(), + "thread_id": thread_id, + "input_args": safe_serialize(args), + "returns": safe_serialize(returns), + } + ) + self.logger.info(log_data) + except Exception as e: + self.logger.error(f"[file_logger] Failed to log event {e}") + + def log_new_agent( + self, agent: ConversableAgent, init_args: Dict[str, Any] = {} + ) -> None: + """ + Log a new agent instance. + """ + thread_id = threading.get_ident() + + try: + log_data = json.dumps( + { + Events.KEY: Events.NEW_AGENT, + "id": id(agent), + "agent_name": ( + agent.name + if hasattr(agent, "name") and agent.name is not None + else "" + ), + "wrapper_id": to_dict( + agent.client.wrapper_id + if hasattr(agent, "client") and agent.client is not None + else "" + ), + "session_id": self.session_id, + "current_time": get_current_ts(), + "agent_type": type(agent).__name__, + "args": to_dict(init_args), + "thread_id": thread_id, + } + ) + self.logger.info(log_data) + except Exception as e: + self.logger.error(f"[file_logger] Failed to log new agent: {e}") From 1928c215ed65dd06bf52781aa80e35d47bfa2985 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 15 Nov 2024 15:41:04 -0500 Subject: [PATCH 03/58] Add report generation. --- ads/llm/autogen/report.py | 289 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 ads/llm/autogen/report.py diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py new file mode 100644 index 000000000..47bf79dc7 --- /dev/null +++ b/ads/llm/autogen/report.py @@ -0,0 +1,289 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import copy +import json +from datetime import datetime +from typing import Optional + +import autogen +import autogen.runtime_logging +import plotly.express as px +import pandas as pd +import report_creator as rc +from ads.llm.autogen.oci_logger import ( + OCIFileLogger, + Events, +) + + +def start_logging(log_dir: str, session_id: Optional[str] = None): + return autogen.runtime_logging.start( + logger=OCIFileLogger(log_dir=log_dir, session_id=session_id) + ) + + +def stop_logging(): + autogen.runtime_logging.stop() + + +def parse_datetime(s): + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S.%f") + + +def get_duration(log: dict) -> float: + return ( + parse_datetime(log.get("end_time")) - parse_datetime(log.get("start_time")) + ).total_seconds() + + +class SessionReport: + def __init__(self, log_file: str) -> None: + self.log_file = log_file + with open(self.log_file, mode="r", encoding="utf-8") as f: + self.log_lines = f.readlines() + self.logs = self._parse_logs() + self.event_logs = self.get_event_logs() + self.invocation_logs = self._parse_invocation_events() + + def _parse_logs(self): + logs = [] + for log in self.log_lines: + try: + logs.append(json.loads(log)) + except Exception as e: + continue + return logs + + def _parse_invocation_events(self): + # LLM calls + llm_events = self.filter_event_logs(Events.LLM_CALL) + llm_call_counter = 1 + for event in llm_events: + event["name"] = f"LLM Call {str(llm_call_counter)}" + llm_call_counter += 1 + # Tool Calls + tool_events = self.filter_event_logs(Events.TOOL_CALL) + for event in tool_events: + event["start_time"] = self.estimate_tool_call_start_time(event) + event["name"] = event["tool_name"] + event["end_time"] = event["timestamp"] + + events = sorted(llm_events + tool_events, key=lambda x: x.get("start_time")) + for event in events: + event["duration"] = get_duration(event) + + return events + + def get_event_data(self, event_name: str): + for log in self.logs: + if log.get(Events.KEY) == event_name: + return log + return None + + def filter_event_logs(self, event_name): + filtered_logs = [] + for log in self.logs: + if log.get(Events.KEY) == event_name: + filtered_logs.append(log) + return filtered_logs + + def get_event_logs(self): + event_logs = [] + for log in self.logs: + if Events.KEY in log: + event_logs.append(log) + return sorted( + event_logs, key=lambda x: x.get("timestamp", x.get("end_time", "")) + ) + + def estimate_tool_call_start_time(self, tool_call_log): + event_index = self.event_logs.index(tool_call_log) + while event_index > 0: + log = self.event_logs[event_index] + + if log.get("json_state") and ( + json.loads(log.get("json_state", "")).get("reply_func_name") + == "check_termination_and_human_reply" + ): + return log.get("timestamp") + event_index -= 1 + return None + + def build_timeline_figure(self): + df = pd.DataFrame(self.invocation_logs) + fig = px.timeline( + df, + x_start="start_time", + x_end="end_time", + y="name", + color="duration", + color_continuous_scale="rdylgn_r", + ) + fig.update_layout(showlegend=False) + fig.update_yaxes(autorange="reversed") + return fig + + def format_messages(self, messages): + text = "" + for message in messages: + text += f"**{message.get('role')}**:\n{message.get('content')}\n\n" + return text + + def build_llm_chat(self, llm_log): + request = llm_log.get("request", {}) + source_name = llm_log.get("source_name") + + header = f"{source_name} invoking {request.get('model')}" + + description = f"*{llm_log.get('start_time')}*" + + request_value = f"{str(len(request.get('messages')))} messages" + tools = request.get("tools") + if tools: + request_value += f", {str(len(tools))} tools" + + response = llm_log.get("response") + response_message = response.get("choices")[0].get("message") + response_text = response_message.get("content", "") + tool_calls = response_message.get("tool_calls") + if tool_calls: + response_text += f"\n\n**Tool Calls**:" + for tool_call in tool_calls: + func = tool_call.get("function") + response_text += f"\n\n`{func.get('name')}(**{func.get('arguments')})`" + response_time = get_duration(llm_log) + + return rc.Block( + rc.Text( + description, + label=header, + ), + rc.Group( + rc.Block( + rc.Metric( + heading="Request", + value=request_value, + label=self.format_messages(request.get("messages")), + ), + rc.Collapse( + rc.Json(request), + label="JSON", + ), + ), + rc.Block( + rc.Metric( + heading="Response", + value=response_time, + unit="s", + label=response_text, + ), + rc.Collapse( + rc.Json(response), + label="JSON", + ), + ), + # label=request_header, + ), + ) + + def build_tool_call(self, log: dict): + source_name = log.get("source_name") + header = f"{source_name} invoking {log.get('tool_name')}" + request = copy.deepcopy(log) + response = request.pop("returns", {}) + try: + response = json.loads(response) + except Exception: + pass + return rc.Block( + rc.Group( + rc.Block( + rc.Metric( + heading="Request", + value=log.get("tool_name"), + label=log.get("input_args"), + ), + rc.Collapse( + rc.Json(request), + label="JSON", + ), + ), + rc.Block( + rc.Metric( + heading="Response", + value=get_duration(log), + unit="s", + label=str(response), + ), + rc.Collapse( + rc.Json(response), + label="JSON", + ), + ), + label=header, + ), + ) + + def build_invocations(self, logs): + blocks = [] + for log in logs: + event_name = log.get(Events.KEY) + if event_name == Events.LLM_CALL: + blocks.append(self.build_llm_chat(log)) + elif event_name == Events.TOOL_CALL: + blocks.append(self.build_tool_call(log)) + return blocks + + def build(self, output_file: str): + start_event = self.get_event_data(Events.SESSION_START) + start_time = start_event.get("timestamp") + session_id = start_event.get("session_id") + + event_logs = self.get_event_logs() + new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) + llm_call_logs = self.filter_event_logs(Events.LLM_CALL) + tool_call_logs = self.filter_event_logs(Events.TOOL_CALL) + + with rc.ReportCreator( + title=f"AutoGen Session: {session_id}", + description=f"Started at {start_time}", + footer="Created with ❤️ by Oracle ADS", + ) as report: + + view = rc.Block( + rc.Group( + rc.Metric( + heading="Agents", + value=len(new_agent_logs), + ), + rc.Metric( + heading="Events", + value=len(event_logs), + ), + rc.Metric( + heading="LLM Calls", + value=len(llm_call_logs), + ), + rc.Metric( + heading="Tool Calls", + value=len(tool_call_logs), + ), + ), + rc.Select( + blocks=[ + rc.Widget(self.build_timeline_figure(), label="Timeline"), + rc.Block( + *self.build_invocations(self.invocation_logs), + label="Invocations", + ), + ], + ), + ) + + report.save(view, output_file) + + +def create_report(log_file: str): + report = SessionReport(log_file=log_file) + report.build("report.html") From ee0b1542f747122567c1b5fe5f5bdb973a18ff78 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 15 Nov 2024 15:52:49 -0500 Subject: [PATCH 04/58] Generate report when stop logging. --- ads/llm/autogen/report.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py index 47bf79dc7..211e0af39 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/report.py @@ -3,6 +3,7 @@ # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import copy import json +import os from datetime import datetime from typing import Optional @@ -23,10 +24,6 @@ def start_logging(log_dir: str, session_id: Optional[str] = None): ) -def stop_logging(): - autogen.runtime_logging.stop() - - def parse_datetime(s): return datetime.strptime(s, "%Y-%m-%d %H:%M:%S.%f") @@ -284,6 +281,18 @@ def build(self, output_file: str): report.save(view, output_file) -def create_report(log_file: str): +def create_report(log_file: str, report_file: str): report = SessionReport(log_file=log_file) - report.build("report.html") + report.build(report_file) + return report_file + + +def stop_logging(report_dir: str): + autogen.runtime_logging.stop() + if not report_dir: + return None + logger = autogen.runtime_logging.autogen_logger + if not isinstance(logger, OCIFileLogger): + raise NotImplementedError("The logger does not support report generation.") + report_file = os.path.join(report_dir, f"{logger.session_id}.html") + return create_report(log_file=logger.log_file, report_file=report_file) From 2888b8d5bb14e8921cbfe1c77cb91372758eafdc Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 18 Nov 2024 15:01:33 -0500 Subject: [PATCH 05/58] Add multi-threading support. --- ads/llm/autogen/oci_logger.py | 105 +++++++++++++++++++++++++++++----- ads/llm/autogen/report.py | 90 +++++++++++++++++++++++++++-- 2 files changed, 177 insertions(+), 18 deletions(-) diff --git a/ads/llm/autogen/oci_logger.py b/ads/llm/autogen/oci_logger.py index 94a6cd001..671d01ae6 100644 --- a/ads/llm/autogen/oci_logger.py +++ b/ads/llm/autogen/oci_logger.py @@ -6,6 +6,7 @@ import os import threading import uuid +from dataclasses import dataclass from types import SimpleNamespace from typing import Any, Dict, List, Optional, Union @@ -92,36 +93,114 @@ def format(self, record: logging.LogRecord): return super().format(record) +@dataclass +class LoggingSession: + session_id: str + log_dir: str + log_file: str + thread_id: int + pid: int + logger: logging.Logger + + class OCIFileLogger(FileLogger): + def __init__(self, log_dir: str, session_id: Optional[str] = None): + self.sessions: Dict[int, LoggingSession] = {} + self.new_session(log_dir=log_dir, session_id=session_id) + + @property + def session(self): + """Session for the current thread.""" + return self.sessions.get(threading.get_ident()) + + @property + def logger(self): + """Logger for the current thread.""" + session = self.sessions.get(threading.get_ident()) + return session.logger if session else None + + @property + def session_id(self): + """Session ID for the current thread.""" + return self.sessions[threading.get_ident()].session_id + + @property + def log_file(self): + """Log file for the current session.""" + return self.sessions[threading.get_ident()].log_file + @property def name(self): - return "oci_file_logger" + return self.session_id or "oci_file_logger" - def __init__(self, log_dir: str, session_id: Optional[str] = None): - self.session_id = session_id or str(uuid.uuid4()) + def new_session(self, log_dir: str, session_id: Optional[str] = None): + """Creates a new logging session. + + If an active logging session is already started in the thread, the existing session will be used. - self.log_dir = os.path.abspath(os.path.expanduser(log_dir)) - os.makedirs(self.log_dir, exist_ok=True) - self.log_file = os.path.join(self.log_dir, f"{self.session_id}.log") - logger.info("Start logging session to file %s", self.log_file) + Parameters + ---------- + log_dir : str + Directory for saving the log file. + session_id : str, optional + Session ID, by default None. + If the session ID is None, a new UUID4 will be generated. + The session ID will be used as the log filename. + + Returns + ------- + str + session ID + """ + thread_id = threading.get_ident() + if thread_id in self.sessions: + logger.warning( + "An active logging session (ID=%s) is already started in this thread (%s). " + "Please stop the active session before starting a new session.", + self.session_id, + thread_id, + ) + return self.session_id + + session_id = session_id or str(uuid.uuid4()) + log_dir = os.path.abspath(os.path.expanduser(log_dir)) + log_file = os.path.join(log_dir, f"{session_id}.log") + + # Test opening the log file + os.makedirs(log_dir, exist_ok=True) try: - with open(self.log_file, "a"): + with open(log_file, "a"): pass except Exception as e: logger.error(f"Failed to write logging file: {e}") - self.logger = logging.getLogger(session_id) - self.logger.setLevel(logging.INFO) - file_handler = OCIFileHandler(self.log_file, session_id=self.session_id) - self.logger.addHandler(file_handler) + # Prepare the logger + session_logger = logging.getLogger(session_id) + session_logger.setLevel(logging.INFO) + file_handler = OCIFileHandler(log_file, session_id=session_id) + session_logger.addHandler(file_handler) + + # Create logging session + self.sessions[thread_id] = LoggingSession( + session_id=session_id, + log_dir=log_dir, + log_file=log_file, + thread_id=thread_id, + pid=os.getpid(), + logger=session_logger, + ) + + logger.info("Start logging session %s to file %s", session_id, log_file) + return session_id def start(self) -> str: - """Start the logger and return the session_id.""" + """Start the logging session and return the session_id.""" self.log_event(source=self, name=Events.SESSION_START) return self.session_id def stop(self) -> None: + """Stops the logging session.""" self.log_event(source=self, name=Events.SESSION_STOP) return super().stop() diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py index 211e0af39..3cfe74c2d 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/report.py @@ -3,6 +3,7 @@ # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import copy import json +import logging import os from datetime import datetime from typing import Optional @@ -18,10 +19,50 @@ ) -def start_logging(log_dir: str, session_id: Optional[str] = None): - return autogen.runtime_logging.start( - logger=OCIFileLogger(log_dir=log_dir, session_id=session_id) - ) +logger = logging.getLogger(__name__) + + +class AutoGenLoggingException(Exception): + pass + + +def start_logging(log_dir: str, session_id: Optional[str] = None) -> str: + """Starts a new logging session. + Each thread can only have one logging session. + + AutoGen saves the logger as global variable. Only one logger can be active at a time. + If you are using other loggers like AgentOps, an exception will be raised. + + Parameters + ---------- + log_dir : str + The location to store the logs. + session_id : str, optional + Session ID for identifying the session, by default None. + If the session ID is None, a new UUID4 will be generated. + The session ID will be used as the log filename. + + Returns + ------- + str + Session ID + """ + autogen_logger = autogen.runtime_logging.autogen_logger + if autogen_logger is None: + autogen_logger = OCIFileLogger(log_dir=log_dir, session_id=session_id) + elif isinstance(autogen_logger, OCIFileLogger): + autogen_logger.new_session(log_dir=log_dir, session_id=session_id) + elif autogen.runtime_logging.is_logging: + raise AutoGenLoggingException( + "AutoGen is currently logging with a different logger. " + "Only one logger can be active at a time. " + "Please call `autogen.runtime_logging.stop()` to stop logging " + "before starting a new session." + ) + else: + logger.warning("Replacing AutoGen logger with OCIFileLogger...") + autogen_logger = OCIFileLogger(log_dir=log_dir, session_id=session_id) + return autogen.runtime_logging.start(logger=autogen_logger) def parse_datetime(s): @@ -29,6 +70,29 @@ def parse_datetime(s): def get_duration(log: dict) -> float: + """Gets the duration of an event in seconds from a log record. + The log record should contain two keys: `start_time` and `end_time`. + Each of the value should be a time in string format of + `%Y-%m-%d %H:%M:%S.%f` + + The duration is calculated by parsing two strings, and + subtracting the `end_time` from `start_time`. + + If either `start_time` or `end_time` is not presented, + 0 will be returned. + + Parameters + ---------- + log : dict + A log record containing keys: `start_time` and `end_time` + + Returns + ------- + float + Duration in seconds. + """ + if "end_time" not in log or "start_time" not in log: + return 0 return ( parse_datetime(log.get("end_time")) - parse_datetime(log.get("start_time")) ).total_seconds() @@ -283,11 +347,27 @@ def build(self, output_file: str): def create_report(log_file: str, report_file: str): report = SessionReport(log_file=log_file) + report_file = os.path.abspath(os.path.expanduser(report_file)) report.build(report_file) return report_file -def stop_logging(report_dir: str): +def stop_logging(report_dir: str = None) -> Optional[str]: + """Stops the logging session. + + Parameters + ---------- + report_dir : str, optional + Directory for saving the session report, by default None. + If `report_dir` is None, no report will be created. + + Returns + ------- + str + The full filename of the report, if `report_dir` is provided. + Otherwise, None. + + """ autogen.runtime_logging.stop() if not report_dir: return None From 838c13b1dbf2f25f2c9dc9432685dd46179dba70 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 18 Nov 2024 15:15:08 -0500 Subject: [PATCH 06/58] Format tool call args if it is a valid JSON. --- ads/llm/autogen/report.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py index 3cfe74c2d..edafca3e6 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/report.py @@ -98,6 +98,15 @@ def get_duration(log: dict) -> float: ).total_seconds() +def is_json_string(s): + """Checks if a string contains valid JSON.""" + try: + json.loads(s) + except Exception: + return False + return True + + class SessionReport: def __init__(self, log_file: str) -> None: self.log_file = log_file @@ -107,6 +116,10 @@ def __init__(self, log_file: str) -> None: self.event_logs = self.get_event_logs() self.invocation_logs = self._parse_invocation_events() + @staticmethod + def format_json_string(s): + return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" + def _parse_logs(self): logs = [] for log in self.log_lines: @@ -257,13 +270,18 @@ def build_tool_call(self, log: dict): response = json.loads(response) except Exception: pass + + tool_call_args = log.get("input_args", "") + if is_json_string(tool_call_args): + tool_call_args = self.format_json_string(tool_call_args) + return rc.Block( rc.Group( rc.Block( rc.Metric( heading="Request", value=log.get("tool_name"), - label=log.get("input_args"), + label=tool_call_args, ), rc.Collapse( rc.Json(request), From c87704b4e631a91058c5538486fa4725bc004235 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Tue, 19 Nov 2024 16:03:35 -0500 Subject: [PATCH 07/58] Support OCI object storage for logging and reports. --- ads/llm/autogen/constants.py | 12 + ads/llm/autogen/oci_logger.py | 209 +++++++++++++--- ads/llm/autogen/report.py | 354 +++------------------------- ads/llm/autogen/reports/__init__.py | 3 + ads/llm/autogen/reports/session.py | 279 ++++++++++++++++++++++ ads/llm/autogen/reports/utils.py | 47 ++++ 6 files changed, 544 insertions(+), 360 deletions(-) create mode 100644 ads/llm/autogen/constants.py create mode 100644 ads/llm/autogen/reports/__init__.py create mode 100644 ads/llm/autogen/reports/session.py create mode 100644 ads/llm/autogen/reports/utils.py diff --git a/ads/llm/autogen/constants.py b/ads/llm/autogen/constants.py new file mode 100644 index 000000000..f11f9f3f3 --- /dev/null +++ b/ads/llm/autogen/constants.py @@ -0,0 +1,12 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. + + +class Events: + KEY = "event_name" + LLM_CALL = "llm_call" + TOOL_CALL = "tool_call" + NEW_AGENT = "new_agent" + SESSION_START = "logging_session_start" + SESSION_STOP = "logging_session_stop" diff --git a/ads/llm/autogen/oci_logger.py b/ads/llm/autogen/oci_logger.py index 671d01ae6..35ece72b4 100644 --- a/ads/llm/autogen/oci_logger.py +++ b/ads/llm/autogen/oci_logger.py @@ -1,14 +1,18 @@ # coding: utf-8 # Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import io import json import logging import os +import tempfile import threading import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field +from datetime import datetime, timezone, timedelta from types import SimpleNamespace from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlparse import fsspec from autogen import Agent, ConversableAgent @@ -20,12 +24,21 @@ safe_serialize, to_dict, ) +from oci.object_storage import ObjectStorageClient +from oci.object_storage.models import ( + CreatePreauthenticatedRequestDetails, + PreauthenticatedRequest, +) + +from ads.common.auth import default_signer +from ads.llm.autogen.constants import Events +from ads.llm.autogen.reports.session import SessionReport logger = logging.getLogger(__name__) -def is_json_serializable(obj): +def is_json_serializable(obj) -> bool: """Checks if an object is JSON serializable.""" try: json.dumps(obj) @@ -34,11 +47,9 @@ def is_json_serializable(obj): return True -def serialize_response(response): - if is_json_serializable(response): - # No need to do anything - return response - elif isinstance(response, SimpleNamespace): +def serialize_response(response) -> dict: + """Serializes the LLM response to dictionary.""" + if isinstance(response, SimpleNamespace) or is_json_serializable(response): # Convert simpleNamespace to dict return json.loads(json.dumps(response, default=vars)) elif hasattr(response, "dict") and callable(response.dict): @@ -54,27 +65,57 @@ def serialize_response(response): return data -class Events: - KEY = "event_name" - LLM_CALL = "llm_call" - TOOL_CALL = "tool_call" - NEW_AGENT = "new_agent" - SESSION_START = "logging_session_start" - SESSION_STOP = "logging_session_stop" - - class OCIFileHandler(logging.FileHandler): + """Log handler for saving log file to OCI object storage.""" + def __init__( self, - filename: str | os.PathLike[str], + filename: str, session_id: str, mode: str = "a", encoding: str | None = None, delay: bool = False, errors: str | None = None, + auth: dict | None = None, ) -> None: - super().__init__(filename, mode, encoding, delay, errors) self.session_id = session_id + self.auth = auth + + if filename.startswith("oci://"): + self.baseFilename = filename + else: + self.baseFilename = os.path.abspath(os.path.expanduser(filename)) + os.makedirs(os.path.dirname(self.baseFilename), exist_ok=True) + + # The following code are from the `FileHandler.__init__()` + self.mode = mode + self.encoding = encoding + if "b" not in mode: + self.encoding = io.text_encoding(encoding) + self.errors = errors + self.delay = delay + + if delay: + # We don't open the stream, but we still need to call the + # Handler constructor to set level, formatter, lock etc. + logging.Handler.__init__(self) + self.stream = None + else: + logging.StreamHandler.__init__(self, self._open()) + + def _open(self): + """ + Open the current base file with the (original) mode and encoding. + Return the resulting stream. + """ + auth = self.auth or default_signer() + return fsspec.open( + self.baseFilename, + self.mode, + encoding=self.encoding, + errors=self.errors, + **auth, + ).open() def format(self, record: logging.LogRecord): """Formats the log record as JSON payload and add session_id.""" @@ -95,45 +136,143 @@ def format(self, record: logging.LogRecord): @dataclass class LoggingSession: + """Represents a logging session.""" + session_id: str log_dir: str log_file: str thread_id: int pid: int logger: logging.Logger + auth: dict = field(default_factory=dict) + report_file: Optional[str] = None + par_uri: Optional[str] = None + + def __repr__(self) -> str: + if self.par_uri: + return self.par_uri + elif self.report_file: + return self.report_file + return self.log_file + + def create_par_uri(self, oci_file: str, **kwargs) -> str: + """Creates a pre-authenticated request URI for a file on OCI object storage. + + Parameters + ---------- + oci_file : str + OCI file URI in the format of oci://bucket@namespace/prefix/to/file + auth : dict, optional + Dictionary containing the OCI authentication config and signer. + Defaults to `ads.common.auth.default_signer()`. + + Returns + ------- + str + The pre-authenticated URI + """ + auth = self.auth or default_signer() + client = ObjectStorageClient(**auth) + parsed = urlparse(oci_file) + bucket = parsed.username + namespace = parsed.hostname + time_expires = kwargs.pop( + "time_expires", datetime.now(timezone.utc) + timedelta(weeks=1) + ) + access_type = kwargs.pop("access_type", "ObjectRead") + response: PreauthenticatedRequest = client.create_preauthenticated_request( + bucket_name=bucket, + namespace_name=namespace, + create_preauthenticated_request_details=CreatePreauthenticatedRequestDetails( + name=os.path.basename(oci_file), + object_name=str(parsed.path).lstrip("/"), + access_type=access_type, + time_expires=time_expires, + **kwargs, + ), + ).data + return response.full_path + + def create_report(self, report_file: str, return_par_uri: bool = False, **kwargs): + auth = self.auth or default_signer() + report = SessionReport(log_file=self.log_file, auth=auth) + if report_file.startswith("oci://"): + with tempfile.TemporaryDirectory() as temp_dir: + # Save the report to local temp dir + temp_report = os.path.join(temp_dir, os.path.basename(report_file)) + report.build(temp_report) + # Upload to OCI object storage + fs = fsspec.filesystem("oci", **auth) + fs.put(temp_report, report_file) + if return_par_uri: + par_uri = self.create_par_uri(oci_file=report_file, **kwargs) + self.report_file = report_file + self.par_uri = par_uri + return par_uri + else: + report_file = os.path.abspath(os.path.expanduser(report_file)) + report.build(report_file) + self.report_file = report_file + return report_file class OCIFileLogger(FileLogger): - def __init__(self, log_dir: str, session_id: Optional[str] = None): + """Logger for saving log file to OCI object storage.""" + + def __init__( + self, + log_dir: str, + session_id: Optional[str] = None, + auth: Optional[dict] = None, + ): + """Initialize a file logger for new session. + + Parameters + ---------- + log_dir : str + Directory for saving the log file. + session_id : str, optional + Session ID, by default None. + If the session ID is None, a new UUID4 will be generated. + The session ID will be used as the log filename. + auth: dict, optional + Dictionary containing the OCI authentication config and signer. + If auth is None, `ads.common.auth.default_signer()` will be used. + """ self.sessions: Dict[int, LoggingSession] = {} - self.new_session(log_dir=log_dir, session_id=session_id) + self.new_session(log_dir=log_dir, session_id=session_id, auth=auth) @property - def session(self): + def session(self) -> Optional[LoggingSession]: """Session for the current thread.""" return self.sessions.get(threading.get_ident()) @property - def logger(self): + def logger(self) -> Optional[str]: """Logger for the current thread.""" session = self.sessions.get(threading.get_ident()) return session.logger if session else None @property - def session_id(self): + def session_id(self) -> Optional[str]: """Session ID for the current thread.""" return self.sessions[threading.get_ident()].session_id @property - def log_file(self): - """Log file for the current session.""" + def log_file(self) -> Optional[str]: + """Log file path for the current session.""" return self.sessions[threading.get_ident()].log_file @property - def name(self): + def name(self) -> Optional[str]: return self.session_id or "oci_file_logger" - def new_session(self, log_dir: str, session_id: Optional[str] = None): + def new_session( + self, + log_dir: str, + session_id: Optional[str] = None, + auth: Optional[dict] = None, + ) -> str: """Creates a new logging session. If an active logging session is already started in the thread, the existing session will be used. @@ -146,6 +285,10 @@ def new_session(self, log_dir: str, session_id: Optional[str] = None): Session ID, by default None. If the session ID is None, a new UUID4 will be generated. The session ID will be used as the log filename. + auth: dict, optional + Dictionary containing the OCI authentication config and signer. + If auth is None, `ads.common.auth.default_signer()` will be used. + Returns ------- @@ -164,21 +307,12 @@ def new_session(self, log_dir: str, session_id: Optional[str] = None): return self.session_id session_id = session_id or str(uuid.uuid4()) - log_dir = os.path.abspath(os.path.expanduser(log_dir)) log_file = os.path.join(log_dir, f"{session_id}.log") - # Test opening the log file - os.makedirs(log_dir, exist_ok=True) - try: - with open(log_file, "a"): - pass - except Exception as e: - logger.error(f"Failed to write logging file: {e}") - # Prepare the logger session_logger = logging.getLogger(session_id) session_logger.setLevel(logging.INFO) - file_handler = OCIFileHandler(log_file, session_id=session_id) + file_handler = OCIFileHandler(log_file, session_id=session_id, auth=auth) session_logger.addHandler(file_handler) # Create logging session @@ -189,6 +323,7 @@ def new_session(self, log_dir: str, session_id: Optional[str] = None): thread_id=thread_id, pid=os.getpid(), logger=session_logger, + auth=auth, ) logger.info("Start logging session %s to file %s", session_id, log_file) diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py index edafca3e6..024d4d071 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/report.py @@ -1,22 +1,13 @@ # coding: utf-8 # Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. -import copy -import json import logging import os -from datetime import datetime from typing import Optional import autogen import autogen.runtime_logging -import plotly.express as px -import pandas as pd -import report_creator as rc -from ads.llm.autogen.oci_logger import ( - OCIFileLogger, - Events, -) +from ads.llm.autogen.oci_logger import OCIFileLogger logger = logging.getLogger(__name__) @@ -26,7 +17,9 @@ class AutoGenLoggingException(Exception): pass -def start_logging(log_dir: str, session_id: Optional[str] = None) -> str: +def start_logging( + log_dir: str, session_id: Optional[str] = None, auth: Optional[dict] = None +) -> str: """Starts a new logging session. Each thread can only have one logging session. @@ -39,8 +32,12 @@ def start_logging(log_dir: str, session_id: Optional[str] = None) -> str: The location to store the logs. session_id : str, optional Session ID for identifying the session, by default None. - If the session ID is None, a new UUID4 will be generated. + If session_id is None, a new UUID4 will be generated. The session ID will be used as the log filename. + auth: dict, optional + Dictionary containing the OCI authentication config and signer. + This is only used if log_dir is on object storage. + If auth is None, `ads.common.auth.default_signer()` will be used. Returns ------- @@ -49,9 +46,11 @@ def start_logging(log_dir: str, session_id: Optional[str] = None) -> str: """ autogen_logger = autogen.runtime_logging.autogen_logger if autogen_logger is None: - autogen_logger = OCIFileLogger(log_dir=log_dir, session_id=session_id) + autogen_logger = OCIFileLogger( + log_dir=log_dir, session_id=session_id, auth=auth + ) elif isinstance(autogen_logger, OCIFileLogger): - autogen_logger.new_session(log_dir=log_dir, session_id=session_id) + autogen_logger.new_session(log_dir=log_dir, session_id=session_id, auth=auth) elif autogen.runtime_logging.is_logging: raise AutoGenLoggingException( "AutoGen is currently logging with a different logger. " @@ -65,312 +64,9 @@ def start_logging(log_dir: str, session_id: Optional[str] = None) -> str: return autogen.runtime_logging.start(logger=autogen_logger) -def parse_datetime(s): - return datetime.strptime(s, "%Y-%m-%d %H:%M:%S.%f") - - -def get_duration(log: dict) -> float: - """Gets the duration of an event in seconds from a log record. - The log record should contain two keys: `start_time` and `end_time`. - Each of the value should be a time in string format of - `%Y-%m-%d %H:%M:%S.%f` - - The duration is calculated by parsing two strings, and - subtracting the `end_time` from `start_time`. - - If either `start_time` or `end_time` is not presented, - 0 will be returned. - - Parameters - ---------- - log : dict - A log record containing keys: `start_time` and `end_time` - - Returns - ------- - float - Duration in seconds. - """ - if "end_time" not in log or "start_time" not in log: - return 0 - return ( - parse_datetime(log.get("end_time")) - parse_datetime(log.get("start_time")) - ).total_seconds() - - -def is_json_string(s): - """Checks if a string contains valid JSON.""" - try: - json.loads(s) - except Exception: - return False - return True - - -class SessionReport: - def __init__(self, log_file: str) -> None: - self.log_file = log_file - with open(self.log_file, mode="r", encoding="utf-8") as f: - self.log_lines = f.readlines() - self.logs = self._parse_logs() - self.event_logs = self.get_event_logs() - self.invocation_logs = self._parse_invocation_events() - - @staticmethod - def format_json_string(s): - return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" - - def _parse_logs(self): - logs = [] - for log in self.log_lines: - try: - logs.append(json.loads(log)) - except Exception as e: - continue - return logs - - def _parse_invocation_events(self): - # LLM calls - llm_events = self.filter_event_logs(Events.LLM_CALL) - llm_call_counter = 1 - for event in llm_events: - event["name"] = f"LLM Call {str(llm_call_counter)}" - llm_call_counter += 1 - # Tool Calls - tool_events = self.filter_event_logs(Events.TOOL_CALL) - for event in tool_events: - event["start_time"] = self.estimate_tool_call_start_time(event) - event["name"] = event["tool_name"] - event["end_time"] = event["timestamp"] - - events = sorted(llm_events + tool_events, key=lambda x: x.get("start_time")) - for event in events: - event["duration"] = get_duration(event) - - return events - - def get_event_data(self, event_name: str): - for log in self.logs: - if log.get(Events.KEY) == event_name: - return log - return None - - def filter_event_logs(self, event_name): - filtered_logs = [] - for log in self.logs: - if log.get(Events.KEY) == event_name: - filtered_logs.append(log) - return filtered_logs - - def get_event_logs(self): - event_logs = [] - for log in self.logs: - if Events.KEY in log: - event_logs.append(log) - return sorted( - event_logs, key=lambda x: x.get("timestamp", x.get("end_time", "")) - ) - - def estimate_tool_call_start_time(self, tool_call_log): - event_index = self.event_logs.index(tool_call_log) - while event_index > 0: - log = self.event_logs[event_index] - - if log.get("json_state") and ( - json.loads(log.get("json_state", "")).get("reply_func_name") - == "check_termination_and_human_reply" - ): - return log.get("timestamp") - event_index -= 1 - return None - - def build_timeline_figure(self): - df = pd.DataFrame(self.invocation_logs) - fig = px.timeline( - df, - x_start="start_time", - x_end="end_time", - y="name", - color="duration", - color_continuous_scale="rdylgn_r", - ) - fig.update_layout(showlegend=False) - fig.update_yaxes(autorange="reversed") - return fig - - def format_messages(self, messages): - text = "" - for message in messages: - text += f"**{message.get('role')}**:\n{message.get('content')}\n\n" - return text - - def build_llm_chat(self, llm_log): - request = llm_log.get("request", {}) - source_name = llm_log.get("source_name") - - header = f"{source_name} invoking {request.get('model')}" - - description = f"*{llm_log.get('start_time')}*" - - request_value = f"{str(len(request.get('messages')))} messages" - tools = request.get("tools") - if tools: - request_value += f", {str(len(tools))} tools" - - response = llm_log.get("response") - response_message = response.get("choices")[0].get("message") - response_text = response_message.get("content", "") - tool_calls = response_message.get("tool_calls") - if tool_calls: - response_text += f"\n\n**Tool Calls**:" - for tool_call in tool_calls: - func = tool_call.get("function") - response_text += f"\n\n`{func.get('name')}(**{func.get('arguments')})`" - response_time = get_duration(llm_log) - - return rc.Block( - rc.Text( - description, - label=header, - ), - rc.Group( - rc.Block( - rc.Metric( - heading="Request", - value=request_value, - label=self.format_messages(request.get("messages")), - ), - rc.Collapse( - rc.Json(request), - label="JSON", - ), - ), - rc.Block( - rc.Metric( - heading="Response", - value=response_time, - unit="s", - label=response_text, - ), - rc.Collapse( - rc.Json(response), - label="JSON", - ), - ), - # label=request_header, - ), - ) - - def build_tool_call(self, log: dict): - source_name = log.get("source_name") - header = f"{source_name} invoking {log.get('tool_name')}" - request = copy.deepcopy(log) - response = request.pop("returns", {}) - try: - response = json.loads(response) - except Exception: - pass - - tool_call_args = log.get("input_args", "") - if is_json_string(tool_call_args): - tool_call_args = self.format_json_string(tool_call_args) - - return rc.Block( - rc.Group( - rc.Block( - rc.Metric( - heading="Request", - value=log.get("tool_name"), - label=tool_call_args, - ), - rc.Collapse( - rc.Json(request), - label="JSON", - ), - ), - rc.Block( - rc.Metric( - heading="Response", - value=get_duration(log), - unit="s", - label=str(response), - ), - rc.Collapse( - rc.Json(response), - label="JSON", - ), - ), - label=header, - ), - ) - - def build_invocations(self, logs): - blocks = [] - for log in logs: - event_name = log.get(Events.KEY) - if event_name == Events.LLM_CALL: - blocks.append(self.build_llm_chat(log)) - elif event_name == Events.TOOL_CALL: - blocks.append(self.build_tool_call(log)) - return blocks - - def build(self, output_file: str): - start_event = self.get_event_data(Events.SESSION_START) - start_time = start_event.get("timestamp") - session_id = start_event.get("session_id") - - event_logs = self.get_event_logs() - new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) - llm_call_logs = self.filter_event_logs(Events.LLM_CALL) - tool_call_logs = self.filter_event_logs(Events.TOOL_CALL) - - with rc.ReportCreator( - title=f"AutoGen Session: {session_id}", - description=f"Started at {start_time}", - footer="Created with ❤️ by Oracle ADS", - ) as report: - - view = rc.Block( - rc.Group( - rc.Metric( - heading="Agents", - value=len(new_agent_logs), - ), - rc.Metric( - heading="Events", - value=len(event_logs), - ), - rc.Metric( - heading="LLM Calls", - value=len(llm_call_logs), - ), - rc.Metric( - heading="Tool Calls", - value=len(tool_call_logs), - ), - ), - rc.Select( - blocks=[ - rc.Widget(self.build_timeline_figure(), label="Timeline"), - rc.Block( - *self.build_invocations(self.invocation_logs), - label="Invocations", - ), - ], - ), - ) - - report.save(view, output_file) - - -def create_report(log_file: str, report_file: str): - report = SessionReport(log_file=log_file) - report_file = os.path.abspath(os.path.expanduser(report_file)) - report.build(report_file) - return report_file - - -def stop_logging(report_dir: str = None) -> Optional[str]: +def stop_logging( + report_dir: str = None, return_par_uri: bool = False, **kwargs +) -> Optional[str]: """Stops the logging session. Parameters @@ -378,6 +74,10 @@ def stop_logging(report_dir: str = None) -> Optional[str]: report_dir : str, optional Directory for saving the session report, by default None. If `report_dir` is None, no report will be created. + return_par_uri: bool, optional + For report_dir on OCI object storage only, + whether to create and return a pre-authenticated uri for the report. + Defaults to False. Returns ------- @@ -387,10 +87,18 @@ def stop_logging(report_dir: str = None) -> Optional[str]: """ autogen.runtime_logging.stop() - if not report_dir: - return None logger = autogen.runtime_logging.autogen_logger + + if not report_dir: + if isinstance(logger, OCIFileLogger): + return logger.session + else: + return None + if not isinstance(logger, OCIFileLogger): raise NotImplementedError("The logger does not support report generation.") report_file = os.path.join(report_dir, f"{logger.session_id}.html") - return create_report(log_file=logger.log_file, report_file=report_file) + logger.session.create_report( + report_file=report_file, return_par_uri=return_par_uri, **kwargs + ) + return logger.session diff --git a/ads/llm/autogen/reports/__init__.py b/ads/llm/autogen/reports/__init__.py new file mode 100644 index 000000000..8b5902cd5 --- /dev/null +++ b/ads/llm/autogen/reports/__init__.py @@ -0,0 +1,3 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py new file mode 100644 index 000000000..2624e8090 --- /dev/null +++ b/ads/llm/autogen/reports/session.py @@ -0,0 +1,279 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +"""Module for building session report.""" +import copy +import json +import logging +from typing import Optional + +import fsspec +import plotly.express as px +import pandas as pd +import report_creator as rc +from ads.common.auth import default_signer +from ads.llm.autogen.constants import Events +from ads.llm.autogen.reports.utils import get_duration, is_json_string + + +logger = logging.getLogger(__name__) + + +class SessionReport: + def __init__(self, log_file: str, auth: Optional[dict] = default_signer()) -> None: + self.log_file = log_file + if self.log_file.startswith("oci://"): + with fsspec.open(self.log_file, mode="r", **auth) as f: + self.log_lines = f.readlines() + else: + with open(self.log_file, mode="r", encoding="utf-8") as f: + self.log_lines = f.readlines() + self.logs = self._parse_logs() + self.event_logs = self.get_event_logs() + self.invocation_logs = self._parse_invocation_events() + + @staticmethod + def format_json_string(s): + return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" + + def _parse_logs(self): + logs = [] + for log in self.log_lines: + try: + logs.append(json.loads(log)) + except Exception as e: + continue + return logs + + def _parse_invocation_events(self): + # LLM calls + llm_events = self.filter_event_logs(Events.LLM_CALL) + llm_call_counter = 1 + for event in llm_events: + event["name"] = f"LLM Call {str(llm_call_counter)}" + llm_call_counter += 1 + # Tool Calls + tool_events = self.filter_event_logs(Events.TOOL_CALL) + for event in tool_events: + event["start_time"] = self.estimate_tool_call_start_time(event) + event["name"] = event["tool_name"] + event["end_time"] = event["timestamp"] + + events = sorted(llm_events + tool_events, key=lambda x: x.get("start_time")) + for event in events: + event["duration"] = get_duration(event) + + return events + + def get_event_data(self, event_name: str): + for log in self.logs: + if log.get(Events.KEY) == event_name: + return log + return None + + def filter_event_logs(self, event_name): + filtered_logs = [] + for log in self.logs: + if log.get(Events.KEY) == event_name: + filtered_logs.append(log) + return filtered_logs + + def get_event_logs(self): + event_logs = [] + for log in self.logs: + if Events.KEY in log: + event_logs.append(log) + return sorted( + event_logs, key=lambda x: x.get("timestamp", x.get("end_time", "")) + ) + + def estimate_tool_call_start_time(self, tool_call_log): + event_index = self.event_logs.index(tool_call_log) + while event_index > 0: + log = self.event_logs[event_index] + + if log.get("json_state") and ( + json.loads(log.get("json_state", "")).get("reply_func_name") + == "check_termination_and_human_reply" + ): + return log.get("timestamp") + event_index -= 1 + return None + + def build_timeline_figure(self): + df = pd.DataFrame(self.invocation_logs) + fig = px.timeline( + df, + x_start="start_time", + x_end="end_time", + y="name", + color="duration", + color_continuous_scale="rdylgn_r", + ) + fig.update_layout(showlegend=False) + fig.update_yaxes(autorange="reversed") + return fig + + def format_messages(self, messages): + text = "" + for message in messages: + text += f"**{message.get('role')}**:\n{message.get('content')}\n\n" + return text + + def build_llm_chat(self, llm_log): + request = llm_log.get("request", {}) + source_name = llm_log.get("source_name") + + header = f"{source_name} invoking {request.get('model')}" + + description = f"*{llm_log.get('start_time')}*" + + request_value = f"{str(len(request.get('messages')))} messages" + tools = request.get("tools") + if tools: + request_value += f", {str(len(tools))} tools" + + response = llm_log.get("response") + response_message = response.get("choices")[0].get("message") + response_text = response_message.get("content", "") + tool_calls = response_message.get("tool_calls") + if tool_calls: + response_text += f"\n\n**Tool Calls**:" + for tool_call in tool_calls: + func = tool_call.get("function") + response_text += f"\n\n`{func.get('name')}(**{func.get('arguments')})`" + response_time = get_duration(llm_log) + + return rc.Block( + rc.Text( + description, + label=header, + ), + rc.Group( + rc.Block( + rc.Metric( + heading="Request", + value=request_value, + label=self.format_messages(request.get("messages")), + ), + rc.Collapse( + rc.Json(request), + label="JSON", + ), + ), + rc.Block( + rc.Metric( + heading="Response", + value=response_time, + unit="s", + label=response_text, + ), + rc.Collapse( + rc.Json(response), + label="JSON", + ), + ), + # label=request_header, + ), + ) + + def build_tool_call(self, log: dict): + source_name = log.get("source_name") + header = f"{source_name} invoking {log.get('tool_name')}" + request = copy.deepcopy(log) + response = request.pop("returns", {}) + try: + response = json.loads(response) + except Exception: + pass + + tool_call_args = log.get("input_args", "") + if is_json_string(tool_call_args): + tool_call_args = self.format_json_string(tool_call_args) + + return rc.Block( + rc.Group( + rc.Block( + rc.Metric( + heading="Request", + value=log.get("tool_name"), + label=tool_call_args, + ), + rc.Collapse( + rc.Json(request), + label="JSON", + ), + ), + rc.Block( + rc.Metric( + heading="Response", + value=get_duration(log), + unit="s", + label=str(response), + ), + rc.Collapse( + rc.Json(response), + label="JSON", + ), + ), + label=header, + ), + ) + + def build_invocations(self, logs): + blocks = [] + for log in logs: + event_name = log.get(Events.KEY) + if event_name == Events.LLM_CALL: + blocks.append(self.build_llm_chat(log)) + elif event_name == Events.TOOL_CALL: + blocks.append(self.build_tool_call(log)) + return blocks + + def build(self, output_file: str): + start_event = self.get_event_data(Events.SESSION_START) + start_time = start_event.get("timestamp") + session_id = start_event.get("session_id") + + event_logs = self.get_event_logs() + new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) + llm_call_logs = self.filter_event_logs(Events.LLM_CALL) + tool_call_logs = self.filter_event_logs(Events.TOOL_CALL) + + with rc.ReportCreator( + title=f"AutoGen Session: {session_id}", + description=f"Started at {start_time}", + footer="Created with ❤️ by Oracle ADS", + ) as report: + + view = rc.Block( + rc.Group( + rc.Metric( + heading="Agents", + value=len(new_agent_logs), + ), + rc.Metric( + heading="Events", + value=len(event_logs), + ), + rc.Metric( + heading="LLM Calls", + value=len(llm_call_logs), + ), + rc.Metric( + heading="Tool Calls", + value=len(tool_call_logs), + ), + ), + rc.Select( + blocks=[ + rc.Widget(self.build_timeline_figure(), label="Timeline"), + rc.Block( + *self.build_invocations(self.invocation_logs), + label="Invocations", + ), + ], + ), + ) + + report.save(view, output_file) diff --git a/ads/llm/autogen/reports/utils.py b/ads/llm/autogen/reports/utils.py new file mode 100644 index 000000000..3dc7903b3 --- /dev/null +++ b/ads/llm/autogen/reports/utils.py @@ -0,0 +1,47 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import json +from datetime import datetime + + +def parse_datetime(s): + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S.%f") + + +def get_duration(log: dict) -> float: + """Gets the duration of an event in seconds from a log record. + The log record should contain two keys: `start_time` and `end_time`. + Each of the value should be a time in string format of + `%Y-%m-%d %H:%M:%S.%f` + + The duration is calculated by parsing two strings, and + subtracting the `end_time` from `start_time`. + + If either `start_time` or `end_time` is not presented, + 0 will be returned. + + Parameters + ---------- + log : dict + A log record containing keys: `start_time` and `end_time` + + Returns + ------- + float + Duration in seconds. + """ + if "end_time" not in log or "start_time" not in log: + return 0 + return ( + parse_datetime(log.get("end_time")) - parse_datetime(log.get("start_time")) + ).total_seconds() + + +def is_json_string(s): + """Checks if a string contains valid JSON.""" + try: + json.loads(s) + except Exception: + return False + return True From 004def7e02194e015e37a10fad17080bfe783651 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Wed, 20 Nov 2024 12:20:27 -0500 Subject: [PATCH 08/58] Refactor logging. --- ads/llm/autogen/__init__.py | 3 + ads/llm/autogen/log_handlers/__init__.py | 3 + .../autogen/log_handlers/oci_file_handler.py | 83 +++++++++++++++++++ ads/llm/autogen/report.py | 12 +-- .../{oci_logger.py => session_logger.py} | 74 +---------------- 5 files changed, 98 insertions(+), 77 deletions(-) create mode 100644 ads/llm/autogen/log_handlers/__init__.py create mode 100644 ads/llm/autogen/log_handlers/oci_file_handler.py rename ads/llm/autogen/{oci_logger.py => session_logger.py} (85%) diff --git a/ads/llm/autogen/__init__.py b/ads/llm/autogen/__init__.py index e69de29bb..8b5902cd5 100644 --- a/ads/llm/autogen/__init__.py +++ b/ads/llm/autogen/__init__.py @@ -0,0 +1,3 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. diff --git a/ads/llm/autogen/log_handlers/__init__.py b/ads/llm/autogen/log_handlers/__init__.py new file mode 100644 index 000000000..8b5902cd5 --- /dev/null +++ b/ads/llm/autogen/log_handlers/__init__.py @@ -0,0 +1,3 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. diff --git a/ads/llm/autogen/log_handlers/oci_file_handler.py b/ads/llm/autogen/log_handlers/oci_file_handler.py new file mode 100644 index 000000000..775420afb --- /dev/null +++ b/ads/llm/autogen/log_handlers/oci_file_handler.py @@ -0,0 +1,83 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import io +import json +import logging +import os +import threading +import fsspec +from ads.common.auth import default_signer + + +logger = logging.getLogger(__name__) + + +class OCIFileHandler(logging.FileHandler): + """Log handler for saving log file to OCI object storage.""" + + def __init__( + self, + filename: str, + session_id: str, + mode: str = "a", + encoding: str | None = None, + delay: bool = False, + errors: str | None = None, + auth: dict | None = None, + ) -> None: + self.session_id = session_id + self.auth = auth + + if filename.startswith("oci://"): + self.baseFilename = filename + else: + self.baseFilename = os.path.abspath(os.path.expanduser(filename)) + os.makedirs(os.path.dirname(self.baseFilename), exist_ok=True) + + # The following code are from the `FileHandler.__init__()` + self.mode = mode + self.encoding = encoding + if "b" not in mode: + self.encoding = io.text_encoding(encoding) + self.errors = errors + self.delay = delay + + if delay: + # We don't open the stream, but we still need to call the + # Handler constructor to set level, formatter, lock etc. + logging.Handler.__init__(self) + self.stream = None + else: + logging.StreamHandler.__init__(self, self._open()) + + def _open(self): + """ + Open the current base file with the (original) mode and encoding. + Return the resulting stream. + """ + auth = self.auth or default_signer() + return fsspec.open( + self.baseFilename, + self.mode, + encoding=self.encoding, + errors=self.errors, + **auth, + ).open() + + def format(self, record: logging.LogRecord): + """Formats the log record as JSON payload and add session_id.""" + msg = record.getMessage() + try: + data = json.loads(msg) + except Exception as e: + data = {"message": msg} + + if "session_id" not in data: + data["session_id"] = self.session_id + if "thread_id" not in data: + data["thread_id"] = threading.get_ident() + + record.msg = json.dumps(data) + return super().format(record) + diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py index 024d4d071..c4da65fff 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/report.py @@ -7,7 +7,7 @@ import autogen import autogen.runtime_logging -from ads.llm.autogen.oci_logger import OCIFileLogger +from ads.llm.autogen.session_logger import SessionLogger logger = logging.getLogger(__name__) @@ -46,10 +46,10 @@ def start_logging( """ autogen_logger = autogen.runtime_logging.autogen_logger if autogen_logger is None: - autogen_logger = OCIFileLogger( + autogen_logger = SessionLogger( log_dir=log_dir, session_id=session_id, auth=auth ) - elif isinstance(autogen_logger, OCIFileLogger): + elif isinstance(autogen_logger, SessionLogger): autogen_logger.new_session(log_dir=log_dir, session_id=session_id, auth=auth) elif autogen.runtime_logging.is_logging: raise AutoGenLoggingException( @@ -60,7 +60,7 @@ def start_logging( ) else: logger.warning("Replacing AutoGen logger with OCIFileLogger...") - autogen_logger = OCIFileLogger(log_dir=log_dir, session_id=session_id) + autogen_logger = SessionLogger(log_dir=log_dir, session_id=session_id) return autogen.runtime_logging.start(logger=autogen_logger) @@ -90,12 +90,12 @@ def stop_logging( logger = autogen.runtime_logging.autogen_logger if not report_dir: - if isinstance(logger, OCIFileLogger): + if isinstance(logger, SessionLogger): return logger.session else: return None - if not isinstance(logger, OCIFileLogger): + if not isinstance(logger, SessionLogger): raise NotImplementedError("The logger does not support report generation.") report_file = os.path.join(report_dir, f"{logger.session_id}.html") logger.session.create_report( diff --git a/ads/llm/autogen/oci_logger.py b/ads/llm/autogen/session_logger.py similarity index 85% rename from ads/llm/autogen/oci_logger.py rename to ads/llm/autogen/session_logger.py index 35ece72b4..1ebfc8502 100644 --- a/ads/llm/autogen/oci_logger.py +++ b/ads/llm/autogen/session_logger.py @@ -1,7 +1,6 @@ # coding: utf-8 # Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. -import io import json import logging import os @@ -24,6 +23,7 @@ safe_serialize, to_dict, ) + from oci.object_storage import ObjectStorageClient from oci.object_storage.models import ( CreatePreauthenticatedRequestDetails, @@ -33,6 +33,7 @@ from ads.common.auth import default_signer from ads.llm.autogen.constants import Events from ads.llm.autogen.reports.session import SessionReport +from ads.llm.autogen.log_handlers.oci_file_handler import OCIFileHandler logger = logging.getLogger(__name__) @@ -65,75 +66,6 @@ def serialize_response(response) -> dict: return data -class OCIFileHandler(logging.FileHandler): - """Log handler for saving log file to OCI object storage.""" - - def __init__( - self, - filename: str, - session_id: str, - mode: str = "a", - encoding: str | None = None, - delay: bool = False, - errors: str | None = None, - auth: dict | None = None, - ) -> None: - self.session_id = session_id - self.auth = auth - - if filename.startswith("oci://"): - self.baseFilename = filename - else: - self.baseFilename = os.path.abspath(os.path.expanduser(filename)) - os.makedirs(os.path.dirname(self.baseFilename), exist_ok=True) - - # The following code are from the `FileHandler.__init__()` - self.mode = mode - self.encoding = encoding - if "b" not in mode: - self.encoding = io.text_encoding(encoding) - self.errors = errors - self.delay = delay - - if delay: - # We don't open the stream, but we still need to call the - # Handler constructor to set level, formatter, lock etc. - logging.Handler.__init__(self) - self.stream = None - else: - logging.StreamHandler.__init__(self, self._open()) - - def _open(self): - """ - Open the current base file with the (original) mode and encoding. - Return the resulting stream. - """ - auth = self.auth or default_signer() - return fsspec.open( - self.baseFilename, - self.mode, - encoding=self.encoding, - errors=self.errors, - **auth, - ).open() - - def format(self, record: logging.LogRecord): - """Formats the log record as JSON payload and add session_id.""" - msg = record.getMessage() - try: - data = json.loads(msg) - except Exception as e: - data = {"message": msg} - - if "session_id" not in data: - data["session_id"] = self.session_id - if "thread_id" not in data: - data["thread_id"] = threading.get_ident() - - record.msg = json.dumps(data) - return super().format(record) - - @dataclass class LoggingSession: """Represents a logging session.""" @@ -216,7 +148,7 @@ def create_report(self, report_file: str, return_par_uri: bool = False, **kwargs return report_file -class OCIFileLogger(FileLogger): +class SessionLogger(FileLogger): """Logger for saving log file to OCI object storage.""" def __init__( From 38b02aa3ecd9f95cfcbea11ccb2f5de2642a9687 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Wed, 20 Nov 2024 13:07:59 -0500 Subject: [PATCH 09/58] Update logic for creating new logging session. --- ads/llm/autogen/report.py | 4 +++- ads/llm/autogen/session_logger.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py index c4da65fff..85ade9011 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/report.py @@ -32,8 +32,10 @@ def start_logging( The location to store the logs. session_id : str, optional Session ID for identifying the session, by default None. - If session_id is None, a new UUID4 will be generated. The session ID will be used as the log filename. + If session_id is None, a new UUID4 will be generated. + To resume a session, use a previously generated session_id. + auth: dict, optional Dictionary containing the OCI authentication config and signer. This is only used if log_dir is on object storage. diff --git a/ads/llm/autogen/session_logger.py b/ads/llm/autogen/session_logger.py index 1ebfc8502..5547ea129 100644 --- a/ads/llm/autogen/session_logger.py +++ b/ads/llm/autogen/session_logger.py @@ -13,6 +13,8 @@ from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse +import autogen +import autogen.runtime_logging import fsspec from autogen import Agent, ConversableAgent from autogen.logger.file_logger import ( @@ -229,7 +231,7 @@ def new_session( """ thread_id = threading.get_ident() - if thread_id in self.sessions: + if autogen.runtime_logging.is_logging and thread_id in self.sessions: logger.warning( "An active logging session (ID=%s) is already started in this thread (%s). " "Please stop the active session before starting a new session.", From 7683e0d76cd8f492461cc47103a65938ecc356ad Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Wed, 20 Nov 2024 17:01:17 -0500 Subject: [PATCH 10/58] Support multiple AutoGen loggers. --- ads/llm/autogen/report.py | 80 ++++++--------------- ads/llm/autogen/reports/session.py | 3 +- ads/llm/autogen/runtime_logging.py | 103 +++++++++++++++++++++++++++ ads/llm/autogen/session_logger.py | 109 +++++++++++++++++++++-------- 4 files changed, 204 insertions(+), 91 deletions(-) create mode 100644 ads/llm/autogen/runtime_logging.py diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/report.py index 85ade9011..4ef990636 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/report.py @@ -5,8 +5,7 @@ import os from typing import Optional -import autogen -import autogen.runtime_logging +from ads.llm.autogen import runtime_logging from ads.llm.autogen.session_logger import SessionLogger @@ -18,7 +17,12 @@ class AutoGenLoggingException(Exception): def start_logging( - log_dir: str, session_id: Optional[str] = None, auth: Optional[dict] = None + log_dir: str, + report_dir: Optional[str] = None, + session_id: Optional[str] = None, + auth: Optional[dict] = None, + report_par_uri=False, + **kwargs, ) -> str: """Starts a new logging session. Each thread can only have one logging session. @@ -35,7 +39,7 @@ def start_logging( The session ID will be used as the log filename. If session_id is None, a new UUID4 will be generated. To resume a session, use a previously generated session_id. - + auth: dict, optional Dictionary containing the OCI authentication config and signer. This is only used if log_dir is on object storage. @@ -46,61 +50,17 @@ def start_logging( str Session ID """ - autogen_logger = autogen.runtime_logging.autogen_logger - if autogen_logger is None: - autogen_logger = SessionLogger( - log_dir=log_dir, session_id=session_id, auth=auth - ) - elif isinstance(autogen_logger, SessionLogger): - autogen_logger.new_session(log_dir=log_dir, session_id=session_id, auth=auth) - elif autogen.runtime_logging.is_logging: - raise AutoGenLoggingException( - "AutoGen is currently logging with a different logger. " - "Only one logger can be active at a time. " - "Please call `autogen.runtime_logging.stop()` to stop logging " - "before starting a new session." - ) - else: - logger.warning("Replacing AutoGen logger with OCIFileLogger...") - autogen_logger = SessionLogger(log_dir=log_dir, session_id=session_id) - return autogen.runtime_logging.start(logger=autogen_logger) - - -def stop_logging( - report_dir: str = None, return_par_uri: bool = False, **kwargs -) -> Optional[str]: - """Stops the logging session. - - Parameters - ---------- - report_dir : str, optional - Directory for saving the session report, by default None. - If `report_dir` is None, no report will be created. - return_par_uri: bool, optional - For report_dir on OCI object storage only, - whether to create and return a pre-authenticated uri for the report. - Defaults to False. - - Returns - ------- - str - The full filename of the report, if `report_dir` is provided. - Otherwise, None. - - """ - autogen.runtime_logging.stop() - logger = autogen.runtime_logging.autogen_logger + autogen_logger = SessionLogger( + log_dir=log_dir, + report_dir=report_dir, + session_id=session_id, + auth=auth, + report_par_uri=report_par_uri, + par_kwargs=kwargs, + ) + return runtime_logging.start(logger=autogen_logger) - if not report_dir: - if isinstance(logger, SessionLogger): - return logger.session - else: - return None - if not isinstance(logger, SessionLogger): - raise NotImplementedError("The logger does not support report generation.") - report_file = os.path.join(report_dir, f"{logger.session_id}.html") - logger.session.create_report( - report_file=report_file, return_par_uri=return_par_uri, **kwargs - ) - return logger.session +def stop_logging(): + """Stops the logging session.""" + return runtime_logging.stop() diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 2624e8090..e10ff816a 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -20,7 +20,8 @@ class SessionReport: - def __init__(self, log_file: str, auth: Optional[dict] = default_signer()) -> None: + def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: + auth = auth or default_signer() self.log_file = log_file if self.log_file.startswith("oci://"): with fsspec.open(self.log_file, mode="r", **auth) as f: diff --git a/ads/llm/autogen/runtime_logging.py b/ads/llm/autogen/runtime_logging.py new file mode 100644 index 000000000..e859c5bde --- /dev/null +++ b/ads/llm/autogen/runtime_logging.py @@ -0,0 +1,103 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import logging +from sqlite3 import Connection +from typing import Any, Dict, List, Optional + +import autogen.runtime_logging +from autogen.logger.base_logger import BaseLogger +from autogen.logger.logger_factory import LoggerFactory + + +module_logger = logging.getLogger(__name__) + + +class LoggerManager(BaseLogger): + + def __init__(self) -> None: + self.loggers: List[BaseLogger] = [] + super().__init__() + + def add_logger(self, logger: BaseLogger) -> None: + """Adds a new AutoGen logger.""" + self.loggers.append(logger) + + def call_loggers(self, method, *args, **kwargs) -> None: + for autogen_logger in self.loggers: + getattr(autogen_logger, method)(*args, **kwargs) + + def start(self) -> str: + return self.call_loggers("start") + + def stop(self) -> None: + return self.call_loggers("stop") + + def get_connection(self) -> None | Connection: + return self.call_loggers("get_connection") + + def log_chat_completion(self, *args, **kwargs) -> None: + return self.call_loggers("log_chat_completion", *args, **kwargs) + + def log_new_agent(self, *args, **kwargs) -> None: + return self.call_loggers("log_new_agent", *args, **kwargs) + + def log_event(self, *args, **kwargs) -> None: + return self.call_loggers("log_event", *args, **kwargs) + + def log_new_wrapper(self, *args, **kwargs) -> None: + return self.call_loggers("log_new_wrapper", *args, **kwargs) + + def log_new_client(self, *args, **kwargs) -> None: + return self.call_loggers("log_new_client", *args, **kwargs) + + def log_function_use(self, *args, **kwargs) -> None: + return self.call_loggers("log_function_use", *args, **kwargs) + + def __repr__(self) -> str: + return "\n".join([logger.__repr__() for logger in self.loggers]) + + +def start( + logger: Optional[BaseLogger] = None, + logger_type: str = None, + config: Optional[Dict[str, Any]] = None, +) -> str: + if logger and logger_type: + raise ValueError( + "Please specify only logger(%s) or logger_type(%s).", logger, logger_type + ) + + # Check if a logger is already configured + autogen_logger = autogen.runtime_logging.autogen_logger + if not autogen_logger: + logger_manager = LoggerManager() + elif not isinstance(autogen_logger, LoggerManager): + module_logger.warning( + "AutoGen is already configured with %s", str(autogen_logger) + ) + logger_manager = LoggerManager() + logger_manager.add_logger(autogen_logger) + + # Add AutoGen logger + if logger: + autogen_logger = logger + else: + autogen_logger = LoggerFactory.get_logger( + logger_type=logger_type, config=config + ) + logger_manager.add_logger(autogen_logger) + + try: + session_id = autogen_logger.start() + autogen.runtime_logging.is_logging = True + autogen.runtime_logging.autogen_logger = logger_manager + except Exception as e: + logger.error(f"Failed to start logging: {e}") + finally: + return session_id + + +def stop(): + autogen.runtime_logging.stop() + return autogen.runtime_logging.autogen_logger diff --git a/ads/llm/autogen/session_logger.py b/ads/llm/autogen/session_logger.py index 5547ea129..a244b9bc5 100644 --- a/ads/llm/autogen/session_logger.py +++ b/ads/llm/autogen/session_logger.py @@ -13,8 +13,6 @@ from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse -import autogen -import autogen.runtime_logging import fsspec from autogen import Agent, ConversableAgent from autogen.logger.file_logger import ( @@ -82,11 +80,17 @@ class LoggingSession: report_file: Optional[str] = None par_uri: Optional[str] = None - def __repr__(self) -> str: + @property + def report(self) -> str: if self.par_uri: return self.par_uri elif self.report_file: return self.report_file + return None + + def __repr__(self) -> str: + if self.report: + return self.report return self.log_file def create_par_uri(self, oci_file: str, **kwargs) -> str: @@ -156,8 +160,12 @@ class SessionLogger(FileLogger): def __init__( self, log_dir: str, + report_dir: Optional[str] = None, session_id: Optional[str] = None, auth: Optional[dict] = None, + log_for_all_threads: str = False, + report_par_uri: bool = False, + par_kwargs: Optional[dict] = None, ): """Initialize a file logger for new session. @@ -172,30 +180,36 @@ def __init__( auth: dict, optional Dictionary containing the OCI authentication config and signer. If auth is None, `ads.common.auth.default_signer()` will be used. + log_for_all_threads: + Indicate if the logger should handle logging for all threads. + Defaults to False, the logger will only log for the current thread. """ - self.sessions: Dict[int, LoggingSession] = {} - self.new_session(log_dir=log_dir, session_id=session_id, auth=auth) + self.report_dir = report_dir + self.report_par_uri = report_par_uri + self.par_kwargs = par_kwargs + self.log_for_all_threads = log_for_all_threads - @property - def session(self) -> Optional[LoggingSession]: - """Session for the current thread.""" - return self.sessions.get(threading.get_ident()) + self.session = self.new_session( + log_dir=log_dir, session_id=session_id, auth=auth + ) @property def logger(self) -> Optional[str]: - """Logger for the current thread.""" - session = self.sessions.get(threading.get_ident()) - return session.logger if session else None + """Logger for the thread.""" + thread_id = threading.get_ident() + if not self.log_for_all_threads and thread_id != self.session.thread_id: + return None + return self.session.logger @property def session_id(self) -> Optional[str]: - """Session ID for the current thread.""" - return self.sessions[threading.get_ident()].session_id + """Session ID for the current session.""" + return self.session.session_id @property def log_file(self) -> Optional[str]: """Log file path for the current session.""" - return self.sessions[threading.get_ident()].log_file + return self.session.log_file @property def name(self) -> Optional[str]: @@ -206,7 +220,7 @@ def new_session( log_dir: str, session_id: Optional[str] = None, auth: Optional[dict] = None, - ) -> str: + ) -> LoggingSession: """Creates a new logging session. If an active logging session is already started in the thread, the existing session will be used. @@ -226,20 +240,11 @@ def new_session( Returns ------- - str - session ID + LoggingSession + The new logging session """ thread_id = threading.get_ident() - if autogen.runtime_logging.is_logging and thread_id in self.sessions: - logger.warning( - "An active logging session (ID=%s) is already started in this thread (%s). " - "Please stop the active session before starting a new session.", - self.session_id, - thread_id, - ) - return self.session_id - session_id = session_id or str(uuid.uuid4()) log_file = os.path.join(log_dir, f"{session_id}.log") @@ -250,7 +255,7 @@ def new_session( session_logger.addHandler(file_handler) # Create logging session - self.sessions[thread_id] = LoggingSession( + session = LoggingSession( session_id=session_id, log_dir=log_dir, log_file=log_file, @@ -261,7 +266,22 @@ def new_session( ) logger.info("Start logging session %s to file %s", session_id, log_file) - return session_id + return session + + def generate_report( + self, + report_dir: Optional[str] = None, + report_par_uri: Optional[bool] = None, + **kwargs, + ) -> str: + report_dir = report_dir or self.report_dir + report_par_uri = report_par_uri if report_par_uri is not None else self.report_par_uri + kwargs = kwargs or self.par_kwargs or {} + + report_file = os.path.join(self.report_dir, f"{self.session_id}.html") + return self.session.create_report( + report_file=report_file, return_par_uri=self.report_par_uri, **kwargs + ) def start(self) -> str: """Start the logging session and return the session_id.""" @@ -271,7 +291,9 @@ def start(self) -> str: def stop(self) -> None: """Stops the logging session.""" self.log_event(source=self, name=Events.SESSION_STOP) - return super().stop() + super().stop() + if self.report_dir: + return self.generate_report() def log_chat_completion( self, @@ -288,6 +310,9 @@ def log_chat_completion( """ Log a chat completion. """ + if not self.logger: + return + thread_id = threading.get_ident() source_name = None if isinstance(source, str): @@ -323,6 +348,9 @@ def log_function_use( """ Log a registered function(can be a tool) use from an agent or a string source. """ + if not self.logger: + return + thread_id = threading.get_ident() try: @@ -353,6 +381,9 @@ def log_new_agent( """ Log a new agent instance. """ + if not self.logger: + return + thread_id = threading.get_ident() try: @@ -380,3 +411,21 @@ def log_new_agent( self.logger.info(log_data) except Exception as e: self.logger.error(f"[file_logger] Failed to log new agent: {e}") + + def log_event(self, *args, **kwargs) -> None: + if not self.logger: + return + return super().log_event(*args, **kwargs) + + def log_new_wrapper(self, *args, **kwargs) -> None: + if not self.logger: + return + return super().log_new_wrapper(*args, **kwargs) + + def log_new_client(self, *args, **kwargs) -> None: + if not self.logger: + return + return super().log_new_wrapper(*args, **kwargs) + + def __repr__(self) -> str: + return self.session.__repr__() From 45caba944388167e6cb301f6186f2edc2c83f7d2 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 21 Nov 2024 10:51:28 -0500 Subject: [PATCH 11/58] Refactor code into ads.llm.autogen.v02 --- ads/llm/autogen/reports/session.py | 18 ++++++++++++++++-- ads/llm/autogen/v02/__init__.py | 5 +++++ .../autogen/{client_v02.py => v02/client.py} | 0 ads/llm/autogen/{ => v02}/constants.py | 0 .../autogen/{ => v02}/log_handlers/__init__.py | 0 .../{ => v02}/log_handlers/oci_file_handler.py | 0 ads/llm/autogen/{ => v02}/report.py | 4 ++-- ads/llm/autogen/{ => v02}/runtime_logging.py | 4 +++- ads/llm/autogen/{ => v02}/session_logger.py | 4 ++-- .../with_extras/autogen/test_autogen_client.py | 2 +- 10 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 ads/llm/autogen/v02/__init__.py rename ads/llm/autogen/{client_v02.py => v02/client.py} (100%) rename ads/llm/autogen/{ => v02}/constants.py (100%) rename ads/llm/autogen/{ => v02}/log_handlers/__init__.py (100%) rename ads/llm/autogen/{ => v02}/log_handlers/oci_file_handler.py (100%) rename ads/llm/autogen/{ => v02}/report.py (94%) rename ads/llm/autogen/{ => v02}/runtime_logging.py (96%) rename ads/llm/autogen/{ => v02}/session_logger.py (99%) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index e10ff816a..1a2832e1e 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -12,7 +12,7 @@ import pandas as pd import report_creator as rc from ads.common.auth import default_signer -from ads.llm.autogen.constants import Events +from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.reports.utils import get_duration, is_json_string @@ -21,9 +21,9 @@ class SessionReport: def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: - auth = auth or default_signer() self.log_file = log_file if self.log_file.startswith("oci://"): + auth = auth or default_signer() with fsspec.open(self.log_file, mode="r", **auth) as f: self.log_lines = f.readlines() else: @@ -231,6 +231,19 @@ def build_invocations(self, logs): blocks.append(self.build_tool_call(log)) return blocks + def build_chats(self): + return rc.Block( + rc.Group( + rc.Block(), + rc.Markdown("# A\nsaying something"), + ), + rc.Group( + rc.Markdown("# A\nsaying something"), + rc.Block(), + ), + label="Chats", + ) + def build(self, output_file: str): start_event = self.get_event_data(Events.SESSION_START) start_time = start_event.get("timestamp") @@ -273,6 +286,7 @@ def build(self, output_file: str): *self.build_invocations(self.invocation_logs), label="Invocations", ), + self.build_chats(), ], ), ) diff --git a/ads/llm/autogen/v02/__init__.py b/ads/llm/autogen/v02/__init__.py new file mode 100644 index 000000000..7f4dd811a --- /dev/null +++ b/ads/llm/autogen/v02/__init__.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. + +from ads.llm.autogen.v02.client import LangChainModelClient, register_custom_client diff --git a/ads/llm/autogen/client_v02.py b/ads/llm/autogen/v02/client.py similarity index 100% rename from ads/llm/autogen/client_v02.py rename to ads/llm/autogen/v02/client.py diff --git a/ads/llm/autogen/constants.py b/ads/llm/autogen/v02/constants.py similarity index 100% rename from ads/llm/autogen/constants.py rename to ads/llm/autogen/v02/constants.py diff --git a/ads/llm/autogen/log_handlers/__init__.py b/ads/llm/autogen/v02/log_handlers/__init__.py similarity index 100% rename from ads/llm/autogen/log_handlers/__init__.py rename to ads/llm/autogen/v02/log_handlers/__init__.py diff --git a/ads/llm/autogen/log_handlers/oci_file_handler.py b/ads/llm/autogen/v02/log_handlers/oci_file_handler.py similarity index 100% rename from ads/llm/autogen/log_handlers/oci_file_handler.py rename to ads/llm/autogen/v02/log_handlers/oci_file_handler.py diff --git a/ads/llm/autogen/report.py b/ads/llm/autogen/v02/report.py similarity index 94% rename from ads/llm/autogen/report.py rename to ads/llm/autogen/v02/report.py index 4ef990636..f30737761 100644 --- a/ads/llm/autogen/report.py +++ b/ads/llm/autogen/v02/report.py @@ -5,8 +5,8 @@ import os from typing import Optional -from ads.llm.autogen import runtime_logging -from ads.llm.autogen.session_logger import SessionLogger +from ads.llm.autogen.v02 import runtime_logging +from ads.llm.autogen.v02.session_logger import SessionLogger logger = logging.getLogger(__name__) diff --git a/ads/llm/autogen/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py similarity index 96% rename from ads/llm/autogen/runtime_logging.py rename to ads/llm/autogen/v02/runtime_logging.py index e859c5bde..4491654b8 100644 --- a/ads/llm/autogen/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -55,7 +55,9 @@ def log_function_use(self, *args, **kwargs) -> None: return self.call_loggers("log_function_use", *args, **kwargs) def __repr__(self) -> str: - return "\n".join([logger.__repr__() for logger in self.loggers]) + return "\n\n".join( + [f"{str(logger)}\n{logger.__repr__()}" for logger in self.loggers] + ) def start( diff --git a/ads/llm/autogen/session_logger.py b/ads/llm/autogen/v02/session_logger.py similarity index 99% rename from ads/llm/autogen/session_logger.py rename to ads/llm/autogen/v02/session_logger.py index a244b9bc5..b96e0e02a 100644 --- a/ads/llm/autogen/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -31,9 +31,9 @@ ) from ads.common.auth import default_signer -from ads.llm.autogen.constants import Events +from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.reports.session import SessionReport -from ads.llm.autogen.log_handlers.oci_file_handler import OCIFileHandler +from ads.llm.autogen.v02.log_handlers.oci_file_handler import OCIFileHandler logger = logging.getLogger(__name__) diff --git a/tests/unitary/with_extras/autogen/test_autogen_client.py b/tests/unitary/with_extras/autogen/test_autogen_client.py index c8cce9121..57700ac9a 100644 --- a/tests/unitary/with_extras/autogen/test_autogen_client.py +++ b/tests/unitary/with_extras/autogen/test_autogen_client.py @@ -11,7 +11,7 @@ import autogen from langchain_core.messages import AIMessage, ToolCall -from ads.llm.autogen.client_v02 import ( +from ads.llm.autogen.v02.client import ( LangChainModelClient, register_custom_client, custom_clients, From 82b067d785fc263ba23cb7391b9a2e7c3ae67414 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 21 Nov 2024 13:45:57 -0500 Subject: [PATCH 12/58] Update logger repr. --- ads/llm/autogen/v02/runtime_logging.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index 4491654b8..49a9cf20c 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -56,7 +56,10 @@ def log_function_use(self, *args, **kwargs) -> None: def __repr__(self) -> str: return "\n\n".join( - [f"{str(logger)}\n{logger.__repr__()}" for logger in self.loggers] + [ + f"{str(logger.__class__)}:\n{logger.__repr__()}" + for logger in self.loggers + ] ) From 209b5fabb21b00362d88e465488a042b9c3a75bb Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 21 Nov 2024 13:50:47 -0500 Subject: [PATCH 13/58] Disable chat tab. --- ads/llm/autogen/reports/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 1a2832e1e..dcb083a38 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -286,7 +286,7 @@ def build(self, output_file: str): *self.build_invocations(self.invocation_logs), label="Invocations", ), - self.build_chats(), + # self.build_chats(), ], ), ) From c2aa1dad41ac0d584ef805af6e4e0e6f4cc66ed2 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 22 Nov 2024 17:15:31 -0500 Subject: [PATCH 14/58] Add Chat tab. --- ads/llm/autogen/reports/session.py | 65 +++++++++++++++---- .../reports/templates/chat_box_lt.html | 17 +++++ .../reports/templates/chat_box_rt.html | 16 +++++ 3 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 ads/llm/autogen/reports/templates/chat_box_lt.html create mode 100644 ads/llm/autogen/reports/templates/chat_box_rt.html diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index dcb083a38..3c318ba34 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -5,12 +5,14 @@ import copy import json import logging -from typing import Optional +import os +from typing import Optional, List import fsspec import plotly.express as px import pandas as pd import report_creator as rc +from jinja2 import Environment, FileSystemLoader from ads.common.auth import default_signer from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.reports.utils import get_duration, is_json_string @@ -37,6 +39,15 @@ def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: def format_json_string(s): return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" + @staticmethod + def _apply_template(template_path, **kwargs): + template_dir = os.path.join(os.path.dirname(__file__), "templates") + environment = Environment( + loader=FileSystemLoader(template_dir), autoescape=True + ) + template = environment.get_template(template_path) + return template.render(**kwargs) + def _parse_logs(self): logs = [] for log in self.log_lines: @@ -72,7 +83,7 @@ def get_event_data(self, event_name: str): return log return None - def filter_event_logs(self, event_name): + def filter_event_logs(self, event_name) -> List[dict]: filtered_logs = [] for log in self.logs: if log.get(Events.KEY) == event_name: @@ -232,15 +243,47 @@ def build_invocations(self, logs): return blocks def build_chats(self): + logs = self.filter_event_logs("received_message") + if not logs: + return rc.Text("No messages received in this session.") + states: List[dict] = [json.loads(log.get("json_state", "{}")) for log in logs] + for i, state in enumerate(states): + state.update(logs[i]) + # The agent sending the first message will be placed on the right. + # All other agents will be placed on the left + host = states[0].get("sender") + blocks = [] + for log in states: + sender = log.get("sender") + message = log.get("message") + # Content + if isinstance(message, dict) and "content" in message: + content = message.get("content") + if is_json_string(content): + log["json_content"] = json.dumps(json.loads(content), indent=2) + log["content"] = content + else: + log["content"] = message + # Tool call + if isinstance(message, dict) and "tool_calls" in message: + tool_calls = message.get("tool_calls") + if tool_calls: + tool_call_signatures = [] + for tool_call in tool_calls: + func = tool_call.get("function") + if not func: + continue + tool_call_signatures.append( + f'{func.get("name")}(**{func.get("arguments", "{}")})' + ) + log["tool_calls"] = tool_call_signatures + if sender == host: + html = self._apply_template("chat_box_rt.html", **log) + else: + html = self._apply_template("chat_box_lt.html", **log) + blocks.append(rc.Html(html)) return rc.Block( - rc.Group( - rc.Block(), - rc.Markdown("# A\nsaying something"), - ), - rc.Group( - rc.Markdown("# A\nsaying something"), - rc.Block(), - ), + *blocks, label="Chats", ) @@ -286,7 +329,7 @@ def build(self, output_file: str): *self.build_invocations(self.invocation_logs), label="Invocations", ), - # self.build_chats(), + self.build_chats(), ], ), ) diff --git a/ads/llm/autogen/reports/templates/chat_box_lt.html b/ads/llm/autogen/reports/templates/chat_box_lt.html new file mode 100644 index 000000000..23433bcc3 --- /dev/null +++ b/ads/llm/autogen/reports/templates/chat_box_lt.html @@ -0,0 +1,17 @@ +
+
+

{{ sender }}

+ {% if json_content %} +
{{ json_content }}
+ {% else%} +

{{ content }}

+ {% endif %} + {% if tool_calls %} + {% for tool_call in tool_calls %} +
{{ tool_call }}
+ {% endfor %} + {% endif %} + +

{{ timestamp }}

+
+
\ No newline at end of file diff --git a/ads/llm/autogen/reports/templates/chat_box_rt.html b/ads/llm/autogen/reports/templates/chat_box_rt.html new file mode 100644 index 000000000..2d2f1a7fb --- /dev/null +++ b/ads/llm/autogen/reports/templates/chat_box_rt.html @@ -0,0 +1,16 @@ +
+
+

{{ sender }}

+ {% if json_content %} +
{{ json_content }}
+ {% else%} +

{{ content }}

+ {% endif %} + {% if tool_calls %} + {% for tool_call in tool_calls %} +
{{ tool_call }}
+ {% endfor %} + {% endif %} +

{{ timestamp }}

+
+
\ No newline at end of file From 8c608ef00546133c192494d81462e84ada8ed197 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 25 Nov 2024 11:54:19 -0500 Subject: [PATCH 15/58] Handle chat rendering error. --- ads/llm/autogen/reports/session.py | 42 ++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 3c318ba34..8d17d3fa8 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -40,13 +40,27 @@ def format_json_string(s): return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" @staticmethod - def _apply_template(template_path, **kwargs): + def _apply_template(template_path, **kwargs) -> str: template_dir = os.path.join(os.path.dirname(__file__), "templates") environment = Environment( loader=FileSystemLoader(template_dir), autoescape=True ) template = environment.get_template(template_path) - return template.render(**kwargs) + try: + html = template.render(**kwargs) + except Exception: + logger.error( + "Unable to render template %s with data:\n%s", + template_path, + str(kwargs), + ) + return SessionReport._apply_template( + template_path=template_path, + sender=kwargs.get("sender", "N/A"), + content="TEMPLATE RENDER ERROR", + timestamp=kwargs.get("timestamp", ""), + ) + return html def _parse_logs(self): logs = [] @@ -112,7 +126,7 @@ def estimate_tool_call_start_time(self, tool_call_log): event_index -= 1 return None - def build_timeline_figure(self): + def build_timeline_tab(self): df = pd.DataFrame(self.invocation_logs) fig = px.timeline( df, @@ -124,7 +138,7 @@ def build_timeline_figure(self): ) fig.update_layout(showlegend=False) fig.update_yaxes(autorange="reversed") - return fig + return rc.Widget(fig, label="Timeline") def format_messages(self, messages): text = "" @@ -232,17 +246,20 @@ def build_tool_call(self, log: dict): ), ) - def build_invocations(self, logs): + def build_invocations_tab(self): blocks = [] - for log in logs: + for log in self.invocation_logs: event_name = log.get(Events.KEY) if event_name == Events.LLM_CALL: blocks.append(self.build_llm_chat(log)) elif event_name == Events.TOOL_CALL: blocks.append(self.build_tool_call(log)) - return blocks + return rc.Block( + *blocks, + label="Invocations", + ) - def build_chats(self): + def build_chat_tab(self): logs = self.filter_event_logs("received_message") if not logs: return rc.Text("No messages received in this session.") @@ -324,12 +341,9 @@ def build(self, output_file: str): ), rc.Select( blocks=[ - rc.Widget(self.build_timeline_figure(), label="Timeline"), - rc.Block( - *self.build_invocations(self.invocation_logs), - label="Invocations", - ), - self.build_chats(), + self.build_timeline_tab(), + self.build_invocations_tab(), + self.build_chat_tab(), ], ), ) From cc7f18678e66d010a0f23805289d34108c513e26 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 25 Nov 2024 13:39:28 -0500 Subject: [PATCH 16/58] Add logs tab. --- ads/llm/autogen/reports/session.py | 48 ++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 8d17d3fa8..3254efc88 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -36,7 +36,7 @@ def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: self.invocation_logs = self._parse_invocation_events() @staticmethod - def format_json_string(s): + def format_json_string(s) -> str: return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" @staticmethod @@ -62,7 +62,22 @@ def _apply_template(template_path, **kwargs) -> str: ) return html - def _parse_logs(self): + @staticmethod + def _preview_message(message: str, max_length=30) -> str: + # Return the entire string if it is less than the max_length + if len(message) <= max_length: + return message + # Go backward until we find the first whitespace + idx = 30 + while not message[idx].isspace() and idx > 0: + idx -= 1 + # If we found a whitespace + if idx > 0: + return message[:idx] + "..." + # If we didn't find a whitespace + return message[:30] + "..." + + def _parse_logs(self) -> List[dict]: logs = [] for log in self.log_lines: try: @@ -146,7 +161,7 @@ def format_messages(self, messages): text += f"**{message.get('role')}**:\n{message.get('content')}\n\n" return text - def build_llm_chat(self, llm_log): + def build_llm_call(self, llm_log): request = llm_log.get("request", {}) source_name = llm_log.get("source_name") @@ -246,12 +261,12 @@ def build_tool_call(self, log: dict): ), ) - def build_invocations_tab(self): + def build_invocations_tab(self) -> rc.Block: blocks = [] for log in self.invocation_logs: event_name = log.get(Events.KEY) if event_name == Events.LLM_CALL: - blocks.append(self.build_llm_chat(log)) + blocks.append(self.build_llm_call(log)) elif event_name == Events.TOOL_CALL: blocks.append(self.build_tool_call(log)) return rc.Block( @@ -259,7 +274,7 @@ def build_invocations_tab(self): label="Invocations", ) - def build_chat_tab(self): + def build_chat_tab(self) -> rc.Block: logs = self.filter_event_logs("received_message") if not logs: return rc.Text("No messages received in this session.") @@ -304,6 +319,26 @@ def build_chat_tab(self): label="Chats", ) + def build_logs_tab(self) -> rc.Block: + blocks = [] + for log_line in self.log_lines: + if is_json_string(log_line): + log = json.loads(log_line) + label = log.get( + "event_name", self._preview_message(log.get("message", "")) + ) + blocks.append(rc.Collapse(rc.Json(log), label=label)) + else: + log = log_line + blocks.append( + rc.Collapse(rc.Text(log), label=self._preview_message(log_line)) + ) + + return rc.Block( + *blocks, + label="Logs", + ) + def build(self, output_file: str): start_event = self.get_event_data(Events.SESSION_START) start_time = start_event.get("timestamp") @@ -344,6 +379,7 @@ def build(self, output_file: str): self.build_timeline_tab(), self.build_invocations_tab(), self.build_chat_tab(), + self.build_logs_tab(), ], ), ) From f35a995dba23f5a520d6d11ff6cb6c2a1859a80d Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 25 Nov 2024 15:34:44 -0500 Subject: [PATCH 17/58] Log library versions. --- ads/llm/autogen/v02/session_logger.py | 72 ++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index b96e0e02a..48ff805fc 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -1,6 +1,7 @@ # coding: utf-8 # Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import importlib import json import logging import os @@ -13,7 +14,9 @@ from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse +import autogen import fsspec +import oci from autogen import Agent, ConversableAgent from autogen.logger.file_logger import ( ChatCompletion, @@ -23,13 +26,13 @@ safe_serialize, to_dict, ) - from oci.object_storage import ObjectStorageClient from oci.object_storage.models import ( CreatePreauthenticatedRequestDetails, PreauthenticatedRequest, ) +import ads from ads.common.auth import default_signer from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.reports.session import SessionReport @@ -131,7 +134,25 @@ def create_par_uri(self, oci_file: str, **kwargs) -> str: ).data return response.full_path - def create_report(self, report_file: str, return_par_uri: bool = False, **kwargs): + def create_report( + self, report_file: str, return_par_uri: bool = False, **kwargs + ) -> str: + """Creates a report in HTML format. + + Parameters + ---------- + report_file : str + The file path to save the report. + return_par_uri : bool, optional + If the report is saved in object storage, + whether to create a pre-authenticated link for the report, by default False. + This will be ignored if the report is not saved in object storage. + + Returns + ------- + str + The full path or pre-authenticated link of the report. + """ auth = self.auth or default_signer() report = SessionReport(log_file=self.log_file, auth=auth) if report_file.startswith("oci://"): @@ -212,7 +233,8 @@ def log_file(self) -> Optional[str]: return self.session.log_file @property - def name(self) -> Optional[str]: + def name(self) -> str: + """Name of the logger.""" return self.session_id or "oci_file_logger" def new_session( @@ -274,10 +296,29 @@ def generate_report( report_par_uri: Optional[bool] = None, **kwargs, ) -> str: + """Generates a report for the session. + + Parameters + ---------- + report_dir : str, optional + Directory for saving the report, by default None + report_par_uri : bool, optional + Whether to create a pre-authenticated link for the report, by default None. + If the `report_par_uri` is not set, the value of `self.report_par_uri` will be used. + + Returns + ------- + str + The link to the report. + If the `report_dir` is local, the local file path will be returned. + If a pre-authenticated link is created, the link will be returned. + """ report_dir = report_dir or self.report_dir - report_par_uri = report_par_uri if report_par_uri is not None else self.report_par_uri + report_par_uri = ( + report_par_uri if report_par_uri is not None else self.report_par_uri + ) kwargs = kwargs or self.par_kwargs or {} - + report_file = os.path.join(self.report_dir, f"{self.session_id}.html") return self.session.create_report( report_file=report_file, return_par_uri=self.report_par_uri, **kwargs @@ -285,7 +326,26 @@ def generate_report( def start(self) -> str: """Start the logging session and return the session_id.""" - self.log_event(source=self, name=Events.SESSION_START) + envs = { + "oracle-ads": ads.__version__, + "oci": oci.__version__, + "autogen": autogen.__version__, + } + libraries = [ + "langchain", + "langchain-core", + "langchain-community", + "langchain-openai", + "openai", + ] + for library in libraries: + try: + imported_library = importlib.import_module(library) + version = imported_library.__version__ + envs[library] = version + except Exception: + pass + self.log_event(source=self, name=Events.SESSION_START, environment=envs) return self.session_id def stop(self) -> None: From 4c0ca6f78f4b6b9ba1e53f6073541c3b0ba8a97b Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 25 Nov 2024 16:10:58 -0500 Subject: [PATCH 18/58] Create report_dir when it does not exist. --- ads/llm/autogen/v02/session_logger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index 48ff805fc..3abdd0860 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -170,6 +170,7 @@ def create_report( return par_uri else: report_file = os.path.abspath(os.path.expanduser(report_file)) + os.makedirs(os.path.dirname(report_file), exist_ok=True) report.build(report_file) self.report_file = report_file return report_file From ab64b623509b658956ea0a66b693efd48cfdf678 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 25 Nov 2024 16:11:14 -0500 Subject: [PATCH 19/58] Fix error when there is no LLM call. --- ads/llm/autogen/reports/session.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 3254efc88..e2915aa4b 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -142,6 +142,8 @@ def estimate_tool_call_start_time(self, tool_call_log): return None def build_timeline_tab(self): + if not self.invocation_logs: + return rc.Text("No LLM or Tool Calls.", label="Timeline") df = pd.DataFrame(self.invocation_logs) fig = px.timeline( df, From 43284879f2ae7d9518c1f5f98c1ae53949185959 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 2 Dec 2024 13:12:41 -0500 Subject: [PATCH 20/58] Fix error when starting multiple loggers. --- ads/llm/autogen/v02/runtime_logging.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index 49a9cf20c..74886ab75 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -76,13 +76,18 @@ def start( # Check if a logger is already configured autogen_logger = autogen.runtime_logging.autogen_logger if not autogen_logger: + # No logger is configured logger_manager = LoggerManager() elif not isinstance(autogen_logger, LoggerManager): + # Logger is configured but it is not via ADS module_logger.warning( "AutoGen is already configured with %s", str(autogen_logger) ) logger_manager = LoggerManager() logger_manager.add_logger(autogen_logger) + else: + # Logger is already configured with ADS + logger_manager = autogen_logger # Add AutoGen logger if logger: From cd600a41e03ea2fbeaddfee7a775c4d7ddba9966 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 2 Dec 2024 13:14:02 -0500 Subject: [PATCH 21/58] Add usage property to client response. --- ads/llm/autogen/v02/client.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ads/llm/autogen/v02/client.py b/ads/llm/autogen/v02/client.py index 300a2878e..1dd3672c2 100644 --- a/ads/llm/autogen/v02/client.py +++ b/ads/llm/autogen/v02/client.py @@ -72,6 +72,8 @@ import importlib import json import logging +from dataclasses import asdict, dataclass +from types import SimpleNamespace from typing import Any, Dict, List, Union from types import SimpleNamespace @@ -177,6 +179,13 @@ def function_call(self): return self.tool_calls +@dataclass +class Usage: + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + class LangChainModelClient(ModelClient): """Represents a model client wrapping a LangChain chat model.""" @@ -248,6 +257,7 @@ def create(self, params) -> ModelClient.ModelClientResponseProtocol: response = SimpleNamespace() response.choices = [] response.model = self.model_name + response.usage = Usage() if streaming and messages: # If streaming is enabled and has messages, then iterate over the chunks of the response. @@ -278,4 +288,4 @@ def cost(self, response: ModelClient.ModelClientResponseProtocol) -> float: @staticmethod def get_usage(response: ModelClient.ModelClientResponseProtocol) -> Dict: """Return usage summary of the response using RESPONSE_USAGE_KEYS.""" - return {} + return asdict(response.usage) From 57e60c48607ac24c203568f74c05f938f63e7c28 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 2 Dec 2024 14:45:59 -0500 Subject: [PATCH 22/58] Fix log_new_client() bug in session_logger. --- ads/llm/autogen/v02/session_logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index 3abdd0860..0fbafcbec 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -486,7 +486,7 @@ def log_new_wrapper(self, *args, **kwargs) -> None: def log_new_client(self, *args, **kwargs) -> None: if not self.logger: return - return super().log_new_wrapper(*args, **kwargs) + return super().log_new_client(*args, **kwargs) def __repr__(self) -> str: return self.session.__repr__() From 67c59e1f7fff1403889b615083140f40d3f8ed29 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Tue, 3 Dec 2024 14:47:51 -0500 Subject: [PATCH 23/58] Print error instead raising exception when failed to create report. --- ads/llm/autogen/reports/session.py | 2 +- .../v02/log_handlers/oci_file_handler.py | 1 - ads/llm/autogen/v02/session_logger.py | 19 +++++++++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index e2915aa4b..d283dfff0 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -181,7 +181,7 @@ def build_llm_call(self, llm_log): response_text = response_message.get("content", "") tool_calls = response_message.get("tool_calls") if tool_calls: - response_text += f"\n\n**Tool Calls**:" + response_text += "\n\n**Tool Calls**:" for tool_call in tool_calls: func = tool_call.get("function") response_text += f"\n\n`{func.get('name')}(**{func.get('arguments')})`" diff --git a/ads/llm/autogen/v02/log_handlers/oci_file_handler.py b/ads/llm/autogen/v02/log_handlers/oci_file_handler.py index 775420afb..435789fb9 100644 --- a/ads/llm/autogen/v02/log_handlers/oci_file_handler.py +++ b/ads/llm/autogen/v02/log_handlers/oci_file_handler.py @@ -1,4 +1,3 @@ -# coding: utf-8 # Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import io diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index 0fbafcbec..b74c3e0bb 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -1,4 +1,3 @@ -# coding: utf-8 # Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import importlib @@ -7,9 +6,10 @@ import os import tempfile import threading +import traceback import uuid from dataclasses import dataclass, field -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse @@ -34,11 +34,10 @@ import ads from ads.common.auth import default_signer -from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.reports.session import SessionReport +from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.v02.log_handlers.oci_file_handler import OCIFileHandler - logger = logging.getLogger(__name__) @@ -268,7 +267,7 @@ def new_session( """ thread_id = threading.get_ident() - session_id = session_id or str(uuid.uuid4()) + session_id = str(session_id or uuid.uuid4()) log_file = os.path.join(log_dir, f"{session_id}.log") # Prepare the logger @@ -354,7 +353,15 @@ def stop(self) -> None: self.log_event(source=self, name=Events.SESSION_STOP) super().stop() if self.report_dir: - return self.generate_report() + try: + return self.generate_report() + except Exception as e: + logger.error( + "Failed to create session report for AutoGen session %s\n%s", + self.session_id, + str(e), + ) + logger.debug(traceback.format_exc()) def log_chat_completion( self, From 2e303628cd96142918d6bc1eceaac9071768b146 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 5 Dec 2024 11:47:10 -0500 Subject: [PATCH 24/58] Update copyright and sort imports. --- ads/llm/autogen/__init__.py | 3 +-- ads/llm/autogen/reports/__init__.py | 3 +-- ads/llm/autogen/reports/session.py | 11 +++++------ ads/llm/autogen/reports/utils.py | 3 +-- ads/llm/autogen/v02/__init__.py | 3 +-- ads/llm/autogen/v02/constants.py | 3 +-- ads/llm/autogen/v02/log_handlers/__init__.py | 3 +-- ads/llm/autogen/v02/log_handlers/oci_file_handler.py | 2 +- ads/llm/autogen/v02/report.py | 3 +-- ads/llm/autogen/v02/runtime_logging.py | 4 +--- 10 files changed, 14 insertions(+), 24 deletions(-) diff --git a/ads/llm/autogen/__init__.py b/ads/llm/autogen/__init__.py index 8b5902cd5..ae9d03ba2 100644 --- a/ads/llm/autogen/__init__.py +++ b/ads/llm/autogen/__init__.py @@ -1,3 +1,2 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. diff --git a/ads/llm/autogen/reports/__init__.py b/ads/llm/autogen/reports/__init__.py index 8b5902cd5..ae9d03ba2 100644 --- a/ads/llm/autogen/reports/__init__.py +++ b/ads/llm/autogen/reports/__init__.py @@ -1,3 +1,2 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index d283dfff0..4a5059439 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -1,22 +1,21 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. """Module for building session report.""" import copy import json import logging import os -from typing import Optional, List +from typing import List, Optional import fsspec -import plotly.express as px import pandas as pd +import plotly.express as px import report_creator as rc from jinja2 import Environment, FileSystemLoader + from ads.common.auth import default_signer -from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.reports.utils import get_duration, is_json_string - +from ads.llm.autogen.v02.constants import Events logger = logging.getLogger(__name__) diff --git a/ads/llm/autogen/reports/utils.py b/ads/llm/autogen/reports/utils.py index 3dc7903b3..b2bb8d4a4 100644 --- a/ads/llm/autogen/reports/utils.py +++ b/ads/llm/autogen/reports/utils.py @@ -1,5 +1,4 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import json from datetime import datetime diff --git a/ads/llm/autogen/v02/__init__.py b/ads/llm/autogen/v02/__init__.py index 7f4dd811a..fbf564be4 100644 --- a/ads/llm/autogen/v02/__init__.py +++ b/ads/llm/autogen/v02/__init__.py @@ -1,5 +1,4 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. from ads.llm.autogen.v02.client import LangChainModelClient, register_custom_client diff --git a/ads/llm/autogen/v02/constants.py b/ads/llm/autogen/v02/constants.py index f11f9f3f3..6c90b1066 100644 --- a/ads/llm/autogen/v02/constants.py +++ b/ads/llm/autogen/v02/constants.py @@ -1,5 +1,4 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. diff --git a/ads/llm/autogen/v02/log_handlers/__init__.py b/ads/llm/autogen/v02/log_handlers/__init__.py index 8b5902cd5..ae9d03ba2 100644 --- a/ads/llm/autogen/v02/log_handlers/__init__.py +++ b/ads/llm/autogen/v02/log_handlers/__init__.py @@ -1,3 +1,2 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. diff --git a/ads/llm/autogen/v02/log_handlers/oci_file_handler.py b/ads/llm/autogen/v02/log_handlers/oci_file_handler.py index 435789fb9..f62d42525 100644 --- a/ads/llm/autogen/v02/log_handlers/oci_file_handler.py +++ b/ads/llm/autogen/v02/log_handlers/oci_file_handler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import io import json diff --git a/ads/llm/autogen/v02/report.py b/ads/llm/autogen/v02/report.py index f30737761..ac3b71bee 100644 --- a/ads/llm/autogen/v02/report.py +++ b/ads/llm/autogen/v02/report.py @@ -1,5 +1,4 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import logging import os diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index 74886ab75..9ff2c73dd 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -1,5 +1,4 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import logging from sqlite3 import Connection @@ -9,7 +8,6 @@ from autogen.logger.base_logger import BaseLogger from autogen.logger.logger_factory import LoggerFactory - module_logger = logging.getLogger(__name__) From ee2df37f5d0ae3df87abe2ad6a7ceed4bbd0999e Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 5 Dec 2024 12:08:38 -0500 Subject: [PATCH 25/58] Add method to get all existing loggers. --- ads/llm/autogen/v02/runtime_logging.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index 9ff2c73dd..26e955a91 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -106,6 +106,15 @@ def start( return session_id -def stop(): +def stop() -> BaseLogger: + """Stops all AutoGen loggers.""" autogen.runtime_logging.stop() return autogen.runtime_logging.autogen_logger + + +def get_loggers() -> List[BaseLogger]: + """Gets a list of existing AutoGen loggers.""" + autogen_logger = autogen.runtime_logging.autogen_logger + if isinstance(autogen_logger, LoggerManager): + return autogen_logger.loggers + return [autogen_logger] From 2aa919addd0e24a932a932df204455a300d83366 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 5 Dec 2024 12:09:09 -0500 Subject: [PATCH 26/58] Catch logging exception and log traceback. --- ads/llm/autogen/v02/runtime_logging.py | 88 +++++++++++++++++--------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index 26e955a91..6a325463c 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -1,6 +1,7 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import logging +import traceback from sqlite3 import Connection from typing import Any, Dict, List, Optional @@ -8,10 +9,11 @@ from autogen.logger.base_logger import BaseLogger from autogen.logger.logger_factory import LoggerFactory -module_logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) class LoggerManager(BaseLogger): + """Manages multiple AutoGen loggers.""" def __init__(self) -> None: self.loggers: List[BaseLogger] = [] @@ -21,36 +23,47 @@ def add_logger(self, logger: BaseLogger) -> None: """Adds a new AutoGen logger.""" self.loggers.append(logger) - def call_loggers(self, method, *args, **kwargs) -> None: + def _call_loggers(self, method: str, *args, **kwargs) -> None: + """Calls the specific method on each AutoGen logger in self.loggers.""" for autogen_logger in self.loggers: - getattr(autogen_logger, method)(*args, **kwargs) + try: + getattr(autogen_logger, method)(*args, **kwargs) + except Exception as e: + # Catch the logging exception so that the program will not be interrupted. + logger.error( + "Failed to %s with %s: %s", + method, + autogen_logger.__class__.__name__, + str(e), + ) + logger.debug(traceback.format_exc()) def start(self) -> str: - return self.call_loggers("start") + return self._call_loggers("start") def stop(self) -> None: - return self.call_loggers("stop") + return self._call_loggers("stop") def get_connection(self) -> None | Connection: - return self.call_loggers("get_connection") + return self._call_loggers("get_connection") def log_chat_completion(self, *args, **kwargs) -> None: - return self.call_loggers("log_chat_completion", *args, **kwargs) + return self._call_loggers("log_chat_completion", *args, **kwargs) def log_new_agent(self, *args, **kwargs) -> None: - return self.call_loggers("log_new_agent", *args, **kwargs) + return self._call_loggers("log_new_agent", *args, **kwargs) def log_event(self, *args, **kwargs) -> None: - return self.call_loggers("log_event", *args, **kwargs) + return self._call_loggers("log_event", *args, **kwargs) def log_new_wrapper(self, *args, **kwargs) -> None: - return self.call_loggers("log_new_wrapper", *args, **kwargs) + return self._call_loggers("log_new_wrapper", *args, **kwargs) def log_new_client(self, *args, **kwargs) -> None: - return self.call_loggers("log_new_client", *args, **kwargs) + return self._call_loggers("log_new_client", *args, **kwargs) def log_function_use(self, *args, **kwargs) -> None: - return self.call_loggers("log_function_use", *args, **kwargs) + return self._call_loggers("log_function_use", *args, **kwargs) def __repr__(self) -> str: return "\n\n".join( @@ -62,35 +75,51 @@ def __repr__(self) -> str: def start( - logger: Optional[BaseLogger] = None, + autogen_logger: Optional[BaseLogger] = None, logger_type: str = None, config: Optional[Dict[str, Any]] = None, ) -> str: - if logger and logger_type: + """Starts logging with AutoGen logger. + Specify your custom autogen_logger, or the logger_type and config to use a built-in logger. + + Parameters + ---------- + autogen_logger : BaseLogger, optional + An AutoGen logger, which should be a subclass of autogen.logger.base_logger.BaseLogger. + logger_type : str, optional + Logger type of a built-in AutoGen logger (for example, "file"), by default None. + config : dict, optional + Configurations for the built-in AutoGen logger, by default None + + Returns + ------- + str + A unique session ID returned from starting the logger. + + """ + if autogen_logger and logger_type: raise ValueError( - "Please specify only logger(%s) or logger_type(%s).", logger, logger_type + "Please specify only autogen_logger(%s) or logger_type(%s).", + autogen_logger, + logger_type, ) # Check if a logger is already configured - autogen_logger = autogen.runtime_logging.autogen_logger - if not autogen_logger: + existing_logger = autogen.runtime_logging.autogen_logger + if not existing_logger: # No logger is configured logger_manager = LoggerManager() - elif not isinstance(autogen_logger, LoggerManager): + elif isinstance(existing_logger, LoggerManager): + # Logger is already configured with ADS + logger_manager = existing_logger + else: # Logger is configured but it is not via ADS - module_logger.warning( - "AutoGen is already configured with %s", str(autogen_logger) - ) + logger.warning("AutoGen is already configured with %s", str(existing_logger)) logger_manager = LoggerManager() - logger_manager.add_logger(autogen_logger) - else: - # Logger is already configured with ADS - logger_manager = autogen_logger + logger_manager.add_logger(existing_logger) # Add AutoGen logger - if logger: - autogen_logger = logger - else: + if not autogen_logger: autogen_logger = LoggerFactory.get_logger( logger_type=logger_type, config=config ) @@ -102,8 +131,7 @@ def start( autogen.runtime_logging.autogen_logger = logger_manager except Exception as e: logger.error(f"Failed to start logging: {e}") - finally: - return session_id + return session_id def stop() -> BaseLogger: From 9d13d8fc80d275e0786ced261db507f71f7422f6 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 5 Dec 2024 14:56:05 -0500 Subject: [PATCH 27/58] Use space to replace empty message for OCI GenAI LLM call. --- ads/llm/autogen/v02/client.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ads/llm/autogen/v02/client.py b/ads/llm/autogen/v02/client.py index 1dd3672c2..fd81d5bda 100644 --- a/ads/llm/autogen/v02/client.py +++ b/ads/llm/autogen/v02/client.py @@ -1,5 +1,4 @@ -# coding: utf-8 -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. """This module contains the custom LLM client for AutoGen v0.2 to use LangChain chat models. @@ -75,13 +74,11 @@ from dataclasses import asdict, dataclass from types import SimpleNamespace from typing import Any, Dict, List, Union -from types import SimpleNamespace from autogen import ModelClient from autogen.oai.client import OpenAIWrapper, PlaceHolderClient from langchain_core.messages import AIMessage - logger = logging.getLogger(__name__) # custom_clients is a dictionary mapping the name of the class to the actual class @@ -211,8 +208,8 @@ def __init__(self, config: dict, **kwargs) -> None: # Import the LangChain class if "langchain_cls" not in config: raise ValueError("Missing langchain_cls in LangChain Model Client config.") - module_cls = config.pop("langchain_cls") - module_name, cls_name = str(module_cls).rsplit(".", 1) + self.langchain_cls = config.pop("langchain_cls") + module_name, cls_name = str(self.langchain_cls).rsplit(".", 1) langchain_module = importlib.import_module(module_name) langchain_cls = getattr(langchain_module, cls_name) @@ -241,7 +238,14 @@ def create(self, params) -> ModelClient.ModelClientResponseProtocol: streaming = params.get("stream", False) # TODO: num_of_responses num_of_responses = params.get("n", 1) - messages = params.get("messages", []) + + messages = copy.deepcopy(params.get("messages", [])) + + # OCI Gen AI does not allow empty message. + if str(self.langchain_cls).endswith("oci_generative_ai.ChatOCIGenAI"): + for message in messages: + if len(message.get("content", "")) == 0: + message["content"] = " " invoke_params = copy.deepcopy(self.invoke_params) From 1430005883aa6492c46f371e79b49806c5d4efe4 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 5 Dec 2024 16:11:07 -0500 Subject: [PATCH 28/58] Include recipient in chat. --- ads/llm/autogen/reports/session.py | 2 +- ads/llm/autogen/reports/templates/chat_box.html | 13 +++++++++++++ .../autogen/reports/templates/chat_box_lt.html | 14 +------------- .../autogen/reports/templates/chat_box_rt.html | 16 +++------------- 4 files changed, 18 insertions(+), 27 deletions(-) create mode 100644 ads/llm/autogen/reports/templates/chat_box.html diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 4a5059439..0a31e883e 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -177,7 +177,7 @@ def build_llm_call(self, llm_log): response = llm_log.get("response") response_message = response.get("choices")[0].get("message") - response_text = response_message.get("content", "") + response_text = response_message.get("content") or "" tool_calls = response_message.get("tool_calls") if tool_calls: response_text += "\n\n**Tool Calls**:" diff --git a/ads/llm/autogen/reports/templates/chat_box.html b/ads/llm/autogen/reports/templates/chat_box.html new file mode 100644 index 000000000..61768f636 --- /dev/null +++ b/ads/llm/autogen/reports/templates/chat_box.html @@ -0,0 +1,13 @@ +

{{ sender }}

+

to {{ source_name }}

+{% if json_content %} +
{{ json_content }}
+{% else%} +

{{ content }}

+{% endif %} +{% if tool_calls %} +{% for tool_call in tool_calls %} +
{{ tool_call }}
+{% endfor %} +{% endif %} +

{{ timestamp }}

\ No newline at end of file diff --git a/ads/llm/autogen/reports/templates/chat_box_lt.html b/ads/llm/autogen/reports/templates/chat_box_lt.html index 23433bcc3..da766bb1a 100644 --- a/ads/llm/autogen/reports/templates/chat_box_lt.html +++ b/ads/llm/autogen/reports/templates/chat_box_lt.html @@ -1,17 +1,5 @@
-

{{ sender }}

- {% if json_content %} -
{{ json_content }}
- {% else%} -

{{ content }}

- {% endif %} - {% if tool_calls %} - {% for tool_call in tool_calls %} -
{{ tool_call }}
- {% endfor %} - {% endif %} - -

{{ timestamp }}

+ {% include "chat_box.html" %}
\ No newline at end of file diff --git a/ads/llm/autogen/reports/templates/chat_box_rt.html b/ads/llm/autogen/reports/templates/chat_box_rt.html index 2d2f1a7fb..126c903a0 100644 --- a/ads/llm/autogen/reports/templates/chat_box_rt.html +++ b/ads/llm/autogen/reports/templates/chat_box_rt.html @@ -1,16 +1,6 @@
-
-

{{ sender }}

- {% if json_content %} -
{{ json_content }}
- {% else%} -

{{ content }}

- {% endif %} - {% if tool_calls %} - {% for tool_call in tool_calls %} -
{{ tool_call }}
- {% endfor %} - {% endif %} -

{{ timestamp }}

+
+ {% include "chat_box.html" %}
\ No newline at end of file From bd239a0da158b7c23fc307462d465061e7be69d2 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 5 Dec 2024 16:11:37 -0500 Subject: [PATCH 29/58] Update session logger serialization. --- ads/llm/autogen/v02/session_logger.py | 47 +++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index b74c3e0bb..30cc8269f 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -1,6 +1,7 @@ -# Copyright (c) 2016, 2024, Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. import importlib +import inspect import json import logging import os @@ -11,7 +12,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import urlparse import autogen @@ -24,7 +25,6 @@ FileLogger, get_current_ts, safe_serialize, - to_dict, ) from oci.object_storage import ObjectStorageClient from oci.object_storage.models import ( @@ -68,6 +68,41 @@ def serialize_response(response) -> dict: return data +def serialize( + obj: Union[int, float, str, bool, Dict[Any, Any], List[Any], Tuple[Any, ...], Any], + exclude: Tuple[str, ...] = (), + no_recursive: Tuple[Any, ...] = (), +) -> Any: + try: + if isinstance(obj, (int, float, str, bool)): + return obj + elif callable(obj): + return inspect.getsource(obj).strip() + elif isinstance(obj, dict): + return { + str(k): ( + serialize(str(v)) + if isinstance(v, no_recursive) + else serialize(v, exclude, no_recursive) + ) + for k, v in obj.items() + if k not in exclude + } + elif isinstance(obj, (list, tuple)): + return [ + ( + serialize(str(v)) + if isinstance(v, no_recursive) + else serialize(v, exclude, no_recursive) + ) + for v in obj + ] + else: + return str(obj) + except Exception: + return str(obj) + + @dataclass class LoggingSession: """Represents a logging session.""" @@ -395,7 +430,7 @@ def log_chat_completion( "invocation_id": str(invocation_id), "client_id": client_id, "wrapper_id": wrapper_id, - "request": to_dict(request), + "request": serialize(request), "response": serialize_response(response), "is_cached": is_cached, "cost": cost, @@ -464,7 +499,7 @@ def log_new_agent( if hasattr(agent, "name") and agent.name is not None else "" ), - "wrapper_id": to_dict( + "wrapper_id": serialize( agent.client.wrapper_id if hasattr(agent, "client") and agent.client is not None else "" @@ -472,7 +507,7 @@ def log_new_agent( "session_id": self.session_id, "current_time": get_current_ts(), "agent_type": type(agent).__name__, - "args": to_dict(init_args), + "args": serialize(init_args), "thread_id": thread_id, } ) From def985c2ff11f2f46b90c340fa4681b87ccb9239 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 5 Dec 2024 16:51:09 -0500 Subject: [PATCH 30/58] Ignore message from chat manager in chat tab. --- ads/llm/autogen/reports/session.py | 6 ++++++ ads/llm/autogen/v02/session_logger.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 0a31e883e..e6ed6b908 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -276,6 +276,10 @@ def build_invocations_tab(self) -> rc.Block: ) def build_chat_tab(self) -> rc.Block: + # Identify the GroupChatManagers + # We will ignore the messages from GroupChatManager as they are just broadcasting. + logs = self.filter_event_logs("new_agent") + managers = [log.get("agent_name") for log in logs if log.get("is_manager")] logs = self.filter_event_logs("received_message") if not logs: return rc.Text("No messages received in this session.") @@ -288,6 +292,8 @@ def build_chat_tab(self) -> rc.Block: blocks = [] for log in states: sender = log.get("sender") + if sender in managers: + continue message = log.get("message") # Content if isinstance(message, dict) and "content" in message: diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index 30cc8269f..dbd946f26 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -18,7 +18,7 @@ import autogen import fsspec import oci -from autogen import Agent, ConversableAgent +from autogen import Agent, ConversableAgent, GroupChatManager from autogen.logger.file_logger import ( ChatCompletion, F, @@ -509,6 +509,7 @@ def log_new_agent( "agent_type": type(agent).__name__, "args": serialize(init_args), "thread_id": thread_id, + "is_manager": isinstance(agent, GroupChatManager), } ) self.logger.info(log_data) From 76a201c29d52675f595eea79ab2b18d970b61395 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 6 Dec 2024 11:50:14 -0500 Subject: [PATCH 31/58] Update Chat box template. --- ads/llm/autogen/reports/templates/chat_box.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ads/llm/autogen/reports/templates/chat_box.html b/ads/llm/autogen/reports/templates/chat_box.html index 61768f636..a41ab2ef6 100644 --- a/ads/llm/autogen/reports/templates/chat_box.html +++ b/ads/llm/autogen/reports/templates/chat_box.html @@ -1,5 +1,6 @@ -

{{ sender }}

-

to {{ source_name }}

+

{{ sender }}
to {{ source_name }}

+

{{ timestamp }}

+
{% if json_content %}
{{ json_content }}
{% else%} @@ -9,5 +10,4 @@

{{ sender }}

{% for tool_call in tool_calls %}
{{ tool_call }}
{% endfor %} -{% endif %} -

{{ timestamp }}

\ No newline at end of file +{% endif %} \ No newline at end of file From 696065d0de4f95b44aff76d9da130c703123e804 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 6 Dec 2024 11:51:58 -0500 Subject: [PATCH 32/58] Update response serialization. --- ads/llm/autogen/v02/session_logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index dbd946f26..49bb7207c 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -56,7 +56,7 @@ def serialize_response(response) -> dict: # Convert simpleNamespace to dict return json.loads(json.dumps(response, default=vars)) elif hasattr(response, "dict") and callable(response.dict): - return response.dict() + return json.loads(json.dumps(response.dict(), default=str)) data = { "model": response.model, "choices": [ From af5de1363531cdaadaca9bea878b0237c5b00d5c Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 6 Dec 2024 11:52:37 -0500 Subject: [PATCH 33/58] Add HTML escape for displaying raw logs. --- ads/llm/autogen/reports/session.py | 7 +++---- ads/llm/autogen/reports/utils.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index e6ed6b908..8f667030d 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -14,7 +14,7 @@ from jinja2 import Environment, FileSystemLoader from ads.common.auth import default_signer -from ads.llm.autogen.reports.utils import get_duration, is_json_string +from ads.llm.autogen.reports.utils import escape_html, get_duration, is_json_string from ads.llm.autogen.v02.constants import Events logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: with fsspec.open(self.log_file, mode="r", **auth) as f: self.log_lines = f.readlines() else: - with open(self.log_file, mode="r", encoding="utf-8") as f: + with open(self.log_file, encoding="utf-8") as f: self.log_lines = f.readlines() self.logs = self._parse_logs() self.event_logs = self.get_event_logs() @@ -215,7 +215,6 @@ def build_llm_call(self, llm_log): label="JSON", ), ), - # label=request_header, ), ) @@ -334,7 +333,7 @@ def build_logs_tab(self) -> rc.Block: label = log.get( "event_name", self._preview_message(log.get("message", "")) ) - blocks.append(rc.Collapse(rc.Json(log), label=label)) + blocks.append(rc.Collapse(rc.Json(escape_html(log)), label=label)) else: log = log_line blocks.append( diff --git a/ads/llm/autogen/reports/utils.py b/ads/llm/autogen/reports/utils.py index b2bb8d4a4..e026e0d82 100644 --- a/ads/llm/autogen/reports/utils.py +++ b/ads/llm/autogen/reports/utils.py @@ -1,5 +1,6 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +import html import json from datetime import datetime @@ -44,3 +45,13 @@ def is_json_string(s): except Exception: return False return True + + +def escape_html(obj): + if isinstance(obj, dict): + return {k: escape_html(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [escape_html(v) for v in obj] + elif isinstance(obj, str): + return html.escape(obj) + return html.escape(str(obj)) From 728eeb552fcb0a675829124bbbe615d7acb9e89c Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 6 Dec 2024 14:37:04 -0500 Subject: [PATCH 34/58] Update timeline header and show whether LLM call is cached. --- ads/llm/autogen/reports/session.py | 32 +++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 8f667030d..4377425b3 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -76,6 +76,16 @@ def _preview_message(message: str, max_length=30) -> str: # If we didn't find a whitespace return message[:30] + "..." + @staticmethod + def _llm_call_header(log): + request = log.get("request", {}) + source_name = log.get("source_name") + + header = f"{source_name} invoking {request.get('model')}" + if log.get("is_cached"): + header += "(Cached)" + return header + def _parse_logs(self) -> List[dict]: logs = [] for log in self.log_lines: @@ -90,17 +100,20 @@ def _parse_invocation_events(self): llm_events = self.filter_event_logs(Events.LLM_CALL) llm_call_counter = 1 for event in llm_events: - event["name"] = f"LLM Call {str(llm_call_counter)}" + event["header"] = self._llm_call_header(event) llm_call_counter += 1 # Tool Calls tool_events = self.filter_event_logs(Events.TOOL_CALL) for event in tool_events: event["start_time"] = self.estimate_tool_call_start_time(event) - event["name"] = event["tool_name"] + event["header"] = ( + f"{event.get('source_name', '')} invoking {event.get('tool_name', '')}" + ) event["end_time"] = event["timestamp"] events = sorted(llm_events + tool_events, key=lambda x: x.get("start_time")) - for event in events: + for i, event in enumerate(events): + event["header"] = f'{str(i + 1)} {event["header"]}' event["duration"] = get_duration(event) return events @@ -148,9 +161,11 @@ def build_timeline_tab(self): df, x_start="start_time", x_end="end_time", - y="name", + y="header", + labels={"header": "Invocation"}, color="duration", color_continuous_scale="rdylgn_r", + height=max(len(df.index) * 50, 500), ) fig.update_layout(showlegend=False) fig.update_yaxes(autorange="reversed") @@ -164,11 +179,11 @@ def format_messages(self, messages): def build_llm_call(self, llm_log): request = llm_log.get("request", {}) - source_name = llm_log.get("source_name") - - header = f"{source_name} invoking {request.get('model')}" + header = llm_log.get("header", "") description = f"*{llm_log.get('start_time')}*" + if llm_log.get("is_cached"): + description += ", **CACHED**" request_value = f"{str(len(request.get('messages')))} messages" tools = request.get("tools") @@ -219,8 +234,7 @@ def build_llm_call(self, llm_log): ) def build_tool_call(self, log: dict): - source_name = log.get("source_name") - header = f"{source_name} invoking {log.get('tool_name')}" + header = log.get("header", "") request = copy.deepcopy(log) response = request.pop("returns", {}) try: From 9f89d0f076670a20f8ae80d34ad9a683eff1ad27 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 6 Dec 2024 15:40:09 -0500 Subject: [PATCH 35/58] Show more metrics in invocation tab. --- ads/llm/autogen/reports/session.py | 50 ++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 4377425b3..7974174f5 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -181,12 +181,15 @@ def build_llm_call(self, llm_log): request = llm_log.get("request", {}) header = llm_log.get("header", "") - description = f"*{llm_log.get('start_time')}*" + start_date, start_time = llm_log.get("start_time", " ").split(" ", 1) + start_time = start_time.split(".", 1)[0] + + description = f" " if llm_log.get("is_cached"): description += ", **CACHED**" request_value = f"{str(len(request.get('messages')))} messages" - tools = request.get("tools") + tools = request.get("tools", []) if tools: request_value += f", {str(len(tools))} tools" @@ -199,19 +202,43 @@ def build_llm_call(self, llm_log): for tool_call in tool_calls: func = tool_call.get("function") response_text += f"\n\n`{func.get('name')}(**{func.get('arguments')})`" - response_time = get_duration(llm_log) + duration = get_duration(llm_log) + + metrics = [ + rc.Metric(heading="Time", value=start_time, label=start_date), + rc.Metric( + heading="Messages", + value=len(request.get("messages", [])), + ), + rc.Metric(heading="Tools", value=len(tools)), + rc.Metric(heading="Duration", value=duration, unit="s"), + rc.Metric( + heading="Cached", + value="Yes" if llm_log.get("is_cached") else "No", + ), + rc.Metric(heading="Cost", value=llm_log.get("cost")), + ] + + usage = response.get("usage") + if isinstance(usage, dict): + for k, v in usage.items(): + if not v: + continue + metrics.append( + rc.Metric(heading=str(k).replace("_", " ").title(), value=v) + ) return rc.Block( rc.Text( - description, + "", label=header, ), + rc.Block(rc.Group(*metrics)), rc.Group( rc.Block( - rc.Metric( - heading="Request", - value=request_value, - label=self.format_messages(request.get("messages")), + rc.Markdown( + "## Request:\n\n" + + self.format_messages(request.get("messages")), ), rc.Collapse( rc.Json(request), @@ -219,11 +246,8 @@ def build_llm_call(self, llm_log): ), ), rc.Block( - rc.Metric( - heading="Response", - value=response_time, - unit="s", - label=response_text, + rc.Markdown( + "## Response:\n\n" + response_text, ), rc.Collapse( rc.Json(response), From 9937b3246d5c06af074991f8ac71125568653d31 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Sat, 7 Dec 2024 23:01:05 -0500 Subject: [PATCH 36/58] Count unique agents and chat managers. --- ads/llm/autogen/reports/session.py | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 7974174f5..fd6c0df8a 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -383,16 +383,44 @@ def build_logs_tab(self) -> rc.Block: label="Logs", ) + def count_agents(self): + # AutoGen may have new_agent multiple times + # Here we count the number agents by name + new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) + agents = set() + for log in new_agent_logs: + agents.add((log.get("agent_name"), log.get("agent_type"))) + return len(agents) + + def get_chat_managers(self): + # AutoGen may have new_agent multiple times + # Here we count the number agents by name + new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) + agents = set() + for log in new_agent_logs: + if not log.get("is_manager"): + continue + agents.add((log.get("agent_name"), log.get("agent_type"))) + return agents + def build(self, output_file: str): start_event = self.get_event_data(Events.SESSION_START) start_time = start_event.get("timestamp") session_id = start_event.get("session_id") event_logs = self.get_event_logs() - new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) + llm_call_logs = self.filter_event_logs(Events.LLM_CALL) tool_call_logs = self.filter_event_logs(Events.TOOL_CALL) + chat_managers = self.get_chat_managers() + if not chat_managers: + agent_label = "" + elif len(chat_managers) == 1: + agent_label = "+1 chat manager" + else: + agent_label = f"+{str(len(chat_managers))} chat managers" + with rc.ReportCreator( title=f"AutoGen Session: {session_id}", description=f"Started at {start_time}", @@ -403,7 +431,8 @@ def build(self, output_file: str): rc.Group( rc.Metric( heading="Agents", - value=len(new_agent_logs), + value=self.count_agents() - len(chat_managers), + label=agent_label, ), rc.Metric( heading="Events", From b4384e812d4349b47ebf309c9ca9fa879844b5cb Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 9 Dec 2024 14:47:58 -0500 Subject: [PATCH 37/58] Align left within code block. --- ads/llm/autogen/reports/templates/chat_box.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ads/llm/autogen/reports/templates/chat_box.html b/ads/llm/autogen/reports/templates/chat_box.html index a41ab2ef6..62d792888 100644 --- a/ads/llm/autogen/reports/templates/chat_box.html +++ b/ads/llm/autogen/reports/templates/chat_box.html @@ -2,12 +2,12 @@

{{ timestamp }}


{% if json_content %} -
{{ json_content }}
+
{{ json_content }}
{% else%}

{{ content }}

{% endif %} {% if tool_calls %} {% for tool_call in tool_calls %} -
{{ tool_call }}
+
{{ tool_call }}
{% endfor %} {% endif %} \ No newline at end of file From d9a11fc9879387cb159e1891d4ddf4904a87c7be Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 9 Dec 2024 14:49:02 -0500 Subject: [PATCH 38/58] Do logging only if logger is started. --- ads/llm/autogen/v02/session_logger.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index 49bb7207c..333617e9a 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -248,10 +248,13 @@ def __init__( self.session = self.new_session( log_dir=log_dir, session_id=session_id, auth=auth ) + self.started = False @property def logger(self) -> Optional[str]: """Logger for the thread.""" + if not self.started: + return None thread_id = threading.get_ident() if not self.log_for_all_threads and thread_id != self.session.thread_id: return None @@ -280,8 +283,6 @@ def new_session( ) -> LoggingSession: """Creates a new logging session. - If an active logging session is already started in the thread, the existing session will be used. - Parameters ---------- log_dir : str @@ -380,6 +381,7 @@ def start(self) -> str: envs[library] = version except Exception: pass + self.started = True self.log_event(source=self, name=Events.SESSION_START, environment=envs) return self.session_id @@ -387,6 +389,7 @@ def stop(self) -> None: """Stops the logging session.""" self.log_event(source=self, name=Events.SESSION_STOP) super().stop() + self.started = False if self.report_dir: try: return self.generate_report() @@ -522,9 +525,9 @@ def log_event(self, *args, **kwargs) -> None: return super().log_event(*args, **kwargs) def log_new_wrapper(self, *args, **kwargs) -> None: - if not self.logger: - return - return super().log_new_wrapper(*args, **kwargs) + # Do not log new wrapper. + # This is not used at the moment. + return def log_new_client(self, *args, **kwargs) -> None: if not self.logger: From 20bccba3ce21ec6289be3a15f585ff711bfa1db0 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 9 Dec 2024 14:49:36 -0500 Subject: [PATCH 39/58] Remove loggers from LoggerManager once stopped. --- ads/llm/autogen/v02/runtime_logging.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index 6a325463c..ce01d7003 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -39,10 +39,13 @@ def _call_loggers(self, method: str, *args, **kwargs) -> None: logger.debug(traceback.format_exc()) def start(self) -> str: + """Starts all loggers.""" return self._call_loggers("start") def stop(self) -> None: - return self._call_loggers("stop") + self._call_loggers("stop") + # Remove the loggers once they are stopped. + self.loggers = [] def get_connection(self) -> None | Connection: return self._call_loggers("get_connection") @@ -135,7 +138,9 @@ def start( def stop() -> BaseLogger: - """Stops all AutoGen loggers.""" + """Stops all AutoGen loggers. + Once stopped, all loggers will be removed. + """ autogen.runtime_logging.stop() return autogen.runtime_logging.autogen_logger From ef7876a7405e2471fa881924b0fa93e2ed57ae48 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 9 Dec 2024 15:45:53 -0500 Subject: [PATCH 40/58] Skip logging new clients and new wrappers. --- ads/llm/autogen/v02/session_logger.py | 28 ++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index 333617e9a..473bab787 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -18,7 +18,7 @@ import autogen import fsspec import oci -from autogen import Agent, ConversableAgent, GroupChatManager +from autogen import Agent, ConversableAgent, GroupChatManager, OpenAIWrapper from autogen.logger.file_logger import ( ChatCompletion, F, @@ -529,10 +529,32 @@ def log_new_wrapper(self, *args, **kwargs) -> None: # This is not used at the moment. return - def log_new_client(self, *args, **kwargs) -> None: + def log_new_client( + self, + client, + wrapper: OpenAIWrapper, + init_args: Dict[str, Any], + ) -> None: if not self.logger: return - return super().log_new_client(*args, **kwargs) + thread_id = threading.get_ident() + + try: + log_data = json.dumps( + { + "client_id": id(client), + "wrapper_id": id(wrapper), + "session_id": self.session_id, + "class": type(client).__name__, + # init_args may contain credentials like api_key + # "json_state": json.dumps(init_args), + "timestamp": get_current_ts(), + "thread_id": thread_id, + } + ) + self.logger.info(log_data) + except Exception as e: + self.logger.error(f"[file_logger] Failed to log event {e}") def __repr__(self) -> str: return self.session.__repr__() From cf1f1525fab3ff43733a17953602cec4c90ef82e Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 9 Dec 2024 15:46:15 -0500 Subject: [PATCH 41/58] Update Request/Response block in invocations. --- ads/llm/autogen/reports/session.py | 63 +++++++++++++----------------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index fd6c0df8a..c0b4d2b51 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -77,7 +77,7 @@ def _preview_message(message: str, max_length=30) -> str: return message[:30] + "..." @staticmethod - def _llm_call_header(log): + def _llm_call_header(log: dict): request = log.get("request", {}) source_name = log.get("source_name") @@ -86,6 +86,12 @@ def _llm_call_header(log): header += "(Cached)" return header + @staticmethod + def _parse_start_time(log: dict): + start_date, start_time = log.get("start_time", " ").split(" ", 1) + start_time = start_time.split(".", 1)[0] + return start_date, start_time + def _parse_logs(self) -> List[dict]: logs = [] for log in self.log_lines: @@ -177,12 +183,11 @@ def format_messages(self, messages): text += f"**{message.get('role')}**:\n{message.get('content')}\n\n" return text - def build_llm_call(self, llm_log): + def build_llm_call(self, llm_log: dict): request = llm_log.get("request", {}) header = llm_log.get("header", "") - start_date, start_time = llm_log.get("start_time", " ").split(" ", 1) - start_time = start_time.split(".", 1)[0] + start_date, start_time = self._parse_start_time(llm_log) description = f" " if llm_log.get("is_cached"): @@ -229,16 +234,11 @@ def build_llm_call(self, llm_log): ) return rc.Block( - rc.Text( - "", - label=header, - ), - rc.Block(rc.Group(*metrics)), + rc.Block(rc.Group(*metrics, label=header)), rc.Group( rc.Block( rc.Markdown( - "## Request:\n\n" - + self.format_messages(request.get("messages")), + self.format_messages(request.get("messages")), label="Request" ), rc.Collapse( rc.Json(request), @@ -246,9 +246,7 @@ def build_llm_call(self, llm_log): ), ), rc.Block( - rc.Markdown( - "## Response:\n\n" + response_text, - ), + rc.Markdown(response_text, label="Response"), rc.Collapse( rc.Json(response), label="JSON", @@ -261,41 +259,36 @@ def build_tool_call(self, log: dict): header = log.get("header", "") request = copy.deepcopy(log) response = request.pop("returns", {}) - try: - response = json.loads(response) - except Exception: - pass + start_date, start_time = self._parse_start_time(log) tool_call_args = log.get("input_args", "") if is_json_string(tool_call_args): tool_call_args = self.format_json_string(tool_call_args) + if is_json_string(response): + response = self.format_json_string(response) + + duration = get_duration(log) + + metrics = [ + rc.Metric(heading="Time", value=start_time, label=start_date), + rc.Metric(heading="Duration", value=duration, unit="s"), + ] + return rc.Block( + rc.Block(rc.Group(*metrics, label=header)), rc.Group( rc.Block( - rc.Metric( - heading="Request", - value=log.get("tool_name"), - label=tool_call_args, + rc.Markdown( + (log.get("tool_name") or "") + "\n\n" + tool_call_args, + label="Request", ), rc.Collapse( rc.Json(request), label="JSON", ), ), - rc.Block( - rc.Metric( - heading="Response", - value=get_duration(log), - unit="s", - label=str(response), - ), - rc.Collapse( - rc.Json(response), - label="JSON", - ), - ), - label=header, + rc.Block(rc.Text("", label="Response"), rc.Markdown(response)), ), ) From 014f69f3c6a178a1021943dbab109a98eb38ca65 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Tue, 10 Dec 2024 13:23:03 -0500 Subject: [PATCH 42/58] Update new client logging. --- ads/llm/autogen/v02/constants.py | 1 + ads/llm/autogen/v02/session_logger.py | 1 + 2 files changed, 2 insertions(+) diff --git a/ads/llm/autogen/v02/constants.py b/ads/llm/autogen/v02/constants.py index 6c90b1066..2d22a540f 100644 --- a/ads/llm/autogen/v02/constants.py +++ b/ads/llm/autogen/v02/constants.py @@ -7,5 +7,6 @@ class Events: LLM_CALL = "llm_call" TOOL_CALL = "tool_call" NEW_AGENT = "new_agent" + NEW_CLIENT = "new_client" SESSION_START = "logging_session_start" SESSION_STOP = "logging_session_stop" diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/session_logger.py index 473bab787..709177511 100644 --- a/ads/llm/autogen/v02/session_logger.py +++ b/ads/llm/autogen/v02/session_logger.py @@ -542,6 +542,7 @@ def log_new_client( try: log_data = json.dumps( { + Events.KEY: Events.NEW_CLIENT, "client_id": id(client), "wrapper_id": id(wrapper), "session_id": self.session_id, From a0ffcd207015f41c374122f47b46d59cb9d83819 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Tue, 10 Dec 2024 16:19:45 -0500 Subject: [PATCH 43/58] Show flow chat with timeline. --- ads/llm/autogen/reports/session.py | 74 ++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index c0b4d2b51..279a9440f 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -33,6 +33,7 @@ def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: self.logs = self._parse_logs() self.event_logs = self.get_event_logs() self.invocation_logs = self._parse_invocation_events() + self.received_message_logs = self._parse_received_messages() @staticmethod def format_json_string(s) -> str: @@ -124,6 +125,20 @@ def _parse_invocation_events(self): return events + def _parse_received_messages(self): + logs = self.filter_event_logs("new_agent") + managers = self.get_chat_managers() + logs = self.filter_event_logs("received_message") + if not logs: + return [] + states: List[dict] = [json.loads(log.get("json_state", "{}")) for log in logs] + for i, state in enumerate(states): + state.update(logs[i]) + logs = states + logs = sorted(logs, key=lambda x: x.get("timestamp", "")) + logs = [log for log in logs if log.get("sender") not in managers] + return logs + def get_event_data(self, event_name: str): for log in self.logs: if log.get(Events.KEY) == event_name: @@ -134,7 +149,7 @@ def filter_event_logs(self, event_name) -> List[dict]: filtered_logs = [] for log in self.logs: if log.get(Events.KEY) == event_name: - filtered_logs.append(log) + filtered_logs.append(copy.deepcopy(log)) return filtered_logs def get_event_logs(self): @@ -175,7 +190,9 @@ def build_timeline_tab(self): ) fig.update_layout(showlegend=False) fig.update_yaxes(autorange="reversed") - return rc.Widget(fig, label="Timeline") + return rc.Block( + rc.Widget(fig, label="Timeline"), self.build_flowchart(), label="Timeline" + ) def format_messages(self, messages): text = "" @@ -305,25 +322,51 @@ def build_invocations_tab(self) -> rc.Block: label="Invocations", ) + def build_flowchart(self): + logs = self.received_message_logs + senders = [] + for log in logs: + sender = log.get("sender") + senders.append(sender) + + diagram_src = "graph LR\n" + prev_sender = None + links = [] + # Conversation Flow + for sender in senders: + if prev_sender is None: + link = f"START([START]) --> {sender}" + else: + link = f"{prev_sender} --> {sender}" + if link not in links: + links.append(link) + prev_sender = sender + links.append(f"{prev_sender} --> END([END])") + # Tool Calls + logs = self.filter_event_logs(Events.TOOL_CALL) + for log in logs: + tool = log.get("tool_name") + agent = log.get("source_name") + if tool and agent: + link = f"{agent} <--> {tool}[[{tool}]]" + if link not in links: + links.append(link) + + diagram_src += "\n".join(links) + print(diagram_src) + return rc.Diagram(src=diagram_src, label="Flowchart") + def build_chat_tab(self) -> rc.Block: - # Identify the GroupChatManagers - # We will ignore the messages from GroupChatManager as they are just broadcasting. - logs = self.filter_event_logs("new_agent") - managers = [log.get("agent_name") for log in logs if log.get("is_manager")] - logs = self.filter_event_logs("received_message") + logs = copy.deepcopy(self.received_message_logs) if not logs: return rc.Text("No messages received in this session.") - states: List[dict] = [json.loads(log.get("json_state", "{}")) for log in logs] - for i, state in enumerate(states): - state.update(logs[i]) # The agent sending the first message will be placed on the right. # All other agents will be placed on the left - host = states[0].get("sender") + host = logs[0].get("sender") blocks = [] - for log in states: + + for log in copy.deepcopy(self.received_message_logs): sender = log.get("sender") - if sender in managers: - continue message = log.get("message") # Content if isinstance(message, dict) and "content" in message: @@ -351,6 +394,7 @@ def build_chat_tab(self) -> rc.Block: else: html = self._apply_template("chat_box_lt.html", **log) blocks.append(rc.Html(html)) + return rc.Block( *blocks, label="Chats", @@ -393,7 +437,7 @@ def get_chat_managers(self): for log in new_agent_logs: if not log.get("is_manager"): continue - agents.add((log.get("agent_name"), log.get("agent_type"))) + agents.add(log.get("agent_name")) return agents def build(self, output_file: str): From a3ff19743fb965680206118ea834a1b3c88329ba Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 12 Dec 2024 15:13:31 -0500 Subject: [PATCH 44/58] Move SessionLogger into ads.llm.autogen.v02.loggers. --- ads/llm/autogen/v02/loggers/__init__.py | 4 ++ .../v02/{ => loggers}/session_logger.py | 0 ads/llm/autogen/v02/report.py | 65 ------------------- ads/llm/autogen/v02/runtime_logging.py | 2 +- 4 files changed, 5 insertions(+), 66 deletions(-) create mode 100644 ads/llm/autogen/v02/loggers/__init__.py rename ads/llm/autogen/v02/{ => loggers}/session_logger.py (100%) delete mode 100644 ads/llm/autogen/v02/report.py diff --git a/ads/llm/autogen/v02/loggers/__init__.py b/ads/llm/autogen/v02/loggers/__init__.py new file mode 100644 index 000000000..9db08fc4d --- /dev/null +++ b/ads/llm/autogen/v02/loggers/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. + +from ads.llm.autogen.v02.loggers.session_logger import SessionLogger diff --git a/ads/llm/autogen/v02/session_logger.py b/ads/llm/autogen/v02/loggers/session_logger.py similarity index 100% rename from ads/llm/autogen/v02/session_logger.py rename to ads/llm/autogen/v02/loggers/session_logger.py diff --git a/ads/llm/autogen/v02/report.py b/ads/llm/autogen/v02/report.py deleted file mode 100644 index ac3b71bee..000000000 --- a/ads/llm/autogen/v02/report.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. -import logging -import os -from typing import Optional - -from ads.llm.autogen.v02 import runtime_logging -from ads.llm.autogen.v02.session_logger import SessionLogger - - -logger = logging.getLogger(__name__) - - -class AutoGenLoggingException(Exception): - pass - - -def start_logging( - log_dir: str, - report_dir: Optional[str] = None, - session_id: Optional[str] = None, - auth: Optional[dict] = None, - report_par_uri=False, - **kwargs, -) -> str: - """Starts a new logging session. - Each thread can only have one logging session. - - AutoGen saves the logger as global variable. Only one logger can be active at a time. - If you are using other loggers like AgentOps, an exception will be raised. - - Parameters - ---------- - log_dir : str - The location to store the logs. - session_id : str, optional - Session ID for identifying the session, by default None. - The session ID will be used as the log filename. - If session_id is None, a new UUID4 will be generated. - To resume a session, use a previously generated session_id. - - auth: dict, optional - Dictionary containing the OCI authentication config and signer. - This is only used if log_dir is on object storage. - If auth is None, `ads.common.auth.default_signer()` will be used. - - Returns - ------- - str - Session ID - """ - autogen_logger = SessionLogger( - log_dir=log_dir, - report_dir=report_dir, - session_id=session_id, - auth=auth, - report_par_uri=report_par_uri, - par_kwargs=kwargs, - ) - return runtime_logging.start(logger=autogen_logger) - - -def stop_logging(): - """Stops the logging session.""" - return runtime_logging.stop() diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index ce01d7003..acc5f97d9 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -90,7 +90,7 @@ def start( autogen_logger : BaseLogger, optional An AutoGen logger, which should be a subclass of autogen.logger.base_logger.BaseLogger. logger_type : str, optional - Logger type of a built-in AutoGen logger (for example, "file"), by default None. + Logger type, which can be a built-in AutoGen logger type ("file", or "sqlite"), by default None. config : dict, optional Configurations for the built-in AutoGen logger, by default None From 3da1519c588f362ea5f36125e26d65113d8e69e0 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Mon, 16 Dec 2024 13:42:48 -0500 Subject: [PATCH 45/58] Show empty message as empty instead of None in chat tab. --- ads/llm/autogen/reports/session.py | 36 +++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 279a9440f..2f8ab49f6 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -21,7 +21,18 @@ class SessionReport: + """Class for building session report from session log file.""" + def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: + """Initialize the session report with log file. + + Parameters + ---------- + log_file : str + Path or URI of the log file. + auth : dict, optional + Authentication signer/config for OCI, by default None + """ self.log_file = log_file if self.log_file.startswith("oci://"): auth = auth or default_signer() @@ -37,10 +48,12 @@ def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: @staticmethod def format_json_string(s) -> str: + """Formats the JSON string in markdown.""" return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" @staticmethod - def _apply_template(template_path, **kwargs) -> str: + def _render_template(template_path, **kwargs) -> str: + """Render Jinja template with kwargs.""" template_dir = os.path.join(os.path.dirname(__file__), "templates") environment = Environment( loader=FileSystemLoader(template_dir), autoescape=True @@ -54,7 +67,7 @@ def _apply_template(template_path, **kwargs) -> str: template_path, str(kwargs), ) - return SessionReport._apply_template( + return SessionReport._render_template( template_path=template_path, sender=kwargs.get("sender", "N/A"), content="TEMPLATE RENDER ERROR", @@ -64,6 +77,7 @@ def _apply_template(template_path, **kwargs) -> str: @staticmethod def _preview_message(message: str, max_length=30) -> str: + """Shows the beginning part of a string message.""" # Return the entire string if it is less than the max_length if len(message) <= max_length: return message @@ -89,16 +103,21 @@ def _llm_call_header(log: dict): @staticmethod def _parse_start_time(log: dict): + """Parses a datetime string in the logs into date and time.""" start_date, start_time = log.get("start_time", " ").split(" ", 1) start_time = start_time.split(".", 1)[0] return start_date, start_time def _parse_logs(self) -> List[dict]: + """Parses the logs into dictionary.""" logs = [] - for log in self.log_lines: + for i, log in enumerate(self.log_lines): try: logs.append(json.loads(log)) except Exception as e: + logger.debug( + "Error when parsing log record at line %s:\n%s", str(i + 1), str(e) + ) continue return logs @@ -353,7 +372,6 @@ def build_flowchart(self): links.append(link) diagram_src += "\n".join(links) - print(diagram_src) return rc.Diagram(src=diagram_src, label="Flowchart") def build_chat_tab(self) -> rc.Block: @@ -367,15 +385,17 @@ def build_chat_tab(self) -> rc.Block: for log in copy.deepcopy(self.received_message_logs): sender = log.get("sender") - message = log.get("message") + message = log.get("message", "") # Content if isinstance(message, dict) and "content" in message: - content = message.get("content") + content = message.get("content", "") if is_json_string(content): log["json_content"] = json.dumps(json.loads(content), indent=2) log["content"] = content else: log["content"] = message + if log["content"] is None: + log["content"] = "" # Tool call if isinstance(message, dict) and "tool_calls" in message: tool_calls = message.get("tool_calls") @@ -390,9 +410,9 @@ def build_chat_tab(self) -> rc.Block: ) log["tool_calls"] = tool_call_signatures if sender == host: - html = self._apply_template("chat_box_rt.html", **log) + html = self._render_template("chat_box_rt.html", **log) else: - html = self._apply_template("chat_box_lt.html", **log) + html = self._render_template("chat_box_lt.html", **log) blocks.append(rc.Html(html)) return rc.Block( From 9750e0639c15579e7f953fb6bb78b77ce01b2b31 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Tue, 17 Dec 2024 14:34:05 -0500 Subject: [PATCH 46/58] Update copyrights. --- ads/llm/autogen/__init__.py | 2 +- ads/llm/autogen/reports/__init__.py | 2 +- ads/llm/autogen/reports/session.py | 2 +- ads/llm/autogen/reports/utils.py | 2 +- ads/llm/autogen/v02/__init__.py | 2 +- ads/llm/autogen/v02/client.py | 2 +- ads/llm/autogen/v02/constants.py | 2 +- ads/llm/autogen/v02/log_handlers/__init__.py | 2 +- ads/llm/autogen/v02/log_handlers/oci_file_handler.py | 5 +++-- ads/llm/autogen/v02/loggers/__init__.py | 2 +- ads/llm/autogen/v02/runtime_logging.py | 2 +- 11 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ads/llm/autogen/__init__.py b/ads/llm/autogen/__init__.py index ae9d03ba2..72e03c615 100644 --- a/ads/llm/autogen/__init__.py +++ b/ads/llm/autogen/__init__.py @@ -1,2 +1,2 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ diff --git a/ads/llm/autogen/reports/__init__.py b/ads/llm/autogen/reports/__init__.py index ae9d03ba2..72e03c615 100644 --- a/ads/llm/autogen/reports/__init__.py +++ b/ads/llm/autogen/reports/__init__.py @@ -1,2 +1,2 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 2f8ab49f6..4caaf4ca8 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -1,5 +1,5 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ """Module for building session report.""" import copy import json diff --git a/ads/llm/autogen/reports/utils.py b/ads/llm/autogen/reports/utils.py index e026e0d82..7de78a04b 100644 --- a/ads/llm/autogen/reports/utils.py +++ b/ads/llm/autogen/reports/utils.py @@ -1,5 +1,5 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ import html import json from datetime import datetime diff --git a/ads/llm/autogen/v02/__init__.py b/ads/llm/autogen/v02/__init__.py index fbf564be4..83f271279 100644 --- a/ads/llm/autogen/v02/__init__.py +++ b/ads/llm/autogen/v02/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ from ads.llm.autogen.v02.client import LangChainModelClient, register_custom_client diff --git a/ads/llm/autogen/v02/client.py b/ads/llm/autogen/v02/client.py index fd81d5bda..10e7b02ab 100644 --- a/ads/llm/autogen/v02/client.py +++ b/ads/llm/autogen/v02/client.py @@ -1,5 +1,5 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ """This module contains the custom LLM client for AutoGen v0.2 to use LangChain chat models. https://microsoft.github.io/autogen/0.2/blog/2024/01/26/Custom-Models/ diff --git a/ads/llm/autogen/v02/constants.py b/ads/llm/autogen/v02/constants.py index 2d22a540f..8a473de06 100644 --- a/ads/llm/autogen/v02/constants.py +++ b/ads/llm/autogen/v02/constants.py @@ -1,5 +1,5 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ class Events: diff --git a/ads/llm/autogen/v02/log_handlers/__init__.py b/ads/llm/autogen/v02/log_handlers/__init__.py index ae9d03ba2..72e03c615 100644 --- a/ads/llm/autogen/v02/log_handlers/__init__.py +++ b/ads/llm/autogen/v02/log_handlers/__init__.py @@ -1,2 +1,2 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ diff --git a/ads/llm/autogen/v02/log_handlers/oci_file_handler.py b/ads/llm/autogen/v02/log_handlers/oci_file_handler.py index f62d42525..0a1713749 100644 --- a/ads/llm/autogen/v02/log_handlers/oci_file_handler.py +++ b/ads/llm/autogen/v02/log_handlers/oci_file_handler.py @@ -1,13 +1,14 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ import io import json import logging import os import threading + import fsspec -from ads.common.auth import default_signer +from ads.common.auth import default_signer logger = logging.getLogger(__name__) diff --git a/ads/llm/autogen/v02/loggers/__init__.py b/ads/llm/autogen/v02/loggers/__init__.py index 9db08fc4d..b95587622 100644 --- a/ads/llm/autogen/v02/loggers/__init__.py +++ b/ads/llm/autogen/v02/loggers/__init__.py @@ -1,4 +1,4 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ from ads.llm.autogen.v02.loggers.session_logger import SessionLogger diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index acc5f97d9..1f6266df6 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -1,5 +1,5 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ import logging import traceback from sqlite3 import Connection From 519508be2de254e54b513c2c8c941cc879c163c2 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 19 Dec 2024 11:22:45 -0500 Subject: [PATCH 47/58] Refactor logging and report generation. --- ads/llm/autogen/{v02 => }/constants.py | 1 + ads/llm/autogen/reports/base.py | 67 +++ ads/llm/autogen/reports/data.py | 103 ++++ ads/llm/autogen/reports/session.py | 508 +++++++++--------- ads/llm/autogen/reports/utils.py | 21 +- ads/llm/autogen/v02/loggers/session_logger.py | 284 ++++++---- 6 files changed, 603 insertions(+), 381 deletions(-) rename ads/llm/autogen/{v02 => }/constants.py (90%) create mode 100644 ads/llm/autogen/reports/base.py create mode 100644 ads/llm/autogen/reports/data.py diff --git a/ads/llm/autogen/v02/constants.py b/ads/llm/autogen/constants.py similarity index 90% rename from ads/llm/autogen/v02/constants.py rename to ads/llm/autogen/constants.py index 8a473de06..9e918d99c 100644 --- a/ads/llm/autogen/v02/constants.py +++ b/ads/llm/autogen/constants.py @@ -8,5 +8,6 @@ class Events: TOOL_CALL = "tool_call" NEW_AGENT = "new_agent" NEW_CLIENT = "new_client" + RECEIVED_MESSAGE = "received_message" SESSION_START = "logging_session_start" SESSION_STOP = "logging_session_stop" diff --git a/ads/llm/autogen/reports/base.py b/ads/llm/autogen/reports/base.py new file mode 100644 index 000000000..4f081eb76 --- /dev/null +++ b/ads/llm/autogen/reports/base.py @@ -0,0 +1,67 @@ +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +import json +import logging +import os + +from jinja2 import Environment, FileSystemLoader + +logger = logging.getLogger(__name__) + + +class BaseReport: + """Base class containing utilities for generating reports.""" + + @staticmethod + def format_json_string(s) -> str: + """Formats the JSON string in markdown.""" + return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" + + @staticmethod + def _parse_date_time(datetime_string: str): + """Parses a datetime string in the logs into date and time. + Keeps only the seconds in the time. + """ + date_str, time_str = datetime_string.split(" ", 1) + time_str = time_str.split(".", 1)[0] + return date_str, time_str + + @staticmethod + def _preview_message(message: str, max_length=30) -> str: + """Shows the beginning part of a string message.""" + # Return the entire string if it is less than the max_length + if len(message) <= max_length: + return message + # Go backward until we find the first whitespace + idx = 30 + while not message[idx].isspace() and idx > 0: + idx -= 1 + # If we found a whitespace + if idx > 0: + return message[:idx] + "..." + # If we didn't find a whitespace + return message[:30] + "..." + + @classmethod + def _render_template(cls, template_path, **kwargs) -> str: + """Render Jinja template with kwargs.""" + template_dir = os.path.join(os.path.dirname(__file__), "templates") + environment = Environment( + loader=FileSystemLoader(template_dir), autoescape=True + ) + template = environment.get_template(template_path) + try: + html = template.render(**kwargs) + except Exception: + logger.error( + "Unable to render template %s with data:\n%s", + template_path, + str(kwargs), + ) + return cls._render_template( + template_path=template_path, + sender=kwargs.get("sender", "N/A"), + content="TEMPLATE RENDER ERROR", + timestamp=kwargs.get("timestamp", ""), + ) + return html diff --git a/ads/llm/autogen/reports/data.py b/ads/llm/autogen/reports/data.py new file mode 100644 index 000000000..9e70cfa7a --- /dev/null +++ b/ads/llm/autogen/reports/data.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# Copyright (c) 2024 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +"""Contains the data structure for logging and reporting.""" +import copy +import json +from dataclasses import asdict, dataclass, field +from typing import Optional, Union + +from ads.llm.autogen.constants import Events + + +@dataclass +class LogData: + """Base class for the data field of LogRecord.""" + + def to_dict(self): + """Convert the log data to dictionary.""" + return asdict(self) + + +@dataclass +class LogRecord: + """Represents a log record. + + The `data` field is for pre-defined structured data, which should be an instance of LogData. + The `kwargs` field is for freeform key value pairs. + """ + + session_id: str + thread_id: int + timestamp: str + event_name: str + source_id: Optional[int] = None + source_name: Optional[str] = None + # Structured data for specific type of logs + data: Optional[LogData] = None + # Freeform data + kwargs: dict = field(default_factory=dict) + + def to_dict(self): + """Convert the log record to dictionary.""" + return asdict(self) + + def to_string(self): + """Serialize the log record to JSON string.""" + return json.dumps(self.to_dict(), default=str) + + @classmethod + def from_dict(cls, data: dict) -> "LogRecord": + """Initializes a LogRecord object from dictionary.""" + event_mapping = { + Events.NEW_AGENT: AgentData, + Events.TOOL_CALL: ToolCallData, + Events.LLM_CALL: LLMCompletionData, + } + if Events.KEY not in data: + raise KeyError("event_name not found in data.") + + data = copy.deepcopy(data) + + event_name = data["event_name"] + if event_name in event_mapping and data.get("data"): + data["data"] = event_mapping[event_name](**data.pop("data")) + + return cls(**data) + + +@dataclass +class AgentData(LogData): + """Represents agent log Data.""" + + agent_name: str + agent_class: str + agent_module: Optional[str] = None + is_manager: Optional[bool] = None + + +@dataclass +class LLMCompletionData(LogData): + """Represents LLM completion log data.""" + + invocation_id: str + request: dict + response: dict + start_time: str + end_time: str + cost: Optional[float] = None + is_cached: Optional[bool] = None + + +@dataclass +class ToolCallData(LogData): + """Represents tool call log data.""" + + tool_name: str + start_time: str + end_time: str + agent_name: str + agent_class: str + agent_module: Optional[str] = None + input_args: dict = field(default_factory=dict) + returns: Optional[Union[str, list, dict, tuple]] = None diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index 4caaf4ca8..bb1a2a712 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -4,27 +4,44 @@ import copy import json import logging -import os +from dataclasses import dataclass from typing import List, Optional import fsspec import pandas as pd import plotly.express as px import report_creator as rc -from jinja2 import Environment, FileSystemLoader from ads.common.auth import default_signer +from ads.llm.autogen.constants import Events +from ads.llm.autogen.reports.base import BaseReport +from ads.llm.autogen.reports.data import ( + AgentData, + LLMCompletionData, + LogRecord, + ToolCallData, +) from ads.llm.autogen.reports.utils import escape_html, get_duration, is_json_string -from ads.llm.autogen.v02.constants import Events logger = logging.getLogger(__name__) -class SessionReport: +@dataclass +class AgentInvocation: + """Represents an agent invocation.""" + + log: LogRecord + header: str = "" + description: str = "" + duration: Optional[float] = None + + +class SessionReport(BaseReport): """Class for building session report from session log file.""" def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: """Initialize the session report with log file. + It is assumed that the file contains logs for a single session. Parameters ---------- @@ -33,7 +50,7 @@ def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: auth : dict, optional Authentication signer/config for OCI, by default None """ - self.log_file = log_file + self.log_file: str = log_file if self.log_file.startswith("oci://"): auth = auth or default_signer() with fsspec.open(self.log_file, mode="r", **auth) as f: @@ -41,162 +58,190 @@ def __init__(self, log_file: str, auth: Optional[dict] = None) -> None: else: with open(self.log_file, encoding="utf-8") as f: self.log_lines = f.readlines() - self.logs = self._parse_logs() - self.event_logs = self.get_event_logs() - self.invocation_logs = self._parse_invocation_events() + self.logs: List[LogRecord] = self._parse_logs() + + # Parse logs to get entities for building the report + # Agents + self.agents: List[AgentData] = self._parse_agents() + self.managers: List[AgentData] = self._parse_managers() + # Events + self.start_event: LogRecord = self._parse_start_event() + self.session_id: str = self.start_event.session_id + self.llm_calls: List[AgentInvocation] = self._parse_llm_calls() + self.tool_calls: List[AgentInvocation] = self._parse_tool_calls() + self.invocations: List[AgentInvocation] = self._parse_invocations() + self.received_message_logs = self._parse_received_messages() - @staticmethod - def format_json_string(s) -> str: - """Formats the JSON string in markdown.""" - return f"```json\n{json.dumps(json.loads(s), indent=2)}\n```" - - @staticmethod - def _render_template(template_path, **kwargs) -> str: - """Render Jinja template with kwargs.""" - template_dir = os.path.join(os.path.dirname(__file__), "templates") - environment = Environment( - loader=FileSystemLoader(template_dir), autoescape=True - ) - template = environment.get_template(template_path) - try: - html = template.render(**kwargs) - except Exception: - logger.error( - "Unable to render template %s with data:\n%s", - template_path, - str(kwargs), - ) - return SessionReport._render_template( - template_path=template_path, - sender=kwargs.get("sender", "N/A"), - content="TEMPLATE RENDER ERROR", - timestamp=kwargs.get("timestamp", ""), - ) - return html - - @staticmethod - def _preview_message(message: str, max_length=30) -> str: - """Shows the beginning part of a string message.""" - # Return the entire string if it is less than the max_length - if len(message) <= max_length: - return message - # Go backward until we find the first whitespace - idx = 30 - while not message[idx].isspace() and idx > 0: - idx -= 1 - # If we found a whitespace - if idx > 0: - return message[:idx] + "..." - # If we didn't find a whitespace - return message[:30] + "..." - - @staticmethod - def _llm_call_header(log: dict): - request = log.get("request", {}) - source_name = log.get("source_name") - - header = f"{source_name} invoking {request.get('model')}" - if log.get("is_cached"): - header += "(Cached)" - return header - - @staticmethod - def _parse_start_time(log: dict): - """Parses a datetime string in the logs into date and time.""" - start_date, start_time = log.get("start_time", " ").split(" ", 1) - start_time = start_time.split(".", 1)[0] - return start_date, start_time - - def _parse_logs(self) -> List[dict]: - """Parses the logs into dictionary.""" + def _parse_logs(self) -> List[LogRecord]: + """Parses the logs form strings into LogRecord objects.""" logs = [] for i, log in enumerate(self.log_lines): try: - logs.append(json.loads(log)) + logs.append(LogRecord.from_dict(json.loads(log))) except Exception as e: - logger.debug( + logger.error( "Error when parsing log record at line %s:\n%s", str(i + 1), str(e) ) continue + # Sort the logs by timestamp + logs = sorted(logs, key=lambda x: x.timestamp) return logs - def _parse_invocation_events(self): - # LLM calls - llm_events = self.filter_event_logs(Events.LLM_CALL) - llm_call_counter = 1 - for event in llm_events: - event["header"] = self._llm_call_header(event) - llm_call_counter += 1 - # Tool Calls - tool_events = self.filter_event_logs(Events.TOOL_CALL) - for event in tool_events: - event["start_time"] = self.estimate_tool_call_start_time(event) - event["header"] = ( - f"{event.get('source_name', '')} invoking {event.get('tool_name', '')}" + def _parse_agents(self) -> List[AgentData]: + """Parses the logs to identify unique agents. + AutoGen may have new_agent multiple times. + Here we identify the agents by the unique tuple of (name, module, class). + """ + new_agent_logs = self.filter_by_event(Events.NEW_AGENT) + agents = {} + for log in new_agent_logs: + agent: AgentData = log.data + agents[(agent.agent_name, agent.agent_module, agent.agent_class)] = ( + AgentData ) - event["end_time"] = event["timestamp"] - - events = sorted(llm_events + tool_events, key=lambda x: x.get("start_time")) - for i, event in enumerate(events): - event["header"] = f'{str(i + 1)} {event["header"]}' - event["duration"] = get_duration(event) - - return events + return list(agents.values()) + + def _parse_managers(self) -> List[AgentData]: + """Parses the logs to get chat managers.""" + managers = [] + for agent in self.agents: + if agent.is_manager: + managers.append(agent) + return managers + + def _parse_start_event(self) -> LogRecord: + """Parses the logs to get the first logging_session_start event log.""" + records = self.filter_by_event(event_name=Events.SESSION_START) + if not records: + raise ValueError("logging_session_start event is not found in the logs.") + records = sorted(records, key=lambda x: x.timestamp) + return records[0] + + def _parse_llm_calls(self) -> List[AgentInvocation]: + """Parses the logs to get the LLM calls.""" + records = self.filter_by_event(Events.LLM_CALL) + invocations = [] + for record in records: + log_data: LLMCompletionData = record.data + source_name = record.source_name + request = log_data.request + # If there is no request, the log is invalid. + if not request: + continue - def _parse_received_messages(self): - logs = self.filter_event_logs("new_agent") - managers = self.get_chat_managers() - logs = self.filter_event_logs("received_message") + header = f"{source_name} invoking {request.get('model')}" + if log_data.is_cached: + header += " (Cached)" + invocations.append( + AgentInvocation( + header=header, + log=record, + duration=get_duration(log_data.start_time, log_data.end_time), + ) + ) + return invocations + + def _parse_tool_calls(self) -> List[AgentInvocation]: + """Parses the logs to get the tool calls.""" + records = self.filter_by_event(Events.TOOL_CALL) + invocations = [] + for record in records: + log_data: ToolCallData = record.data + source_name = record.source_name + invocations.append( + AgentInvocation( + log=record, + header=f"{source_name} invoking {log_data.tool_name}", + duration=get_duration(log_data.start_time, log_data.end_time), + ) + ) + return invocations + + def _parse_invocations(self) -> List[AgentInvocation]: + """Add numbering to the combined list of LLM and tool calls.""" + invocations = self.llm_calls + self.tool_calls + invocations = sorted(invocations, key=lambda x: x.log.data.start_time) + for i, invocation in enumerate(invocations): + invocation.header = f"{str(i + 1)} {invocation.header}" + return invocations + + def _parse_received_messages(self) -> List[LogRecord]: + """Parses the logs to get the received_message events.""" + managers = [manager.agent_name for manager in self.managers] + logs = self.filter_by_event(Events.RECEIVED_MESSAGE) if not logs: return [] - states: List[dict] = [json.loads(log.get("json_state", "{}")) for log in logs] - for i, state in enumerate(states): - state.update(logs[i]) - logs = states - logs = sorted(logs, key=lambda x: x.get("timestamp", "")) - logs = [log for log in logs if log.get("sender") not in managers] + logs = sorted(logs, key=lambda x: x.timestamp) + logs = [log for log in logs if log.kwargs.get("sender") not in managers] return logs - def get_event_data(self, event_name: str): - for log in self.logs: - if log.get(Events.KEY) == event_name: - return log - return None + def filter_by_event(self, event_name: str) -> List[LogRecord]: + """Filters the logs by event name. - def filter_event_logs(self, event_name) -> List[dict]: + Parameters + ---------- + event_name : str + Name of the event. + + Returns + ------- + List[LogRecord] + A list of LogRecord objects for the event. + """ filtered_logs = [] for log in self.logs: - if log.get(Events.KEY) == event_name: - filtered_logs.append(copy.deepcopy(log)) + if log.event_name == event_name: + filtered_logs.append(log) return filtered_logs - def get_event_logs(self): - event_logs = [] - for log in self.logs: - if Events.KEY in log: - event_logs.append(log) - return sorted( - event_logs, key=lambda x: x.get("timestamp", x.get("end_time", "")) - ) + def _build_flowchart(self): + """Builds the flowchart of agent chats.""" + senders = [] + for log in self.received_message_logs: + sender = log.kwargs.get("sender") + senders.append(sender) - def estimate_tool_call_start_time(self, tool_call_log): - event_index = self.event_logs.index(tool_call_log) - while event_index > 0: - log = self.event_logs[event_index] - - if log.get("json_state") and ( - json.loads(log.get("json_state", "")).get("reply_func_name") - == "check_termination_and_human_reply" - ): - return log.get("timestamp") - event_index -= 1 - return None - - def build_timeline_tab(self): - if not self.invocation_logs: + diagram_src = "graph LR\n" + prev_sender = None + links = [] + # Conversation Flow + for sender in senders: + if prev_sender is None: + link = f"START([START]) --> {sender}" + else: + link = f"{prev_sender} --> {sender}" + if link not in links: + links.append(link) + prev_sender = sender + links.append(f"{prev_sender} --> END([END])") + # Tool Calls + for invocation in self.tool_calls: + tool = invocation.log.data.tool_name + agent = invocation.log.data.agent_name + if tool and agent: + link = f"{agent} <--> {tool}[[{tool}]]" + if link not in links: + links.append(link) + + diagram_src += "\n".join(links) + return rc.Diagram(src=diagram_src, label="Flowchart") + + def _build_timeline_tab(self): + """Builds the plotly timeline chart.""" + if not self.invocations: return rc.Text("No LLM or Tool Calls.", label="Timeline") - df = pd.DataFrame(self.invocation_logs) + invocations = [] + for invocation in self.invocations: + invocations.append( + { + "start_time": invocation.log.data.start_time, + "end_time": invocation.log.data.end_time, + "header": invocation.header, + "duration": invocation.duration, + } + ) + df = pd.DataFrame(invocations) fig = px.timeline( df, x_start="start_time", @@ -210,31 +255,29 @@ def build_timeline_tab(self): fig.update_layout(showlegend=False) fig.update_yaxes(autorange="reversed") return rc.Block( - rc.Widget(fig, label="Timeline"), self.build_flowchart(), label="Timeline" + rc.Widget(fig, label="Timeline"), self._build_flowchart(), label="Timeline" ) - def format_messages(self, messages): + def _format_messages(self, messages: List[dict]): + """Formats the LLM call messages to be displayed in the report.""" text = "" for message in messages: text += f"**{message.get('role')}**:\n{message.get('content')}\n\n" return text - def build_llm_call(self, llm_log: dict): - request = llm_log.get("request", {}) - header = llm_log.get("header", "") + def _build_llm_call(self, invocation: AgentInvocation): + """Builds the LLM call details.""" + log_data: LLMCompletionData = invocation.log.data + request = log_data.request + response = log_data.response - start_date, start_time = self._parse_start_time(llm_log) - - description = f" " - if llm_log.get("is_cached"): - description += ", **CACHED**" + start_date, start_time = self._parse_date_time(log_data.start_time) request_value = f"{str(len(request.get('messages')))} messages" tools = request.get("tools", []) if tools: request_value += f", {str(len(tools))} tools" - response = llm_log.get("response") response_message = response.get("choices")[0].get("message") response_text = response_message.get("content") or "" tool_calls = response_message.get("tool_calls") @@ -243,7 +286,6 @@ def build_llm_call(self, llm_log: dict): for tool_call in tool_calls: func = tool_call.get("function") response_text += f"\n\n`{func.get('name')}(**{func.get('arguments')})`" - duration = get_duration(llm_log) metrics = [ rc.Metric(heading="Time", value=start_time, label=start_date), @@ -252,12 +294,12 @@ def build_llm_call(self, llm_log: dict): value=len(request.get("messages", [])), ), rc.Metric(heading="Tools", value=len(tools)), - rc.Metric(heading="Duration", value=duration, unit="s"), + rc.Metric(heading="Duration", value=invocation.duration, unit="s"), rc.Metric( heading="Cached", - value="Yes" if llm_log.get("is_cached") else "No", + value="Yes" if log_data.is_cached else "No", ), - rc.Metric(heading="Cost", value=llm_log.get("cost")), + rc.Metric(heading="Cost", value=log_data.cost), ] usage = response.get("usage") @@ -270,11 +312,11 @@ def build_llm_call(self, llm_log: dict): ) return rc.Block( - rc.Block(rc.Group(*metrics, label=header)), + rc.Block(rc.Group(*metrics, label=invocation.header)), rc.Group( rc.Block( rc.Markdown( - self.format_messages(request.get("messages")), label="Request" + self._format_messages(request.get("messages")), label="Request" ), rc.Collapse( rc.Json(request), @@ -291,32 +333,31 @@ def build_llm_call(self, llm_log: dict): ), ) - def build_tool_call(self, log: dict): - header = log.get("header", "") - request = copy.deepcopy(log) + def _build_tool_call(self, invocation: AgentInvocation): + """Builds the tool call details.""" + log_data: ToolCallData = invocation.log.data + request = log_data.to_dict() response = request.pop("returns", {}) - start_date, start_time = self._parse_start_time(log) - tool_call_args = log.get("input_args", "") + start_date, start_time = self._parse_date_time(log_data.start_time) + tool_call_args = log_data.input_args if is_json_string(tool_call_args): tool_call_args = self.format_json_string(tool_call_args) if is_json_string(response): response = self.format_json_string(response) - duration = get_duration(log) - metrics = [ rc.Metric(heading="Time", value=start_time, label=start_date), - rc.Metric(heading="Duration", value=duration, unit="s"), + rc.Metric(heading="Duration", value=invocation.duration, unit="s"), ] return rc.Block( - rc.Block(rc.Group(*metrics, label=header)), + rc.Block(rc.Group(*metrics, label=invocation.header)), rc.Group( rc.Block( rc.Markdown( - (log.get("tool_name") or "") + "\n\n" + tool_call_args, + (log_data.tool_name or "") + "\n\n" + tool_call_args, label="Request", ), rc.Collapse( @@ -328,74 +369,44 @@ def build_tool_call(self, log: dict): ), ) - def build_invocations_tab(self) -> rc.Block: + def _build_invocations_tab(self) -> rc.Block: + """Builds the invocations tab.""" blocks = [] - for log in self.invocation_logs: - event_name = log.get(Events.KEY) + for invocation in self.invocations: + event_name = invocation.log.event_name if event_name == Events.LLM_CALL: - blocks.append(self.build_llm_call(log)) + blocks.append(self._build_llm_call(invocation)) elif event_name == Events.TOOL_CALL: - blocks.append(self.build_tool_call(log)) + blocks.append(self._build_tool_call(invocation)) return rc.Block( *blocks, label="Invocations", ) - def build_flowchart(self): - logs = self.received_message_logs - senders = [] - for log in logs: - sender = log.get("sender") - senders.append(sender) - - diagram_src = "graph LR\n" - prev_sender = None - links = [] - # Conversation Flow - for sender in senders: - if prev_sender is None: - link = f"START([START]) --> {sender}" - else: - link = f"{prev_sender} --> {sender}" - if link not in links: - links.append(link) - prev_sender = sender - links.append(f"{prev_sender} --> END([END])") - # Tool Calls - logs = self.filter_event_logs(Events.TOOL_CALL) - for log in logs: - tool = log.get("tool_name") - agent = log.get("source_name") - if tool and agent: - link = f"{agent} <--> {tool}[[{tool}]]" - if link not in links: - links.append(link) - - diagram_src += "\n".join(links) - return rc.Diagram(src=diagram_src, label="Flowchart") - - def build_chat_tab(self) -> rc.Block: - logs = copy.deepcopy(self.received_message_logs) - if not logs: + def _build_chat_tab(self) -> rc.Block: + """Builds the chat tab.""" + if not self.received_message_logs: return rc.Text("No messages received in this session.") # The agent sending the first message will be placed on the right. # All other agents will be placed on the left - host = logs[0].get("sender") + host = self.received_message_logs[0].kwargs.get("sender") blocks = [] - for log in copy.deepcopy(self.received_message_logs): - sender = log.get("sender") - message = log.get("message", "") + for log in self.received_message_logs: + context = copy.deepcopy(log.kwargs) + context.update(log.to_dict()) + sender = context.get("sender") + message = context.get("message", "") # Content if isinstance(message, dict) and "content" in message: content = message.get("content", "") if is_json_string(content): - log["json_content"] = json.dumps(json.loads(content), indent=2) - log["content"] = content + context["json_content"] = json.dumps(json.loads(content), indent=2) + context["content"] = content else: - log["content"] = message - if log["content"] is None: - log["content"] = "" + context["content"] = message + if context["content"] is None: + context["content"] = "" # Tool call if isinstance(message, dict) and "tool_calls" in message: tool_calls = message.get("tool_calls") @@ -408,11 +419,11 @@ def build_chat_tab(self) -> rc.Block: tool_call_signatures.append( f'{func.get("name")}(**{func.get("arguments", "{}")})' ) - log["tool_calls"] = tool_call_signatures + context["tool_calls"] = tool_call_signatures if sender == host: - html = self._render_template("chat_box_rt.html", **log) + html = self._render_template("chat_box_rt.html", **context) else: - html = self._render_template("chat_box_lt.html", **log) + html = self._render_template("chat_box_lt.html", **context) blocks.append(rc.Html(html)) return rc.Block( @@ -420,7 +431,8 @@ def build_chat_tab(self) -> rc.Block: label="Chats", ) - def build_logs_tab(self) -> rc.Block: + def _build_logs_tab(self) -> rc.Block: + """Builds the logs tab.""" blocks = [] for log_line in self.log_lines: if is_json_string(log_line): @@ -440,47 +452,25 @@ def build_logs_tab(self) -> rc.Block: label="Logs", ) - def count_agents(self): - # AutoGen may have new_agent multiple times - # Here we count the number agents by name - new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) - agents = set() - for log in new_agent_logs: - agents.add((log.get("agent_name"), log.get("agent_type"))) - return len(agents) - - def get_chat_managers(self): - # AutoGen may have new_agent multiple times - # Here we count the number agents by name - new_agent_logs = self.filter_event_logs(Events.NEW_AGENT) - agents = set() - for log in new_agent_logs: - if not log.get("is_manager"): - continue - agents.add(log.get("agent_name")) - return agents - def build(self, output_file: str): - start_event = self.get_event_data(Events.SESSION_START) - start_time = start_event.get("timestamp") - session_id = start_event.get("session_id") - - event_logs = self.get_event_logs() + """Builds the session report. - llm_call_logs = self.filter_event_logs(Events.LLM_CALL) - tool_call_logs = self.filter_event_logs(Events.TOOL_CALL) + Parameters + ---------- + output_file : str + Local path or OCI object storage URI to save the report HTML file. + """ - chat_managers = self.get_chat_managers() - if not chat_managers: + if not self.managers: agent_label = "" - elif len(chat_managers) == 1: + elif len(self.managers) == 1: agent_label = "+1 chat manager" else: - agent_label = f"+{str(len(chat_managers))} chat managers" + agent_label = f"+{str(len(self.managers))} chat managers" with rc.ReportCreator( - title=f"AutoGen Session: {session_id}", - description=f"Started at {start_time}", + title=f"AutoGen Session: {self.session_id}", + description=f"Started at {self.start_event.timestamp}", footer="Created with ❤️ by Oracle ADS", ) as report: @@ -488,28 +478,28 @@ def build(self, output_file: str): rc.Group( rc.Metric( heading="Agents", - value=self.count_agents() - len(chat_managers), + value=len(self.agents) - len(self.managers), label=agent_label, ), rc.Metric( heading="Events", - value=len(event_logs), + value=len(self.logs), ), rc.Metric( heading="LLM Calls", - value=len(llm_call_logs), + value=len(self.llm_calls), ), rc.Metric( heading="Tool Calls", - value=len(tool_call_logs), + value=len(self.tool_calls), ), ), rc.Select( blocks=[ - self.build_timeline_tab(), - self.build_invocations_tab(), - self.build_chat_tab(), - self.build_logs_tab(), + self._build_timeline_tab(), + self._build_invocations_tab(), + self._build_chat_tab(), + self._build_logs_tab(), ], ), ) diff --git a/ads/llm/autogen/reports/utils.py b/ads/llm/autogen/reports/utils.py index 7de78a04b..baaacc315 100644 --- a/ads/llm/autogen/reports/utils.py +++ b/ads/llm/autogen/reports/utils.py @@ -9,33 +9,32 @@ def parse_datetime(s): return datetime.strptime(s, "%Y-%m-%d %H:%M:%S.%f") -def get_duration(log: dict) -> float: - """Gets the duration of an event in seconds from a log record. - The log record should contain two keys: `start_time` and `end_time`. +def get_duration(start_time: str, end_time: str) -> float: + """Gets the duration in seconds between `start_time` and `end_time`. Each of the value should be a time in string format of `%Y-%m-%d %H:%M:%S.%f` - The duration is calculated by parsing two strings, and - subtracting the `end_time` from `start_time`. + The duration is calculated by parsing the two strings, + then subtracting the `end_time` from `start_time`. If either `start_time` or `end_time` is not presented, 0 will be returned. Parameters ---------- - log : dict - A log record containing keys: `start_time` and `end_time` + start_time : str + The start time. + end_time : str + The end time. Returns ------- float Duration in seconds. """ - if "end_time" not in log or "start_time" not in log: + if not start_time or not end_time: return 0 - return ( - parse_datetime(log.get("end_time")) - parse_datetime(log.get("start_time")) - ).total_seconds() + return (parse_datetime(end_time) - parse_datetime(start_time)).total_seconds() def is_json_string(s): diff --git a/ads/llm/autogen/v02/loggers/session_logger.py b/ads/llm/autogen/v02/loggers/session_logger.py index 709177511..9146a068d 100644 --- a/ads/llm/autogen/v02/loggers/session_logger.py +++ b/ads/llm/autogen/v02/loggers/session_logger.py @@ -1,5 +1,5 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. -# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ import importlib import inspect import json @@ -34,15 +34,35 @@ import ads from ads.common.auth import default_signer +from ads.llm.autogen.constants import Events +from ads.llm.autogen.reports.data import ( + AgentData, + LLMCompletionData, + LogRecord, + ToolCallData, +) from ads.llm.autogen.reports.session import SessionReport -from ads.llm.autogen.v02.constants import Events from ads.llm.autogen.v02.log_handlers.oci_file_handler import OCIFileHandler logger = logging.getLogger(__name__) -def is_json_serializable(obj) -> bool: - """Checks if an object is JSON serializable.""" +CONST_REPLY_FUNC_NAME = "reply_func_name" + + +def is_json_serializable(obj: Any) -> bool: + """Checks if an object is JSON serializable. + + Parameters + ---------- + obj : Any + Any object. + + Returns + ------- + bool + True if the object is JSON serializable, otherwise False. + """ try: json.dumps(obj) except Exception: @@ -70,9 +90,10 @@ def serialize_response(response) -> dict: def serialize( obj: Union[int, float, str, bool, Dict[Any, Any], List[Any], Tuple[Any, ...], Any], - exclude: Tuple[str, ...] = (), + exclude: Tuple[str, ...] = ("api_key", "__class__"), no_recursive: Tuple[Any, ...] = (), ) -> Any: + """Serializes an object for logging purpose.""" try: if isinstance(obj, (int, float, str, bool)): return obj @@ -105,7 +126,7 @@ def serialize( @dataclass class LoggingSession: - """Represents a logging session.""" + """Represents a logging session for a specific thread.""" session_id: str log_dir: str @@ -119,6 +140,14 @@ class LoggingSession: @property def report(self) -> str: + """HTML report path of the logging session. + If the a pre-authenticated link is generated for the report, + the pre-authenticated link will be returned. + + If the report is saved to OCI object storage, the URI will be return. + If the report is saved locally, the local path will be return. + If there is no report generated, `None` will be returned. + """ if self.par_uri: return self.par_uri elif self.report_file: @@ -126,6 +155,7 @@ def report(self) -> str: return None def __repr__(self) -> str: + """Shows the link to report if it is available, otherwise shows the log file link.""" if self.report: return self.report return self.log_file @@ -248,11 +278,21 @@ def __init__( self.session = self.new_session( log_dir=log_dir, session_id=session_id, auth=auth ) + # Log only if started is True self.started = False + # Keep track of last check_termination_and_human_reply for calculating tool call duration + # This will be a dictionary mapping the IDs of the agents to their last timestamp + # of check_termination_and_human_reply + self.last_agent_checks = {} + @property - def logger(self) -> Optional[str]: - """Logger for the thread.""" + def logger(self) -> Optional[logging.Logger]: + """Logger for the thread. + + This property is used to determine whether the log should be saved. + No log will be saved if the logger is None. + """ if not self.started: return None thread_id = threading.get_ident() @@ -356,9 +396,44 @@ def generate_report( kwargs = kwargs or self.par_kwargs or {} report_file = os.path.join(self.report_dir, f"{self.session_id}.html") - return self.session.create_report( + report_link = self.session.create_report( report_file=report_file, return_par_uri=self.report_par_uri, **kwargs ) + print(f"ADS AutoGen Session Report: {report_link}") + return report_link + + def new_record(self, event_name: str, source: Any = None) -> LogRecord: + """Initialize a new log record. + + The record is not logged until `self.log()` is called. + """ + record = LogRecord( + session_id=self.session_id, + thread_id=threading.get_ident(), + timestamp=get_current_ts(), + event_name=event_name, + ) + if source: + record.source_id = id(source) + record.source_name = str(source.name) if hasattr(source, "name") else source + return record + + def log(self, record: LogRecord) -> None: + """Logs a record. + + Parameters + ---------- + data : dict + Data to be logged. + """ + # Do nothing if there is no logger for the thread. + if not self.logger: + return + + try: + self.logger.info(record.to_string()) + except Exception: + self.logger.info("Failed to log %s", record.event_name) def start(self) -> str: """Start the logging session and return the session_id.""" @@ -392,7 +467,7 @@ def stop(self) -> None: self.started = False if self.report_dir: try: - return self.generate_report() + self.generate_report() except Exception as e: logger.error( "Failed to create session report for AutoGen session %s\n%s", @@ -414,115 +489,110 @@ def log_chat_completion( start_time: str, ) -> None: """ - Log a chat completion. + Logs a chat completion. """ if not self.logger: return - thread_id = threading.get_ident() - source_name = None - if isinstance(source, str): - source_name = source - else: - source_name = source.name - - try: - log_data = json.dumps( - { - Events.KEY: Events.LLM_CALL, - "invocation_id": str(invocation_id), - "client_id": client_id, - "wrapper_id": wrapper_id, - "request": serialize(request), - "response": serialize_response(response), - "is_cached": is_cached, - "cost": cost, - "start_time": start_time, - "end_time": get_current_ts(), - "thread_id": thread_id, - "source_name": source_name, - } - ) + record = self.new_record(event_name=Events.LLM_CALL, source=source) + record.data = LLMCompletionData( + invocation_id=str(invocation_id), + request=serialize(request), + response=serialize_response(response), + start_time=start_time, + end_time=get_current_ts(), + cost=cost, + is_cached=is_cached, + ) + record.kwargs = { + "client_id": client_id, + "wrapper_id": wrapper_id, + } - self.logger.info(log_data) - except Exception as e: - self.logger.error(f"[file_logger] Failed to log chat completion: {e}") + self.log(record) def log_function_use( self, source: Union[str, Agent], function: F, args: Dict[str, Any], returns: Any ) -> None: """ - Log a registered function(can be a tool) use from an agent or a string source. + Logs a registered function(can be a tool) use from an agent or a string source. """ if not self.logger: return - thread_id = threading.get_ident() + source_id = id(source) + if source_id in self.last_agent_checks: + start_time = self.last_agent_checks[source_id] + else: + start_time = get_current_ts() + + record = self.new_record(Events.TOOL_CALL, source=source) + record.data = ToolCallData( + tool_name=function.__name__, + start_time=start_time, + end_time=record.timestamp, + agent_name=str(source.name) if hasattr(source, "name") else source, + agent_module=source.__module__, + agent_class=source.__class__.__name__, + input_args=safe_serialize(args), + returns=safe_serialize(returns), + ) - try: - log_data = json.dumps( - { - Events.KEY: Events.TOOL_CALL, - "source_id": id(source), - "source_name": ( - str(source.name) if hasattr(source, "name") else source - ), - "agent_module": source.__module__, - "agent_class": source.__class__.__name__, - "tool_name": function.__name__, - # This is the tool call end time - "timestamp": get_current_ts(), - "thread_id": thread_id, - "input_args": safe_serialize(args), - "returns": safe_serialize(returns), - } - ) - self.logger.info(log_data) - except Exception as e: - self.logger.error(f"[file_logger] Failed to log event {e}") + self.log(record) def log_new_agent( self, agent: ConversableAgent, init_args: Dict[str, Any] = {} ) -> None: """ - Log a new agent instance. + Logs a new agent instance. """ if not self.logger: return - thread_id = threading.get_ident() + record = self.new_record(event_name=Events.NEW_AGENT, source=agent) + record.data = AgentData( + agent_name=( + agent.name + if hasattr(agent, "name") and agent.name is not None + else str(agent) + ), + agent_module=agent.__module__, + agent_class=agent.__class__.__name__, + is_manager=isinstance(agent, GroupChatManager), + ) + record.kwargs = { + "wrapper_id": serialize( + agent.client.wrapper_id + if hasattr(agent, "client") and agent.client is not None + else "" + ), + "args": serialize(init_args), + } + self.log(record) - try: - log_data = json.dumps( - { - Events.KEY: Events.NEW_AGENT, - "id": id(agent), - "agent_name": ( - agent.name - if hasattr(agent, "name") and agent.name is not None - else "" - ), - "wrapper_id": serialize( - agent.client.wrapper_id - if hasattr(agent, "client") and agent.client is not None - else "" - ), - "session_id": self.session_id, - "current_time": get_current_ts(), - "agent_type": type(agent).__name__, - "args": serialize(init_args), - "thread_id": thread_id, - "is_manager": isinstance(agent, GroupChatManager), - } + def log_event( + self, source: Union[str, Agent], name: str, **kwargs: Dict[str, Any] + ) -> None: + """ + Logs an event. + """ + record = self.new_record(event_name=name) + record.source_id = id(source) + record.source_name = str(source.name) if hasattr(source, "name") else source + record.kwargs = kwargs + if isinstance(source, Agent): + if ( + CONST_REPLY_FUNC_NAME in kwargs + and kwargs[CONST_REPLY_FUNC_NAME] == "check_termination_and_human_reply" + ): + self.last_agent_checks[record.source_id] = record.timestamp + record.data = AgentData( + agent_name=record.source_name, + agent_module=source.__module__, + agent_class=source.__class__.__name__, + is_manager=isinstance(source, GroupChatManager), ) - self.logger.info(log_data) - except Exception as e: - self.logger.error(f"[file_logger] Failed to log new agent: {e}") - - def log_event(self, *args, **kwargs) -> None: - if not self.logger: - return - return super().log_event(*args, **kwargs) + self.log(record) def log_new_wrapper(self, *args, **kwargs) -> None: # Do not log new wrapper. @@ -537,25 +607,17 @@ def log_new_client( ) -> None: if not self.logger: return - thread_id = threading.get_ident() - try: - log_data = json.dumps( - { - Events.KEY: Events.NEW_CLIENT, - "client_id": id(client), - "wrapper_id": id(wrapper), - "session_id": self.session_id, - "class": type(client).__name__, - # init_args may contain credentials like api_key - # "json_state": json.dumps(init_args), - "timestamp": get_current_ts(), - "thread_id": thread_id, - } - ) - self.logger.info(log_data) - except Exception as e: - self.logger.error(f"[file_logger] Failed to log event {e}") + record = self.new_record(event_name=Events.NEW_CLIENT) + # init_args may contain credentials like api_key + record.kwargs = { + "client_id": id(client), + "wrapper_id": id(wrapper), + "class": client.__class__.__name__, + "args": serialize(init_args), + } + + self.log(record) def __repr__(self) -> str: return self.session.__repr__() From faa2ca533aca69818a3fc3309c2173f3f131ca69 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 19 Dec 2024 12:20:16 -0500 Subject: [PATCH 48/58] Add context manager for session logger. --- ads/llm/autogen/v02/loggers/session_logger.py | 17 ++++++++++++++++ ads/llm/autogen/v02/runtime_logging.py | 20 ++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/ads/llm/autogen/v02/loggers/session_logger.py b/ads/llm/autogen/v02/loggers/session_logger.py index 9146a068d..ffdc61908 100644 --- a/ads/llm/autogen/v02/loggers/session_logger.py +++ b/ads/llm/autogen/v02/loggers/session_logger.py @@ -42,6 +42,7 @@ ToolCallData, ) from ads.llm.autogen.reports.session import SessionReport +from ads.llm.autogen.v02 import runtime_logging from ads.llm.autogen.v02.log_handlers.oci_file_handler import OCIFileHandler logger = logging.getLogger(__name__) @@ -621,3 +622,19 @@ def log_new_client( def __repr__(self) -> str: return self.session.__repr__() + + def __enter__(self) -> "SessionLogger": + """Starts the session logger + + Returns + ------- + SessionLogger + The session logger + """ + runtime_logging.start(self) + return self + + def __exit__(self, *args, **kwargs): + """Stops the session logger.""" + # TODO: log exceptions. + runtime_logging.stop(self) diff --git a/ads/llm/autogen/v02/runtime_logging.py b/ads/llm/autogen/v02/runtime_logging.py index 1f6266df6..7d65bdb12 100644 --- a/ads/llm/autogen/v02/runtime_logging.py +++ b/ads/llm/autogen/v02/runtime_logging.py @@ -137,12 +137,22 @@ def start( return session_id -def stop() -> BaseLogger: - """Stops all AutoGen loggers. - Once stopped, all loggers will be removed. +def stop(*loggers) -> BaseLogger: + """Stops AutoGen logger. + If loggers are managed by LoggerManager, + you may specify one or more loggers to be stopped. + If no logger is specified, all loggers will be stopped. + Stopped loggers will be removed from the LoggerManager. """ - autogen.runtime_logging.stop() - return autogen.runtime_logging.autogen_logger + autogen_logger = autogen.runtime_logging.autogen_logger + if isinstance(autogen_logger, LoggerManager) and loggers: + for logger in loggers: + logger.stop() + if logger in autogen_logger.loggers: + autogen_logger.loggers.remove(logger) + else: + autogen.runtime_logging.stop() + return autogen_logger def get_loggers() -> List[BaseLogger]: From d84c17882ad17fb6e9dc97e5a91fb44d7dfbcbfc Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 19 Dec 2024 12:27:04 -0500 Subject: [PATCH 49/58] Fix chat manager parsing. --- ads/llm/autogen/reports/session.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index bb1a2a712..f779f1163 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -97,9 +97,7 @@ def _parse_agents(self) -> List[AgentData]: agents = {} for log in new_agent_logs: agent: AgentData = log.data - agents[(agent.agent_name, agent.agent_module, agent.agent_class)] = ( - AgentData - ) + agents[(agent.agent_name, agent.agent_module, agent.agent_class)] = agent return list(agents.values()) def _parse_managers(self) -> List[AgentData]: From 277e3b2708339eb960533b5376ab1746e080c1bc Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 19 Dec 2024 15:04:59 -0500 Subject: [PATCH 50/58] Log and show exception in report. --- ads/llm/autogen/constants.py | 2 + ads/llm/autogen/reports/session.py | 39 ++++++++++++++----- ads/llm/autogen/v02/loggers/session_logger.py | 11 +++++- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/ads/llm/autogen/constants.py b/ads/llm/autogen/constants.py index 9e918d99c..75d3bcd32 100644 --- a/ads/llm/autogen/constants.py +++ b/ads/llm/autogen/constants.py @@ -4,6 +4,8 @@ class Events: KEY = "event_name" + + EXCEPTION = "exception" LLM_CALL = "llm_call" TOOL_CALL = "tool_call" NEW_AGENT = "new_agent" diff --git a/ads/llm/autogen/reports/session.py b/ads/llm/autogen/reports/session.py index f779f1163..8992f7c0b 100644 --- a/ads/llm/autogen/reports/session.py +++ b/ads/llm/autogen/reports/session.py @@ -384,7 +384,7 @@ def _build_invocations_tab(self) -> rc.Block: def _build_chat_tab(self) -> rc.Block: """Builds the chat tab.""" if not self.received_message_logs: - return rc.Text("No messages received in this session.") + return rc.Text("No messages received in this session.", label="Chats") # The agent sending the first message will be placed on the right. # All other agents will be placed on the left host = self.received_message_logs[0].kwargs.get("sender") @@ -450,6 +450,23 @@ def _build_logs_tab(self) -> rc.Block: label="Logs", ) + def _build_errors_tab(self) -> Optional[rc.Block]: + """Builds the error tab to show exception.""" + errors = self.filter_by_event(Events.EXCEPTION) + if not errors: + return None + blocks = [] + for error in errors: + label = f'{error.kwargs.get("exc_type", "")} - {error.kwargs.get("exc_value", "")}' + variables: dict = error.kwargs.get("locals", {}) + table = "| Variable | Value |\n|---|---|\n" + table += "\n".join([f"| {k} | {v} |" for k, v in variables.items()]) + blocks += [ + rc.Unformatted(text=error.kwargs.get("traceback", ""), label=label), + rc.Markdown(table), + ] + return rc.Block(*blocks, label="Error") + def build(self, output_file: str): """Builds the session report. @@ -466,6 +483,17 @@ def build(self, output_file: str): else: agent_label = f"+{str(len(self.managers))} chat managers" + blocks = [ + self._build_timeline_tab(), + self._build_invocations_tab(), + self._build_chat_tab(), + self._build_logs_tab(), + ] + + error_block = self._build_errors_tab() + if error_block: + blocks.append(error_block) + with rc.ReportCreator( title=f"AutoGen Session: {self.session_id}", description=f"Started at {self.start_event.timestamp}", @@ -492,14 +520,7 @@ def build(self, output_file: str): value=len(self.tool_calls), ), ), - rc.Select( - blocks=[ - self._build_timeline_tab(), - self._build_invocations_tab(), - self._build_chat_tab(), - self._build_logs_tab(), - ], - ), + rc.Select(blocks=blocks), ) report.save(view, output_file) diff --git a/ads/llm/autogen/v02/loggers/session_logger.py b/ads/llm/autogen/v02/loggers/session_logger.py index ffdc61908..596e14fef 100644 --- a/ads/llm/autogen/v02/loggers/session_logger.py +++ b/ads/llm/autogen/v02/loggers/session_logger.py @@ -634,7 +634,14 @@ def __enter__(self) -> "SessionLogger": runtime_logging.start(self) return self - def __exit__(self, *args, **kwargs): + def __exit__(self, exc_type, exc_value, tb): """Stops the session logger.""" - # TODO: log exceptions. + record = self.new_record(event_name=Events.EXCEPTION) + record.kwargs = { + "exc_type": exc_type.__name__, + "exc_value": str(exc_value), + "traceback": "".join(traceback.format_tb(tb)), + "locals": serialize(tb.tb_frame.f_locals), + } + self.log(record) runtime_logging.stop(self) From fbb42572d9b91650b9bd3b3488b7645b1d54be47 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Thu, 19 Dec 2024 15:27:01 -0500 Subject: [PATCH 51/58] Add OCI monitoring logger. --- ads/llm/autogen/v02/loggers/metric_logger.py | 222 +++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 ads/llm/autogen/v02/loggers/metric_logger.py diff --git a/ads/llm/autogen/v02/loggers/metric_logger.py b/ads/llm/autogen/v02/loggers/metric_logger.py new file mode 100644 index 000000000..17c699029 --- /dev/null +++ b/ads/llm/autogen/v02/loggers/metric_logger.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python +# Copyright (c) 2023, 2024 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +import json +import logging +import threading +from datetime import datetime +from typing import Any, Dict, List, Union +from uuid import UUID, uuid4 + +import oci +from autogen import Agent, ConversableAgent, OpenAIWrapper +from autogen.logger.base_logger import BaseLogger, LLMConfig +from autogen.logger.file_logger import safe_serialize +from autogen.logger.logger_utils import get_current_ts, to_dict +from oci.monitoring import MonitoringClient +from pydantic import BaseModel + +import ads +import ads.common +import ads.common.oci_client + +logger = logging.getLogger(__name__) + + +class Metric(BaseModel): + """Represents the metric to be logged.""" + + name: str + value: float + agent_name: str + namespace: str + time: str + dimension_name: str + dimension_value: str + + +class AgentMetricMonitoring(BaseLogger): + """AutoGen logger for agent metrics.""" + + def __init__( + self, + session_id=None, + metric_compartment=None, + agent_name=None, + metric_namespace=None, + region=None, + ): + self.session_id = str(session_id or uuid4()) + self.metric_compartment = metric_compartment + auth = ads.auth.default_signer() + signer = auth.get("signer") + if not region: + if not (hasattr(signer, "region") and signer.region): + raise ValueError( + "Unable to determine the region for OCI monitoring service. " + "Please specify the region using the `region` argument." + ) + else: + region = signer.region + self.monitoring_client = MonitoringClient( + config=auth.get("config", {}), + signer=signer, + # Metrics should be submitted with the "telemetry-ingestion" endpoint instead. + # See note here: https://docs.oracle.com/iaas/api/#/en/monitoring/20180401/MetricData/PostMetricData + service_endpoint=f"https://telemetry-ingestion.{region}.oraclecloud.com", + ) + self.agent_name = agent_name + self.metric_namespace = metric_namespace + + def _post_metric(self, metric: Metric): + self.monitoring_client.post_metric_data( + post_metric_data_details=oci.monitoring.models.PostMetricDataDetails( + metric_data=[ + oci.monitoring.models.MetricDataDetails( + namespace=metric.namespace, + compartment_id=self.metric_compartment, + name=metric.name, + dimensions={metric.dimension_name: metric.dimension_value}, + datapoints=[ + oci.monitoring.models.Datapoint( + timestamp=datetime.strptime( + metric.time.replace(" ", "T") + "Z", + "%Y-%m-%dT%H:%M:%S.%fZ", + ), + value=metric.value, + count=1, + ) + ], + resource_group="agent-dev-pocs", + metadata={"agent-name": metric.agent_name}, + ) + ], + batch_atomicity="ATOMIC", + ), + ) + + def start(self): + logger.info(f"Starting logging for session_id: {self.session_id}") + return self.session_id + + def log_new_agent( + self, agent: ConversableAgent, init_args: Dict[str, Any] = {} + ) -> None: + """ + Log a new agent instance. + """ + logger.info(f"Event: {agent} {init_args}") + + def log_function_use( + self, + source: Union[str, Agent], + function: Any, + args: Dict[str, Any], + returns: Any, + ) -> None: + """ + Log a registered function(can be a tool) use from an agent or a string source. + """ + try: + log_data = { + "source_id": id(source), + "source_name": str(source.name) if hasattr(source, "name") else source, + "agent_module": source.__module__, + "agent_class": source.__class__.__name__, + "timestamp": get_current_ts(), + "input_args": safe_serialize(args), + "returns": safe_serialize(returns), + } + metric = Metric( + name="tool-call", + value=1, + dimension_value=function.__name__, + dimension_name="tool-call", + agent_name=self.agent_name, + namespace=self.metric_namespace, + time=get_current_ts(), + ) + self._post_metric(metric=metric) + logger.info(json.dumps(log_data)) + except Exception as e: + self.logger.error(f"[monitoring] Failed to log event {e}") + + def log_chat_completion( + self, + invocation_id: UUID, + client_id: int, + wrapper_id: int, + source: Union[str, Agent], + request: Dict[str, Union[float, str, List[Dict[str, str]]]], + response: Union[str, Any], + is_cached: int, + cost: float, + start_time: str, + ) -> None: + """ + Log a chat completion. + """ + input = to_dict(request) + output = str(response) + if not isinstance(response, str): + total_tokens = response.usage.total_tokens + input_tokens = response.usage.prompt_tokens + output_tokens = response.usage.completion_tokens + model = response.model + metric = { + "total_tokens": total_tokens, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "model": model, + "cost": cost, + } + self._post_metric( + metric=Metric( + name="token", + value=total_tokens, + agent_name=self.agent_name, + namespace=self.metric_namespace, + time=start_time, + dimension_name="agent", + dimension_value=self.agent_name, + ) + ) + logger.info(f"Metric: {metric}") + logger.info(f"Input: {input}") + logger.info(f"output: {output}") + + def log_new_wrapper( + self, + wrapper: OpenAIWrapper, + init_args: Dict[str, Union[LLMConfig, List[LLMConfig]]] = {}, + ) -> None: + """ + Log a new wrapper instance. + """ + thread_id = threading.get_ident() + + try: + log_data = json.dumps( + { + "wrapper_id": id(wrapper), + "session_id": self.session_id, + "json_state": json.dumps(init_args), + "timestamp": get_current_ts(), + "thread_id": thread_id, + } + ) + logger.info(log_data) + except Exception as e: + logger.error(f"[file_logger] Failed to log event {e}") + + def log_new_client(self, client, wrapper, init_args): + logger.info(f"Event: {client} / {wrapper} / {init_args}") + + def log_event(self, source, name, **kwargs): + logger.info(f"Event: {source} / {name}") + + def get_connection(self): + pass + + def stop(self): + logger.info("Event: Stopping...") From d3ef8e7babe8698030d3544fe6c76562ab4fed5e Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 20 Dec 2024 12:59:47 -0500 Subject: [PATCH 52/58] Move functions to utils.py --- ads/llm/autogen/v02/loggers/session_logger.py | 88 +++---------------- ads/llm/autogen/v02/loggers/utils.py | 86 ++++++++++++++++++ 2 files changed, 96 insertions(+), 78 deletions(-) create mode 100644 ads/llm/autogen/v02/loggers/utils.py diff --git a/ads/llm/autogen/v02/loggers/session_logger.py b/ads/llm/autogen/v02/loggers/session_logger.py index 596e14fef..a42cdc1dd 100644 --- a/ads/llm/autogen/v02/loggers/session_logger.py +++ b/ads/llm/autogen/v02/loggers/session_logger.py @@ -1,8 +1,6 @@ # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ import importlib -import inspect -import json import logging import os import tempfile @@ -11,8 +9,7 @@ import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone -from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse import autogen @@ -44,6 +41,10 @@ from ads.llm.autogen.reports.session import SessionReport from ads.llm.autogen.v02 import runtime_logging from ads.llm.autogen.v02.log_handlers.oci_file_handler import OCIFileHandler +from ads.llm.autogen.v02.loggers.utils import ( + serialize, + serialize_response, +) logger = logging.getLogger(__name__) @@ -51,80 +52,6 @@ CONST_REPLY_FUNC_NAME = "reply_func_name" -def is_json_serializable(obj: Any) -> bool: - """Checks if an object is JSON serializable. - - Parameters - ---------- - obj : Any - Any object. - - Returns - ------- - bool - True if the object is JSON serializable, otherwise False. - """ - try: - json.dumps(obj) - except Exception: - return False - return True - - -def serialize_response(response) -> dict: - """Serializes the LLM response to dictionary.""" - if isinstance(response, SimpleNamespace) or is_json_serializable(response): - # Convert simpleNamespace to dict - return json.loads(json.dumps(response, default=vars)) - elif hasattr(response, "dict") and callable(response.dict): - return json.loads(json.dumps(response.dict(), default=str)) - data = { - "model": response.model, - "choices": [ - {"message": {"content": choice.message.content}} - for choice in response.choices - ], - "response": str(response), - } - return data - - -def serialize( - obj: Union[int, float, str, bool, Dict[Any, Any], List[Any], Tuple[Any, ...], Any], - exclude: Tuple[str, ...] = ("api_key", "__class__"), - no_recursive: Tuple[Any, ...] = (), -) -> Any: - """Serializes an object for logging purpose.""" - try: - if isinstance(obj, (int, float, str, bool)): - return obj - elif callable(obj): - return inspect.getsource(obj).strip() - elif isinstance(obj, dict): - return { - str(k): ( - serialize(str(v)) - if isinstance(v, no_recursive) - else serialize(v, exclude, no_recursive) - ) - for k, v in obj.items() - if k not in exclude - } - elif isinstance(obj, (list, tuple)): - return [ - ( - serialize(str(v)) - if isinstance(v, no_recursive) - else serialize(v, exclude, no_recursive) - ) - for v in obj - ] - else: - return str(obj) - except Exception: - return str(obj) - - @dataclass class LoggingSession: """Represents a logging session for a specific thread.""" @@ -311,6 +238,11 @@ def log_file(self) -> Optional[str]: """Log file path for the current session.""" return self.session.log_file + @property + def report(self) -> Optional[str]: + """Report path/link for the session.""" + return self.session.report + @property def name(self) -> str: """Name of the logger.""" diff --git a/ads/llm/autogen/v02/loggers/utils.py b/ads/llm/autogen/v02/loggers/utils.py new file mode 100644 index 000000000..247e11e4b --- /dev/null +++ b/ads/llm/autogen/v02/loggers/utils.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +import inspect +import json +from types import SimpleNamespace +from typing import Any, Dict, List, Tuple, Union + + +def is_json_serializable(obj: Any) -> bool: + """Checks if an object is JSON serializable. + + Parameters + ---------- + obj : Any + Any object. + + Returns + ------- + bool + True if the object is JSON serializable, otherwise False. + """ + try: + json.dumps(obj) + except Exception: + return False + return True + + +def serialize_response(response) -> dict: + """Serializes the LLM response to dictionary.""" + if isinstance(response, SimpleNamespace) or is_json_serializable(response): + # Convert simpleNamespace to dict + return json.loads(json.dumps(response, default=vars)) + elif hasattr(response, "dict") and callable(response.dict): + return json.loads(json.dumps(response.dict(), default=str)) + elif hasattr(response, "model") and hasattr(response, "choices"): + return { + "model": response.model, + "choices": [ + {"message": {"content": choice.message.content}} + for choice in response.choices + ], + "response": str(response), + } + return { + "model": "", + "choices": [{"message": {"content": response}}], + "response": str(response), + } + + +def serialize( + obj: Union[int, float, str, bool, Dict[Any, Any], List[Any], Tuple[Any, ...], Any], + exclude: Tuple[str, ...] = ("api_key", "__class__"), + no_recursive: Tuple[Any, ...] = (), +) -> Any: + """Serializes an object for logging purpose.""" + try: + if isinstance(obj, (int, float, str, bool)): + return obj + elif callable(obj): + return inspect.getsource(obj).strip() + elif isinstance(obj, dict): + return { + str(k): ( + serialize(str(v)) + if isinstance(v, no_recursive) + else serialize(v, exclude, no_recursive) + ) + for k, v in obj.items() + if k not in exclude + } + elif isinstance(obj, (list, tuple)): + return [ + ( + serialize(str(v)) + if isinstance(v, no_recursive) + else serialize(v, exclude, no_recursive) + ) + for v in obj + ] + else: + return str(obj) + except Exception: + return str(obj) From 085249c7679d3d3abf10e190cb415e2e18db1e0c Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 20 Dec 2024 12:59:58 -0500 Subject: [PATCH 53/58] Update metric logger. --- ads/llm/autogen/v02/loggers/__init__.py | 2 + ads/llm/autogen/v02/loggers/metric_logger.py | 283 ++++++++++++------- 2 files changed, 184 insertions(+), 101 deletions(-) diff --git a/ads/llm/autogen/v02/loggers/__init__.py b/ads/llm/autogen/v02/loggers/__init__.py index b95587622..15635dc09 100644 --- a/ads/llm/autogen/v02/loggers/__init__.py +++ b/ads/llm/autogen/v02/loggers/__init__.py @@ -1,4 +1,6 @@ +#!/usr/bin/env python # Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +from ads.llm.autogen.v02.loggers.metric_logger import MetricLogger from ads.llm.autogen.v02.loggers.session_logger import SessionLogger diff --git a/ads/llm/autogen/v02/loggers/metric_logger.py b/ads/llm/autogen/v02/loggers/metric_logger.py index 17c699029..7875da519 100644 --- a/ads/llm/autogen/v02/loggers/metric_logger.py +++ b/ads/llm/autogen/v02/loggers/metric_logger.py @@ -1,9 +1,7 @@ #!/usr/bin/env python -# Copyright (c) 2023, 2024 Oracle and/or its affiliates. +# Copyright (c) 2024 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ -import json import logging -import threading from datetime import datetime from typing import Any, Dict, List, Union from uuid import UUID, uuid4 @@ -11,53 +9,107 @@ import oci from autogen import Agent, ConversableAgent, OpenAIWrapper from autogen.logger.base_logger import BaseLogger, LLMConfig -from autogen.logger.file_logger import safe_serialize -from autogen.logger.logger_utils import get_current_ts, to_dict +from autogen.logger.logger_utils import get_current_ts from oci.monitoring import MonitoringClient -from pydantic import BaseModel +from pydantic import BaseModel, Field import ads -import ads.common -import ads.common.oci_client +import ads.config +from ads.llm.autogen.v02.loggers.utils import serialize_response logger = logging.getLogger(__name__) +class MetricName: + """Constants for metric name.""" + + TOOL_CALL = "tool_call" + CHAT_COMPLETION = "chat_completion_count" + COST = "chat_completion_cost" + SESSION_START = "session_start" + SESSION_STOP = "session_stop" + + +class MetricDimension: + """Constants for metric dimension.""" + + AGENT_NAME = "agent_name" + APP_NAME = "app_name" + MODEL = "model" + SESSION_ID = "session_id" + TOOL_NAME = "tool_name" + + class Metric(BaseModel): """Represents the metric to be logged.""" name: str value: float - agent_name: str - namespace: str - time: str - dimension_name: str - dimension_value: str + timestamp: str + dimensions: dict = Field(default_factory=dict) -class AgentMetricMonitoring(BaseLogger): +class MetricLogger(BaseLogger): """AutoGen logger for agent metrics.""" def __init__( self, - session_id=None, - metric_compartment=None, - agent_name=None, - metric_namespace=None, - region=None, + app_name: str, + namespace: str, + compartment_id: str = None, + session_id: str = None, + region: str = None, + resource_group: str = None, ): + """Initialize the metric logger. + + Parameters + ---------- + app_name : str + Application name, which will be a metric dimension. + namespace : str + Namespace for posting the metric + compartment_id : str, optional + Compartment OCID for posting the metric. + If compartment_id is not specified, + ADS will try to fetch the compartment OCID from environment variable. + session_id : str, optional + Session ID to be saved as a metric dimension, by default None. + If session_id is None, a UUID will be generated automatically. + region : str, optional + OCI region for posting the metric, by default None. + If region is not specified, the region from the authentication signer will be used. + resource_group : str, optional + Resource group for the metric, by default None + + """ + self.app_name = app_name self.session_id = str(session_id or uuid4()) - self.metric_compartment = metric_compartment + self.compartment_id = compartment_id or ads.config.COMPARTMENT_OCID + if not self.compartment_id: + raise ValueError( + "Unable to determine compartment OCID for metric logger." + "Please specify the compartment_id." + ) + self.namespace = namespace + self.resource_group = resource_group + + # Indicate if the logger has started. + self.started = False + auth = ads.auth.default_signer() + + # Use the signer to determine the region if it not specified. signer = auth.get("signer") if not region: - if not (hasattr(signer, "region") and signer.region): + if hasattr(signer, "region") and signer.region: + region = signer.region + else: raise ValueError( "Unable to determine the region for OCI monitoring service. " "Please specify the region using the `region` argument." ) - else: - region = signer.region + self.monitoring_client = MonitoringClient( config=auth.get("config", {}), signer=signer, @@ -65,30 +117,37 @@ def __init__( # See note here: https://docs.oracle.com/iaas/api/#/en/monitoring/20180401/MetricData/PostMetricData service_endpoint=f"https://telemetry-ingestion.{region}.oraclecloud.com", ) - self.agent_name = agent_name - self.metric_namespace = metric_namespace def _post_metric(self, metric: Metric): + """Posts metric to OCI monitoring.""" + # Add app_name and session_id to dimensions + dimensions = metric.dimensions + dimensions.update( + { + MetricDimension.SESSION_ID: self.session_id, + MetricDimension.APP_NAME: self.app_name, + } + ) + logger.debug("Posting metrics:\n%s", str(metric)) self.monitoring_client.post_metric_data( post_metric_data_details=oci.monitoring.models.PostMetricDataDetails( metric_data=[ oci.monitoring.models.MetricDataDetails( - namespace=metric.namespace, - compartment_id=self.metric_compartment, + namespace=self.namespace, + compartment_id=self.compartment_id, name=metric.name, - dimensions={metric.dimension_name: metric.dimension_value}, + dimensions=dimensions, datapoints=[ oci.monitoring.models.Datapoint( timestamp=datetime.strptime( - metric.time.replace(" ", "T") + "Z", + metric.timestamp.replace(" ", "T") + "Z", "%Y-%m-%dT%H:%M:%S.%fZ", ), value=metric.value, count=1, ) ], - resource_group="agent-dev-pocs", - metadata={"agent-name": metric.agent_name}, + resource_group=self.resource_group, ) ], batch_atomicity="ATOMIC", @@ -96,16 +155,25 @@ def _post_metric(self, metric: Metric): ) def start(self): - logger.info(f"Starting logging for session_id: {self.session_id}") + """Starts the logger.""" + logger.info(f"Starting metric logging for session_id: {self.session_id}") + self.started = True + try: + metric = Metric( + name=MetricName.SESSION_START, + value=1, + timestamp=get_current_ts(), + ) + self._post_metric(metric=metric) + except Exception as e: + logger.error(f"MetricLogger Failed to log session start: {str(e)}") return self.session_id def log_new_agent( self, agent: ConversableAgent, init_args: Dict[str, Any] = {} ) -> None: - """ - Log a new agent instance. - """ - logger.info(f"Event: {agent} {init_args}") + """Metric logger does not log new agent.""" + pass def log_function_use( self, @@ -117,29 +185,24 @@ def log_function_use( """ Log a registered function(can be a tool) use from an agent or a string source. """ + if not self.started: + return + agent_name = str(source.name) if hasattr(source, "name") else source + dimensions = { + MetricDimension.TOOL_NAME: function.__name__, + MetricDimension.AGENT_NAME: agent_name, + } try: - log_data = { - "source_id": id(source), - "source_name": str(source.name) if hasattr(source, "name") else source, - "agent_module": source.__module__, - "agent_class": source.__class__.__name__, - "timestamp": get_current_ts(), - "input_args": safe_serialize(args), - "returns": safe_serialize(returns), - } - metric = Metric( - name="tool-call", - value=1, - dimension_value=function.__name__, - dimension_name="tool-call", - agent_name=self.agent_name, - namespace=self.metric_namespace, - time=get_current_ts(), + self._post_metric( + Metric( + name=MetricName.TOOL_CALL, + value=1, + timestamp=get_current_ts(), + dimensions=dimensions, + ) ) - self._post_metric(metric=metric) - logger.info(json.dumps(log_data)) except Exception as e: - self.logger.error(f"[monitoring] Failed to log event {e}") + logger.error(f"MetricLogger Failed to log tool call: {str(e)}") def log_chat_completion( self, @@ -156,67 +219,85 @@ def log_chat_completion( """ Log a chat completion. """ - input = to_dict(request) - output = str(response) - if not isinstance(response, str): - total_tokens = response.usage.total_tokens - input_tokens = response.usage.prompt_tokens - output_tokens = response.usage.completion_tokens - model = response.model - metric = { - "total_tokens": total_tokens, - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "model": model, - "cost": cost, + if not self.started: + return + + try: + response: dict = serialize_response(response) + if "usage" not in response or not isinstance(response["usage"], dict): + return + # Post usage metric + agent_name = str(source.name) if hasattr(source, "name") else source + model = response.get("model", "N/A") + dimensions = { + MetricDimension.AGENT_NAME: agent_name, + MetricDimension.MODEL: model, } + + # Chat completion count self._post_metric( - metric=Metric( - name="token", - value=total_tokens, - agent_name=self.agent_name, - namespace=self.metric_namespace, - time=start_time, - dimension_name="agent", - dimension_value=self.agent_name, + Metric( + name=MetricName.CHAT_COMPLETION, + value=1, + timestamp=get_current_ts(), + dimensions=dimensions, ) ) - logger.info(f"Metric: {metric}") - logger.info(f"Input: {input}") - logger.info(f"output: {output}") + # Cost + if cost: + self._post_metric( + Metric( + name=MetricName.COST, + value=cost, + timestamp=get_current_ts(), + dimensions=dimensions, + ) + ) + # Usage + for key, val in response["usage"].items(): + self._post_metric( + Metric( + name=key, + value=val, + timestamp=get_current_ts(), + dimensions=dimensions, + ) + ) + + except Exception as e: + logger.error(f"MetricLogger Failed to log chat completion: {str(e)}") def log_new_wrapper( self, wrapper: OpenAIWrapper, init_args: Dict[str, Union[LLMConfig, List[LLMConfig]]] = {}, ) -> None: - """ - Log a new wrapper instance. - """ - thread_id = threading.get_ident() - - try: - log_data = json.dumps( - { - "wrapper_id": id(wrapper), - "session_id": self.session_id, - "json_state": json.dumps(init_args), - "timestamp": get_current_ts(), - "thread_id": thread_id, - } - ) - logger.info(log_data) - except Exception as e: - logger.error(f"[file_logger] Failed to log event {e}") + """Metric logger does not log new wrapper.""" + pass def log_new_client(self, client, wrapper, init_args): - logger.info(f"Event: {client} / {wrapper} / {init_args}") + """Metric logger does not log new client.""" + pass def log_event(self, source, name, **kwargs): - logger.info(f"Event: {source} / {name}") + """Metric logger does not log events.""" + pass def get_connection(self): pass def stop(self): - logger.info("Event: Stopping...") + """Stops the metric logger.""" + if not self.started: + return + self.started = False + try: + metric = Metric( + name=MetricName.SESSION_STOP, + value=1, + timestamp=get_current_ts(), + ) + self._post_metric(metric=metric) + except Exception as e: + logger.error(f"MetricLogger Failed to log session stop: {str(e)}") + logger.info("Metric logger stopped.") From 6ddf9bf97c15930a14113da3ceb5ed272bd521c3 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 20 Dec 2024 13:52:25 -0500 Subject: [PATCH 54/58] Fix error in session logger. --- ads/llm/autogen/v02/loggers/session_logger.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ads/llm/autogen/v02/loggers/session_logger.py b/ads/llm/autogen/v02/loggers/session_logger.py index a42cdc1dd..9ed21982f 100644 --- a/ads/llm/autogen/v02/loggers/session_logger.py +++ b/ads/llm/autogen/v02/loggers/session_logger.py @@ -568,12 +568,13 @@ def __enter__(self) -> "SessionLogger": def __exit__(self, exc_type, exc_value, tb): """Stops the session logger.""" - record = self.new_record(event_name=Events.EXCEPTION) - record.kwargs = { - "exc_type": exc_type.__name__, - "exc_value": str(exc_value), - "traceback": "".join(traceback.format_tb(tb)), - "locals": serialize(tb.tb_frame.f_locals), - } - self.log(record) + if exc_type: + record = self.new_record(event_name=Events.EXCEPTION) + record.kwargs = { + "exc_type": exc_type.__name__, + "exc_value": str(exc_value), + "traceback": "".join(traceback.format_tb(tb)), + "locals": serialize(tb.tb_frame.f_locals), + } + self.log(record) runtime_logging.stop(self) From c63e6cf3b78cb5896373e0acfc4c128d42aa313a Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 20 Dec 2024 13:54:26 -0500 Subject: [PATCH 55/58] Make dimensions optional in metric logger. --- ads/llm/autogen/v02/loggers/metric_logger.py | 75 ++++++++++++-------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/ads/llm/autogen/v02/loggers/metric_logger.py b/ads/llm/autogen/v02/loggers/metric_logger.py index 7875da519..efc5a6274 100644 --- a/ads/llm/autogen/v02/loggers/metric_logger.py +++ b/ads/llm/autogen/v02/loggers/metric_logger.py @@ -3,8 +3,8 @@ # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ import logging from datetime import datetime -from typing import Any, Dict, List, Union -from uuid import UUID, uuid4 +from typing import Any, Dict, List, Optional, Union +from uuid import UUID import oci from autogen import Agent, ConversableAgent, OpenAIWrapper @@ -54,37 +54,45 @@ class MetricLogger(BaseLogger): def __init__( self, - app_name: str, namespace: str, - compartment_id: str = None, - session_id: str = None, - region: str = None, - resource_group: str = None, + app_name: Optional[str] = None, + compartment_id: Optional[str] = None, + session_id: Optional[str] = None, + region: Optional[str] = None, + resource_group: Optional[str] = None, + log_agent_name: bool = True, + log_tool_name: bool = True, + log_model_name: bool = True, ): """Initialize the metric logger. Parameters ---------- - app_name : str - Application name, which will be a metric dimension. namespace : str Namespace for posting the metric + app_name : str + Application name, which will be a metric dimension if specified. compartment_id : str, optional Compartment OCID for posting the metric. If compartment_id is not specified, ADS will try to fetch the compartment OCID from environment variable. session_id : str, optional Session ID to be saved as a metric dimension, by default None. - If session_id is None, a UUID will be generated automatically. region : str, optional OCI region for posting the metric, by default None. If region is not specified, the region from the authentication signer will be used. resource_group : str, optional Resource group for the metric, by default None + log_agent_name : bool, optional + Whether to log agent name as a metric dimension, by default True. + log_tool_name : bool, optional + Whether to log tool name as a metric dimension, by default True. + log_model_name : bool, optional + Whether to log model name as a metric dimension, by default True. """ self.app_name = app_name - self.session_id = str(session_id or uuid4()) + self.session_id = session_id self.compartment_id = compartment_id or ads.config.COMPARTMENT_OCID if not self.compartment_id: raise ValueError( @@ -93,17 +101,22 @@ def __init__( ) self.namespace = namespace self.resource_group = resource_group - + self.log_agent_name = log_agent_name + self.log_tool_name = log_tool_name + self.log_model_name = log_model_name # Indicate if the logger has started. self.started = False auth = ads.auth.default_signer() - # Use the signer to determine the region if it not specified. + # Use the config/signer to determine the region if it not specified. signer = auth.get("signer") + config = auth.get("config", {}) if not region: if hasattr(signer, "region") and signer.region: region = signer.region + elif config.get("region"): + region = config.get("region") else: raise ValueError( "Unable to determine the region for OCI monitoring service. " @@ -111,7 +124,7 @@ def __init__( ) self.monitoring_client = MonitoringClient( - config=auth.get("config", {}), + config=config, signer=signer, # Metrics should be submitted with the "telemetry-ingestion" endpoint instead. # See note here: https://docs.oracle.com/iaas/api/#/en/monitoring/20180401/MetricData/PostMetricData @@ -122,12 +135,11 @@ def _post_metric(self, metric: Metric): """Posts metric to OCI monitoring.""" # Add app_name and session_id to dimensions dimensions = metric.dimensions - dimensions.update( - { - MetricDimension.SESSION_ID: self.session_id, - MetricDimension.APP_NAME: self.app_name, - } - ) + if self.app_name: + dimensions[MetricDimension.APP_NAME] = self.app_name + if self.session_id: + dimensions[MetricDimension.SESSION_ID] = self.session_id + logger.debug("Posting metrics:\n%s", str(metric)) self.monitoring_client.post_metric_data( post_metric_data_details=oci.monitoring.models.PostMetricDataDetails( @@ -156,7 +168,10 @@ def _post_metric(self, metric: Metric): def start(self): """Starts the logger.""" - logger.info(f"Starting metric logging for session_id: {self.session_id}") + if self.session_id: + logger.info(f"Starting metric logging for session_id: {self.session_id}") + else: + logger.info("Starting metric logging.") self.started = True try: metric = Metric( @@ -188,10 +203,11 @@ def log_function_use( if not self.started: return agent_name = str(source.name) if hasattr(source, "name") else source - dimensions = { - MetricDimension.TOOL_NAME: function.__name__, - MetricDimension.AGENT_NAME: agent_name, - } + dimensions = {} + if self.log_tool_name: + dimensions[MetricDimension.TOOL_NAME] = function.__name__ + if self.log_agent_name: + dimensions[MetricDimension.AGENT_NAME] = agent_name try: self._post_metric( Metric( @@ -229,10 +245,11 @@ def log_chat_completion( # Post usage metric agent_name = str(source.name) if hasattr(source, "name") else source model = response.get("model", "N/A") - dimensions = { - MetricDimension.AGENT_NAME: agent_name, - MetricDimension.MODEL: model, - } + dimensions = {} + if self.log_model_name: + dimensions[MetricDimension.MODEL] = model + if self.log_agent_name: + dimensions[MetricDimension.AGENT_NAME] = agent_name # Chat completion count self._post_metric( From 389193d072dff5df454939550034fe444c96d62b Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 20 Dec 2024 14:23:56 -0500 Subject: [PATCH 56/58] Update default settings for metric logger. --- ads/llm/autogen/v02/loggers/metric_logger.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ads/llm/autogen/v02/loggers/metric_logger.py b/ads/llm/autogen/v02/loggers/metric_logger.py index efc5a6274..886089568 100644 --- a/ads/llm/autogen/v02/loggers/metric_logger.py +++ b/ads/llm/autogen/v02/loggers/metric_logger.py @@ -60,9 +60,9 @@ def __init__( session_id: Optional[str] = None, region: Optional[str] = None, resource_group: Optional[str] = None, - log_agent_name: bool = True, - log_tool_name: bool = True, - log_model_name: bool = True, + log_agent_name: bool = False, + log_tool_name: bool = False, + log_model_name: bool = False, ): """Initialize the metric logger. From d95e4ea7cf1150ef56b58732b65bd7e2f5de5989 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 20 Dec 2024 14:24:19 -0500 Subject: [PATCH 57/58] Update docs. --- .../autogen_integration.rst | 78 ++++++++++++++++++ .../figures/autogen_report.png | Bin 0 -> 292854 bytes 2 files changed, 78 insertions(+) create mode 100644 docs/source/user_guide/large_language_model/figures/autogen_report.png diff --git a/docs/source/user_guide/large_language_model/autogen_integration.rst b/docs/source/user_guide/large_language_model/autogen_integration.rst index e21a8bd3e..64d2a018c 100644 --- a/docs/source/user_guide/large_language_model/autogen_integration.rst +++ b/docs/source/user_guide/large_language_model/autogen_integration.rst @@ -104,3 +104,81 @@ Following is an example LLM config for the OCI Generative AI service: }, } +Logging And Reporting +===================== + +ADS offers enhanced utilities integrating with OCI to log data for debugging and analysis: +* The ``SessionLogger`` saves events to a log file and generates report to for you to profile and debug the application. +* The ``MetricLogger`` sends the metrics to OCI monitoring service, allowing you to build dashboards to gain more insights about the application usage. + +Session Logger and Report +------------------------- + +To use the session logger, you need to specify a local directory or an OCI object storage location for saving the log files. +A unique session ID will be generated for each session. Each session will be logged into one file. +Optionally, you can specify the ``report_dir`` to generate a report at the end of each session. +If you are using an object storage location as ``report_dir``, you can also have a pre-authenticated link generated automatically for viewing and sharing the report. + +.. code-block:: python3 + + from ads.llm.autogen.v02.loggers import SessionLogger + + session_logger = SessionLogger( + # log_dir can be local dir or OCI object storage location in the form of oci://bucket@namespace/prefix + log_dir="", + # Location for saving the report. Can be local path or object storage location. + report_dir="", + # Specify session ID if you would like to resume a previous session or use your own session ID. + session_id=session_id, + # Set report_par_uri to True when using object storage to auto-generate PAR link. + report_par_uri=True, + ) + + # You may get the auto-generated session id once the logger is initialized + print(session_logger.session_id) + + # It is recommended to run your application with the context manager. + with session_logger: + # Create and run your AutoGen application + ... + + # Access the log file path + print(session_logger.log_file) + + # Report file path or pre-authenticated link + print(session_logger.report) + +The session report provides a comprehensive overview of the timeline, invocations, chat interactions, and logs in HTML format. It effectively visualizes the application's flow, facilitating efficient debugging and analysis. + +.. figure:: figures/autogen_report.png + :width: 800 + +Metric Logger +------------- +The agent metric logger emits agent metrics to `OCI Monitoring `_, +allowing you to integrate AutoGen application with OCI monitoring service to `build queries `_ and `dashboards `_, as well as `managing alarms `_. + +.. code-block:: python3 + + from ads.llm.autogen.v02 import runtime_logging + from ads.llm.autogen.v02.loggers import MetricLogger + + monitoring_logger = AgentMonitoring( + # Metric namespace required by OCI monitoring. + namespace="", + # Optional application name, which will be a metric dimension if specified. + app_name="order_support", + # Compartment OCID for posting the metric + compartment_id="", + # Optional session ID to be saved as a metric dimension. + session_id="" + # Whether to log agent name as a metric dimension. + log_agent_name=False, + # Whether to log tool name as a metric dimension. + log_model_name=False, + # Whether to log model name as a metric dimension. + log_tool_name=False, + ) + # Start logging metrics + runtime_logging.start(monitoring_logger) + diff --git a/docs/source/user_guide/large_language_model/figures/autogen_report.png b/docs/source/user_guide/large_language_model/figures/autogen_report.png new file mode 100644 index 0000000000000000000000000000000000000000..6100eee0180b26cc16fa71ae197136eca118838f GIT binary patch literal 292854 zcmeFZXIK-_w=WEcQWWV$s&u4xkSblJg9)KS5a|Sj(2LSTRgvC75eU8a8bG?zI|2ei zfKa5jJN(Nz=e_TTd(QX6^Msj9X3u11_FilK$__$DOO1$-mJkaIi|EC3Wj!n`JTMj( zz8U^KU`yW5`T$^o?WU)uh*dgFzX`law1&KRqp69-4Xop1;bPNb-M!rfc*$YY{l~g0 zHW${Ne{9FW!V0&;!u{7iTEO!5?*s6To%srXwoCVlG;PTwi4GW8k{q}|ZLXT|^IM=k@ zD~LNpQ$xxM=EQIL8V0rI_i}Q%Jr0(PmlUw-WbJPG*vrY$*-gqzmgOINNCE4&n*~@N z|6><-2U!+~rp{v}*jwwz;{1aAf-G``j~_pldHec}l%BHcznTN@WLa$8-Cd*v1Ux-G z`8`GWVQ*~&gq}WqDj+Be?d)#p#pmqC`p<*>`#8$hZdPyYT-@zo&W~@8 zYYBzH-DO!=Zcp?dfB*cR)?Rl1c_wGKe_a-EfdaRm2ng{D3jD{hfu=IITcvdDysRA! zmF=8>dj_0CPFP4(SmqxM{(m3+=PCb7Q^e_a1u0_!;7>t(fY9W1P8STB?nUU^|}XW-R~swA8Y{6#>YJ^RGfs7yfkJTd9Z z45WwW$&uWXBuvz3GNt!mgiW(#~8od5nJ0l&Ei7nv|UO{COLB8d9{`@g%; z!__FaztizQYV+XzJ)qSr)`tc0|G}AVzY{J}{N8{6yZvKN+h;&4BCHvw|Lzt2>-fNB zS^dv$#s6N`|8>9q_i6omSp07%{Qm@$e~&Fys`qVIJFwl8wj2L*@7;!td7iT7l{cw& z`F}+Y2Sudi#!NqRnr%0#2Hi8sx9{<94+J&-Kr}cO1;oi`U0>O?53h zPMasvi~A2Hdgo_@#yj(m2M>AQ>2`+CxX@pqncs)HLlR;bv|(6Z|f zph$Eczx&jFdtW^#4Q(32nF1jDul@CKOR~<#zm1Og9S#{}P4Cj;FtEH^JMvw+Far_9 z$zOZfe=PC&PDTeL4#M|smumvM$8#`G`KwLsG5?7uf!1W7=U$s%ljs*o7cbW(zA?$L z{bG1OvK-E|`zFi(xZ-W5-@%Kz-=EZDuVCl-77U_Elx$2D%MJ zre5E$p6WnTlsS3UD}P;~gy1*#l-c`*2=UoK$9B%ILM$@8cI2J%sRca^s@%0@QLi%?beIk-DIm+0-s4nVcdJ6?KM+o=;L`e?Qn}4o>b??O+D=K(x%;1 z;ig9J-UBgQVO$GUky3$%-(*Q_FFYFJF(9xZd&UK|sryf@gE|eO_fohdi;t6kl4x8# zdXubh81YpMS4!#E?Sn4D8Z*RZd~_&h;n*!tO33ugY(alb zGE3#AIQ0VeZHTQ#i%1=R1$w{fc}AN}8_|19wO>5uXD_*b?-z15*NII+x;X6w>==Fi zFyobVZOp&GXJ6;eL*Q$8&<7sGXVoVdn0Z6HcuFsO124msn8n2xTzeUIL&&wBpR~^U z6ZXr{IaPn0v-_4oPv}%Z5ZV|Qc)4np*$qv~7=dkMc<%ZWrwA+s03+rZ0Z!$2TB4^o zbg*V2`qS(Y$Eu-3{5#g)jcq4HaIa(D*}8CcIug>Nb1)HtCS)_&Y%KWY&Vsm73eSp9 z$ew=Yg!!$~`7+I=In^2-42q1nkMa+UlN2caYP&|aja!-GB0)1VaHVWdT=8j(J4ZAR z7s6l3a%$vG?p$MTt})8EP=xJ1UY!~)a&>DTX@2!GAqgMcD=Qxvi0T*gzPVT`v-bqG zz4dQcOH@ZGesSZHyc2Mkwxkha$@q15J7vnmZAh8dOu9_^ZiGRca!8Ku*27&WW$Jd_ zQUC&C;9+)i8+H-+qGS~-(Rw#P|CS@|@Hl9{L+oyLQjArhtU0Y#n<5{H>A;ge?f4xn zGWS@zM9WP4?!4abOQb}@_lF0lGxX%S!2+ftSN1oeN@3t$!d1oO6>3(bErQto8K1+ZBbzt(QkMZBMDUn=q}Lt@Jz}r$d?+ zqWBxX^R@gPt*Fu}F_avk_xkm|Fl}f0Zp5cd@*aD81$t==uRL#PED)?c_d4*TbwBs> zt6lYo)e4UF8g{AiwF`^j8nNtsXN{9IK}1;$Npq4G=OfA zxR-KoSO>mbIR5#nZ;|({h%AK6$oXEO4h= z6YlV~2rm>l!xw>HAaIF$xRw_B*WNk)!B~c~x zi$y6GHFK>EN0Y|nh^8^0=(?Ho&$3Nt>uH>1>d3hsUXqhZ^hAVa9kIb6*g-t52v9Zat_GJW||d6br}JK(vHl5_0-bL0=t5$H_ z?7E-%0wG)jk~c@P8`@Ny2V3NTIE_Ue7c>7L!r_4I+?(QR=={EyVUafaf@F^io@$?x zY*Ew1*tT6c@JXWWZ0Ki?*O+v6i-7YB-fL~o1^Gg^>u!O7pJ+=3>Dmz~bn%OrHZkWt z{A3IGp1*|{zS=XB4WjNvwpZmoJ2g|PB{#3gix*wGi0;f}K^f*aIFjoJHCH3;PH=@r zyHK2r_H;NW5-ppWy1k{axd^5Cu-oF19m05#NG-wuhNG|?)ggrB5&~})M7?(^2l$=R zEJo;XbV($EJQ%bTtZ*NUKx)xj4K*cw73G>QmjEh`wmp8HpQYShu z2J?dmJmv-@wigmukkWW6Y!kH#R~W5vaP!S^U7PI3um^(-XNt~wL zMzqf0a!$|$ao}l}E?PbT7a4dymi1}oO&T9}Kr7MCXAgM*gJ>KLkK!eT-XOOJnkMnX1SRIx!#k>pfoeMDoF=W;tyj{&Sb}oCwqP zi(Nk;F~t!Ftx4LE;=>u%q#3Qg8rywM7+g5|!Vnq^OC|v0E#ZNKU2wsM)(Hg{m>=HK z%H=*w{wFGB7q7^Qp$#;%<`tQ&u}O*iKow^OEfTLGg$Wp~4AL1t$}Y^bK5F2*J0t z3m7iZvDKzF@(NW!+`*m606nZ5m^{dLNHX&&^hvD;3V)*(V@>$zyhKM)H;kkVz7!`x6T50w;?fuaX-GQzVLRdIN5bz=*v+g z1_6=w@4Sutg<;)R5mM^ud_EIme?2qs+WX{U=|=AAdk$NMRcXUIE$4ngP=6mISyH=J zab;B-P|YeUJu6P6w=z&FCKeeK3|=QycffZKdIuS9pf>Z$iEkE5iL?;QxDgwn_y7A- zD}*goZ60lQB2_m5Hf{iF^&W)um^;zMVb;xdWt{D)w5z^!BB#Sb^Wm`caoZ-IZ&lmP z6-Kk~UVu4Glvc>row9H*cUQ8FDZa)(xn^*0AirnS2g8A4im7GPGvm*+RUCX}a-u1@ z<#=|0!4*ZixY?xf{j}|3x2a`{W!Nu$O6kO3Xs*+jqry1@WeT$Pz1XR10xF3`0H@vP z$xeY*fU0nI!DG&8SFFri(?A8Nw4Xh}k>0J(BhOLUlwFEqfxyW&W+l9<-2tRiZ|t|< zoxvr3Z=-KUA1aO~FaL-!y-4!bQSfI%xgsUzpS@W8EXgs<$bp%q&f-yu@R`2hTL zTLE?Gme!Yel8JX9D#GIT)U7uC0wY|eD&j&t|ETBI|Mr-+#9R8Ek#7|{8*s5B&DSL? z0kB-`IQZb0Fyi+_;?s{C!4GH^>v@C^)PRI{RdMj*k%I~ODprz>fw@0Ch+Ta5sj$y} zH#NB>8JkU(zeg%Z*qe06^}$AgJJYQ^z&Kgr$0-Q$Vx^__(Uv`W6*N#SzAqayZ!N-7f*X6?jH`^=Cg2~6GcT5MwDIl$AYub_!b@u9czw1(%pE0clL4O;Efui(AiHPe zP?FG-N||HplKGn_a>$T!XO860UF^l~ZCU$6S^OxFuE|Tezd6J;r8lqYAQ}CV9!?eg znTi)?Lr*u-`@A2jgmqY68Y+~AYD@1__xlYvN94pbUe6w%=SxIQR5DgNgtc3c z3PyoaI>KVu3Owu!{{p~()n@_)`fLo0)VvL8LlM!a2`|};1x!F)&Ao4e%z92jyo4yz z&jxEDhU?0<3HDu9XVfo~Ox_~${D55l09#aO4J*5rzf)100(YG1o*_f|fw5&MWjYtl z>!jv3?{KzBdm|VooHyqm+-^J#b~Co$wiVN%J#*p{mgvKmbf2DwWNMjwDPl~ZS=t3q z`EkI#GF}$Qp_X=MpKXQD`R2E#A&DiE&^w3)M82%8=s!TB%(<d5SD?ifp$7Ez~p3DS!5&tolqjcYNca%wGHW0AXUKN~itq4<%wa z!0tqoFFWmftOk-EH>xpue-X-H7sQ1=es`CCGSkb>Rz0{J(yi3K-^;XAyY=M8mD{*j z%bLEa8WL*oUIKsUB6K4NUD`CfJcLB-0tKA=(Ae)doABE>@dRCQkz4)QM~|jSXFpi> zf;pUC&sAdBx;YOqYL-68OASyr5y9;iYmH!^M>v&2TNldxC5U=HP@S>I{qO~{lm^LG+8-UBDinyFMr|5>%{RbB4{eU zWO}csj4M9#xVt4q4+M_}@U-?nNTPgk92Ty_R5iri@2${j+w~Ni-YJy&IWDBc%%KF&$DuRZG7)(!kkRC9@q42EAgUk2`e@)`hDge&F>8=oi9#b zXXT&$QIjv~xyJ+2E#2c*qFF@Xf0(fTp^b_X7shQ=#p3B^Pcjj;r3j^;kbX>Px>&|xwqDd@G6Q!#L+>21AaO4~S2k~zBIa(k_h(_v`t4E0 z13XP?gK$>h+LL|ovt#T9`V*ir8qA93XboU3z zU=PcDnB8KEpV2)0(IXsd{9lO@@?gIJfV3F4HoR*Z{F8`v?AyE_Ey9@WRi^CgTN
=d-}in8UP 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
c0E3Y75C@y4%cb!D{=q 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�Y4cH`iyRsj2d=Dl|A@a@kj`gXH2+rlr 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%0D+yt@KSo_z@6<(y%$CB{eB5e}zv2)KV0%Au z@Rr<+QPolZH!C0Ixy?`tI+x-1HaiEm*(^cQC`5uM9axu_57G~otSqGm0}h`kctb+Z z;JuVq1Nt#GK~&0B|=0p|uJk5<7 zwXyvmo&ZUo{??be16Gh1ZLi>>P2fSS!@F zKA^fO%-cGy@1_O)W$kr>>q7u5{i0t{dfA)NBk2Jm$gwUMm1#GSe|aF|`C72#x-#z$ z_MIR5xNQ0qsm&BAh->{gT)#Lzo(W?OPO}8gx)29DZm+3y55;_sR?M(wcuk$$Bzb9E zTaBJl)q%zrDuv_TtHhL37FrDPc^iusxsNz4A9H@E(f;{ayf=ZfKiPvj=hj!!!MOuU zrAeKe!PGbDFa@Er3x>0AJWO92M`d1T6zCeJoBYwE%Ad4Xy%j`a_8>u**T`=t(?fSZ z^TsKDyFMR|Pp4$Lc+%eTLANYEDVB5av>~5l1KN^U_z1Rxc3ZhLc15*gH=66Rn;h6nav$uNbfqw4LdYUG8(a?%}(-Su52AgxQ zO}Z?58dA7EGpoH*Qn|xr1zy+Pcty*kW5v0|$GNVZxP?}G>XuYU*Jn|?cHff3qcx)i zLh0awPvwD|1q_@$O-isWA{8XvHGl?khJHsYTPWl042}D)NZ@;1=7TxO!}jFWQTx~5 zW2m_zLajF0S%6aX;q-2d0-oj!<#|eO1U5xuHFh6hQ)hGLDnRh>fEwt0dJ_49{EJKf8tp4Aj@0-+5nhBPvTCdzz2R8nc@p-57o-+@M5s zp(wb}1GlItW+RM8%kd(bUfB8;14pK)WmSW`ZLP$;EM%IHHJUVNPA3-rvSU;}BDJkt zIEGZf;nb%)y$TmF{nY$Y(Mb>rkH#}cYUYcdzY3@ohZHR2$%a|^K>h4Jotb~ShQrsl zDGeA=khv_yQk?y8!U+%^??o4)a4b90H>kws|-+?Msbf@%Sh650X^=k2#& z@$&t6!AFV+JzEZa1(6%s^_>ZDS+i{J1+i=nS+`+)KCtgI&-3!`NuII!WE^zd_Ty{R zunV7vpG>3tMP?ZM#v#Cr>(+(&?6n^X&u{Bp#BET|{ETJ+SjI{t?*DNr0=VK|Rhfk^ zf5q$A2+;{HDFQ4+>eCsyMFy>&u!|TDwaU*Px)in~G|1(7j9D6u_$$x%zKNFq>5MDX657vz=x23zBtASroZ{<&x1~1G%J?ON5B8LLTv}# zm6r{oHb}0>`@Q^PADAMLID(nT!d(hA2N4oA*zgGP=3rhG4QRXoyMWgCEl~v%2v8uerF_ zC8m!lAz0rOA(ZM;ww}W~?CXvbHj>eGe+@h^wlMHWnuLJ|fl0y{u2zMyLbEFpfKMab z!^Cs_oNbRCukt3}?6NAUfP)Pqt zIXzAwuFjVJl62lv_*z_Juf{JNeXR`utg^x;dL)EONi57cYY5QOOkBWTGW3x*w0Ye^ zYE>2Kkgx@Q1ZfGaBtz&LN`LY`{m-BiR}2Bzr1+_h*fyvQGv9OfX^8q*t=j9{pqBh= znjT16(u_l@f(VM^p-aDt!V$VU*~(6ecm}1kmc*{`ZCM~hZ<9$ zcwYq|La+67vO`}qoFbq^O?XTn*RAv@L@WYI!DQ`eEB-*T*xny_Wmp32*)OfdhF$>U ze*9)Sozsld4*)jjU^LDDe;iF~78gifCmPBOXg^gub->%Miu=arhKcqS>i!_2xbrrE zh3(-JLd6Gpfv9~C=!4PAc)iEK5XgdLP54g)X+$M6MBupCs)?r@9Kl#DduvLPY2Y@|ymC#c-g2DsZlzrN)13GLnNUMO z{OC#4Jn(~rMmvD56lAQ-l<2E6U}``4DQE@>W!gFh+MCdUmw@4<1+>i*Xi#W2$xEew zrsB6Q1E52zyq?~SPORg@_0*RUOn^_WV{M3}aDKLgm* z{xoVUUg3BZbxY1!Z;GlXbvX~v{}qQP1UPnRo;SfzjvG){Dbl~Xl#0xoPa)_|WG*Xc z*+z#6l>}rDd=m`80;3+& z>qWZo8&n&ep>JE%WoE$0 zGmjEg0!|;9h=N_v@N2^A81V{Owvn8d^4-1f2_wzN3#op)^F;vmp-r6&x5V3crNBM| zNSbRg4i?Y7jxBt7OxsFGdxsVJnuR*6OP9&(rEP;%pz}Rilcus zg734mF? zaR^J=3WHlts6SD)_5=B;HoaY4lwY1#+||6vpmjT*! zCPfsv);0rDrvHM)&DXrgJS^~Pc_w2qDQH5osA!rCn$@!7-ksdn$v}x}HwWA#x)Vtc zj6pW<$9f}+ez9sb7#7JtDcpr=@b$TlRMq~k%{>GFX^%^DUG4xk2d0rO87qmmF%5{e zBHiibk+OvDj4Q2Ri}D(<7%z7U&i)B4|Cc~M_{Bv4rSKw>zg==WCu%^$B3N544|x0? z=Pe0IbI}{l2!^1M#eZ}kX~&tv$%#8W*xUO!y>|-X9>gU&wBOV~=SfUW;zBbT#`>WM z#*jnG6+Dc3O7D&svts@wg-|1;kQM{m2#e9TP9ZK2FS$K;Ng@lP7vmbe6?YP(D(V^= zNRd*`WRjkq;)vaIjzZ0v0id3j=r57faXq5ZN{NP#YPB85E`-W+^*GLrQBRj0>x02g z_OGQO&2hiIyGTN60X-0B#2M@3w>>;ONVUkalAG8! zDa*2+oz12Gb|O-L(|{DwsnQm20jeo7;T!r}Qq+XTyT}lL49*~t+6+Tj#84!@IhL~( z05SZ42`Y$d zwqjJs>BShTrXH$AFp2HIZol3TDH{F3wx|GTXSGiTQ!fPQ^D*qU&RyTTXBPKN%I zg55a}pODXs*EfY;Y<{m+o19K6-;2-SBmC)yyX*aUN`I3hUi40+EFRo_96 z4N{>-NYaYS)(xw~xT5TMF%NtY{nyFbKxgPRX3mk!oI^4)e3?Idn{iN%{02&hPpH(z z6&TK(X+7*D`KMYT#u(F?BixMvOO}Pis)S99yZ4=Om(fily?!OM%=~+{9)H$g z?aoIQ4{ZH%wLi|-9+v3dQ(M^Gx^jm}zkw_M_wqzdOH`W>=+_DHIH zC_VB1Jn*J+W^TkvyeddOHix>>?0n08zUMO031%K}mR8L@<`MJf#xp=}JaBS)|J*5- z!?&!_a_Pe|#1X5}*O$kao&Ex$ZJIIk|4fS|a@}eKQkO4FmOzlDiR+1ZIZ)27GIiCq zQyp%cU=ffhD)52|rUf(?co2=zULaLGy^$iSmS4502g4g*bMV=zDKusN1eX;!Gd^I!eK}C zaS0{L-)F&iUhJM{Lk_d9_BY~l?~Vh&yiTyy4e3@*mUJfNp#nJpNU z`u7fwMeg8hWtk4b(;Q&nWQSwcs5_8pD?ZJkz289*b@XTqxu($bt>=ARQZkoTCBO|z zWSR}5{wo-pO6+#C{b4^5v&;`k$PIC?f7gQ)E10sB4u}injPs1<5^w!~rc5+FASB28 z7Ogi;CuVDqF?{JLIA9%a+c5rJbBw$6 zeY~0(2<4dtEx%dG$Ffre3Aqb5jc?!c>1tX8ykppNWC8xcJJRr!JlR4>!%^ z^OHxZV@ElkYmjH>wJ}0<21wyz9aP7xNph*w0cDRnmN1_)-tt7{R*9rtMI&+KKIAxk z8Op)`i?%zjtGu)Z&WDSvaz?9=RyAsWRrzqHI`f1s(!AHoJ%4vj#Nu75TcUq4HP_d3+ z@|38X;zLv@Vz-SOrP(?CUWfwa=tjK?h+bi*)YnB+8MGeJfocc3J>%-?@gPZ=O~#~u zBIgS)h@4t7^aW^}GjGOdhdqFYrIC3X zWV5*jCEh9#tQmv=QiCRo+08-geq z1MTj`6I-cjCfW+m{~s(KB`O+15YwLH3ocEpWn0wPmiqtOb^MUN^liDYqS`9*l}JFs z#oQ~)?zC|Ho&K{@2|Dk;_di3?WI^SEXNac;?N*#5C2=*J32pF0OLd$6UF^=h=0-iU zmwro;l=Lde`DgMMUJ@w2eR*~3oz{HS78lb=yxU*GSP3%EDJaiB3ZM?`k~)h=^n~TA zhUC?2!SXHsHB__C&P7|#WL(^=I%MmKJ7hje39ZVmx@;P5dXEo{2BnjsbOgBb3grXP z*hJsbifhIq?m9Q*UefjbN`7B-*|>yeLwX}FTZS~-E ziazehW^A@OvU@rZJ8B?s5Nvv@*RH1p{)FoL*{74WdH(gon4?$1y~s*PrXequOCBS$ zdu=A=yC!;&v`H%D8q3D#A8LK`cH$m^*oT>1-{b?aisl2aPhPBD&x6)TZ*%GkKWQuy3?o`i!2DwGFKAjUU_GYx^uTy z><;#FH?v09EnY5fSb~yHAeK?NpIU?aulMS+X_N7&U68$!f2qV2 z8>mHl8E5sx@T^;)c0x3s(U%wdkN67~bUJTjH1{Q$YjVQZ`|D|}l!W(-C7&oOpMK~= zrCWzbrZY}@f&OI3*$L%mnA3_h=(`G)j1^>Lx&CsGF^P(l7ApD|l@x zqDwXy*cV_3&!YH$xn_OYG`X!QG)qFL$fT#ApEYP3y!Bj_^B9pLM5S&MRzFhMp)oSX zzDr9|iOm*j&QeFVJ+en$%d?Vww{&F<592wu!%-AWNS$9vbxZIp+2!i+3%b0)Tm{1q zJS6VZpxYJ9)lQaxc-<>fj7n<5Xb>@G#rM3iYqhyUsFnfUml`=`V=Q|{mk#XyALN)_ ztWdADpex5v-?hIV6hKX)j5~&*IDaxGgl$4Oz9673&K>W%it@`Qv6Jk%A-4!o`jq^k z`FOb!VVH}rP++Ad9Eg3}o9Mk3fUSyRw0(H|W^K;&TihzOULuYM?w4K;%!I)TR5OW==|e;f^Qg3v*@aM?O>m~%lYhm)CQwz?yU4%$85dS z^oGtB3_tQp+imGf3+}t5#(l~2hq;hR9f8myrkJC~XeVUxjassBGh!uVSvq9-Q|N_? zO$r{AQ^Na?iTmm_Cx3Rzi6JfyLLK5}9-l6ry?E;-oFbjDmn>&c^3^tIE+%v)d>{6( zanaWSKD|$FbhDf51HW@ zJ^tU05w`&+>vw2EC-ThII@UW5160P2LT-P}QB-)n7sdk)2%YRFkQWj`H?}%Ft(SQh z5ZRs5fb_A10dN*zzGs2CB#c-!n0V*j+sJ;CCJ2Vlm&kbvYTw8D9jsW%?IlUT)S(>^ zIhtE#cIQ}k5s>x}$Si5TnXC+7Of-wSHpbGT^6LyBBb@vzLr*14CFaP>r0?|GEasJo zPB%mf0blIR>Pr#BQ0^BgSf;c|(bP1bUJeu0OPET{2j|PaQqQD_hA~V&!OO+v$UJ*K z5KAWTD}(x~#$Owh+Ub3^J|Rx-N2Y3wuAj&4LOqSQ!-QxiH1GF~w@eKr7`K$FeK*)T zSiOJGAF4tfObfC=EAa*HYZNMhf?0Y{daV{e>DL+|3Sq>gnAAT^0&-Rp(n~Y^#N~g3 z8E6}p&{E5u^8gk{qTK%16P7UG$le<%DP&$71{dIV;H21zj@|Ed3z@XI6R6EESbFz~ zm+ViO!rC0i%dJ9b4yXjq0ohKi>h)~P@`k7GEpd)UR@@WYw*{A+2#{s2wH@5ixc}wv zkhxwSb`g&l6-u#cwMwt~tp4EjEkoE1_hRXuO-Pgcd%~{|%pdNpitWELx4C81`oIkj z&D%oaiT}r^vBqZkJ?9`xRuwC&DI$Td|8gO%TyJ#G z%&k&~CKs3P`mq+rQw0BB7MY9p;UJ_4bxQYEZcjZ{OO(eqo(Lw0W+v-d9VPGqY*dZ+~!b2>9DP~>_jyP_St+ziR zt9I9Ze_b4;QCy&-mtqmG+kmw8EJ1RkY?}Xlm!c&=-NisvT35$ts~%~Ma(|s zHgKi^r>SFq#l5DuHQ51L3vQ!HmdVYGK5L)c!lxF<1`u2lpBU1sA9)FkB#W+n7&VB1 z8uNQZ+^Y>vhOb!O!K}{A?b@<&uiPD=)|em`+Qs01oS0WS+=BR$5#74%vt(ssoa2bt zb20o92kL$s;DeX*1y(skj^X1>JCGtG*rSGIIj^sBF9)dc||_ojUE0r^td zbce(oqsy4Z55Hs@;-D}TF|N1*aDNG(L3PD;TCTC>%5$n(nl1(+%`_Si!b}l z&p*LEs1^gSG~1c~3EwT(mx=IqTkilYslW02=eZz1kNAG;!yS8}6Ro!IfJwAjtibM(*;&DHuuHsO z-YL762*Gq>_ZxUe3Eh-lSKct$lo<#5RowE=Xk@-{MvMk~M`!fuSe$X(_51lu3E=4@ zyi=FpQ|&%vK`X^K5HqnqD>e(=^wNQ7m_Cg&`tPq>KBTw28mV+fN)s;0mA1;#J3 zOVbw#N%sS>-vAr6zw9v1vf`0mYkMGEdO!jf^r`=HL0=yrEodyh6(}ixvphXL`qR2lah_nWq?8I8QM5f0sY6q8i{9p`3tBI6e4 zIvk}JCz9_mE<^WR>@iPKSZcZiP8fhL<0But%z4HJT&VquPRB3PKX*`Sf8TO<1EPVRXdr#RTaZ&pM!7Bk=IkA9IVfu+oW_XutX?|xX7;&Z8 z*&q=_pYl-y&QI_@8knWO+PH&ztlJ6Z3#Xf2(#tcgIvwk*vY=nkwM_alOLn2@Hn!Gq z->Vjv7>z^9I#VgZFT6icb7-;mwfsGiPs2@*)7#g6!gu)WIB8t{ zxpaR&sR6+9nO4mS=O_5f5`!!ABR0|a7^3?>zXXF{&|3~P`#7ks4R;F2;Y;zu@(Sy= z17^*t@tnVp6W;2&F2`zszaBe!W^_pB;FzW9xAG33w17{wM!PRrhVQ`A6Wzqk!M0>T zy72%1{#apO@%b)jDe;l|UY#~>u2I>yO~bFN(PQ&|kTN{^?^1>UW*Qga95r0E+6=^G z%n}A)H?a7%pQ;g>ajw@2+;YY8r!NUZ0z__(qgZ=|f%140DeG&!Ww4Mbiu1?m15Lp} zvw(6a4o^CU~gLk2P2^PO{^W)2UrPOXDNV#TBTmbpb(n644RSeKQ)s`BH0X ze6FiI5Q~dXln5xUK}C_A3kJDFxmqiDB;Aoj;?pNsSS7IGTJv}(vw19+!l^iibF`fs zS^8_&7Et^(&$(|E#y5PwVjtI)C{gn>broH3Wg{#1GvO-XJagnT?LX@XHyg;&Wi2oFhW%k-- zV&M!Yif=tO3&JaWzq#|?KixR1U9~SeNlq#}&>3kahB|1R3G6(UzO48A=NGGJ{*(8r zwE}zjs<%zaYgxRP1oqe?EI)Bc-bkzm{5 zpiPo;KIQr5T<`E^>{G{KJ|{{l5)4tyLx&(r^fHQJ`3W8hNjhy0YN}-9f5+g_kon-F z<_cADHN#>?%Wp(oC9!jkF8sbFcM6@?gmRLD8V76mKjm0jts3Pd=D0<;s3D@IDXSdf z+%vV*@;h)ryDd*8T-1J9dwG&r{-Lc%j*$WWRgJ0zF3mBwNmA_`Eh<62xX{PvDU0Dw zqnYse**;e>GNS<=c4lnHu&*ONNodOooentm&4ii3|Ar_zN+Qj|%>aJ^mp0ol6ITz{ z5k`FpzBjcwUy$DN#A@p#0X+hX;=X-%kzq2)2}cv?z3f}eUp#piW10)3beXBoc@A!5TC($)OI6@LLtI41)qB^(v=cn2Czlu+ zZ~IFC40d9}CCa zWUYKS=%RD1xeBs=}qF*avIA9s0O+L)$OBXF5vj+}C)*bl%3q6UR6wEU$o4BMM|fT)ZVV4OSCWX9IKcw?YL zV(`k`TUvXkAtRCf%=Ph$x^*(C`99OvnrcH&qK``{;N_akUFoKI_g8=9%iP&45}aHq zri%BvZrZiBr#$O4mAqv9=7_Zm83JB@ezDJ?@b9k3QV++YsC3ooRv~24ejzO;lvi$r zK`HUX*BAf|^sdMm8jKbrUI&h7tr!=@ye2v3$MehaTA8~&B55ht*w2o5s&MXc;Zimh z247%(B(fM=6JP5sozjWq9Td+%H+b5_>o;kZ@8#Vt_u2w>xIbG`kR&VFc^=Iq}*=s}(bws5}?#)G;3%eXgU*D&|NE#JQXa{{X z)0KUmnSz@OMsxl5jS?q?8j4!w=Tx{*xpJ{uZ+}>jODmR01tb@SY2-;GJqAzb<)1fZ z&ZOJM+wpk5xwEtBTKXfBpS_;>im}MjJ_%(1ESGgE|0=^zr$0_SJDJuUWfY|CP(Pd* z@DUetd5MLsfFU>)Ry6B>Sy5^7I13Hjbm`8b3UHROJk4?YGJ5FkZTtWn{A_fd-$PS& zG~ope(ng^x*~w<%nq1@P!}v@~&k2JsT&=T3TKRSnO0GO{80lE*TYF%V_i=19j;j(x zBzvOTAKHcqwSQ_pQ)|2-gu>MdI6IK@i7PJP^OqjXe)n~MQ^H4uOS}P;--+d2j$isB zSVqBt&!gK<`y$LRi0F8cb}q#Sln&-p>>hrS0QV;@`S_Nr@m?xs18)_-C1w3m#n0XN z(R!+CLs+F|VG#b|fMeTtNNEiso^u9>AIISR3OIC-FdPWgiJw!;Eh6f9S)*?`yn%aP zbEvJSB=TG62QJ^P3uwZ7c-IYbiBaW#nlB&IqEFHC-+)#CYG8`L^o`A(q~A!&>t_;p z-JtBe*iX&K4iD=l0qZG-TAJ-tB9x~nKeKyer)2uMTCT1#!RXH|Le5DET%%sbanc1^ z0s$YLv+d()<{mTnCMs6pz!_l4+_dN^OnaIo5vN#KVo_R;Yzl z;3eUBVFUkzjk`45lm*P_8T4c;pc5uUglivVsxg@_QDAQG5X-wBm!C^Jcin=ypp?P5 zD4k4!xNH~~6&|{v6(2uCp9G4PC7o^NAH9k4yxlI0SmOw8;)VoXww~jX94l7m|F|Sr z3`yh2H0*d#yUr$j!}CS2oSLVT%EJDzK>cVTg_IX^k4_K{y33SKd=t zSqO(CG82<+n~y=~JsgNLra6xL0Y}3)$8Kfk&6xIUmpcq-*MnRm+}!USv|M-0NEcoE zbsPOI2H5srFIY%ZRh<#Wb_(a?^BiBU<&u~cQn;LNr!}||!xOkv?eVpAv80#=dR84B zjvqVJt8EgBQswe1dv_!p8tGfamT!qv3P7#jdd)%^FE`HiyVb=@RezmK^>>k__utiO z6;znxp<*)w@a;KQb_;54aXe23{YW7Q)UNTeWKhfMIIll1iF;-Te-TtsMUgBb< zUk}$OuGIm3M;ZO3@O-U%qWGxh6WB^bXeA`H*1DGMf6(^jfl#h}|1;JQiYQyONOrP? zLMhpaF~%}XNkaC$ETcVZ_I*p%84N=9krunqWD7<1?2+B?x^bHPO_#01M|xDrAoR zv!}NJ`=C_r(4pp2ElQ`o&#{~c9=+$a!q?oWx2C!*!V-4_w&x5GZJcqgSGthA)2|AB zb;ONu(V9Ilo~%Ch7a=N2&bjVglKE6Pdm{%0y4CP0#GBVTLgp>S54((uF;U*&itlRH z>mg#41eAaYB6q35D^PG+k$s;X=s#}`6VUJ+(a%k7q+!9G$?^o^m4r3E(A6l>BLvC9 z4W{$PANxLdLC5m=sAQ-_xWl4uxy1Mr4>^f~83n<$%mLbm;x$B@LtYaiwEm>o$PRaY zF@qmHz^tjX9qCLM#;sBrhV$W=ezJ6#Z)W8Y(WWgvON);xDpP&MjttjZvh+bebLE8P zB}R&t(*S)sQ+(dvUq763$v^Z=J=1D8x)K1l9 zT*&>1H3i;7po`OQhn3%+08%NTc}K5_5QSMBTWE-%&4)8Y+fkGgFQ{HzTk8Ndc?;6N z=#0>(V7vT82ifGt+Yt52Yl`LYmZI7e>IRfG5^?wDg;z*`;@3wUE0xk zRJdrQn%@8X^q71X`o^srb(62J&e+^ zJ+A2GZ}6Pw1Eie7TB>gpg!C3M>Wj;rDYSU24NyT+;DXZkZ?SqMoN^n$ZhA%8KgmmK z+P2;?SbR|9&41^O)IBfQ{Bw0e)9sbZER155zQrHKQ~Z=fy^h<%#KuF%R?`6>kd?{- zxG(QbLoz{~@8geGyyF*xtRdYc?s-}&f)KNdw&0|wHt({J?-_zhRItd z6eWDE62yQ#6WH0*f7;pB@^A{HTQN;%a}7AGiQGI#V9mmYeX6xh7G@yP?C(BjszgT! zI&ErUgckVdshwd99EM)O8-T8Kv)$}1OU3q26elh}M)h`cQFu~*n!owBiaj1~YP22A zi&$DuXGOztN&?BZ)mTLPx^0#h`yah2dE>G1CYZyj`cgj9%*&Fld7#F)Ady)tlGg{m zZr#kDwQR-T3!J`;Qw^(Yvo{fMKMxjvlr}d@y~8n~J)%Ja^6js87G`^l3lLt5^8UP` zYcKD#KL|B?b8t_aOH5AFSRmyxyLnLR%f!(J_<(ir!s!Pe7iG0K@L9`UrESo7OP(*r zEBUc^L{?vtR7_Qu(OS#&%`XX6ks|Jg{Yh7s3uM4ToxT6O_A4|U1la8CqUiztanRmh zc~W;h&R|SidosZaFZT-STfX1WclSepzGbY|zx6G_kV{S-ED*0pQc)efiY2 zfXhmll0yT{r*NPbz}w~9u3xi&vjQ_EkETdFzj1+~)vi-t!YGi+Z6VQQ4D#{;A{0M{ z9rQ;y7G8lnfBh40OQ+iFTobYvl@ozIe(Q%r>G19`PdLj0!Gy}>&w09D>c{~Me=j80 z1<`(i@O}%_3?GcSJ9E0j^*p_xBTzEy8>c0oB|XDBfOzjKdpdeZr-uJha=%mbqP9F@ zW>b7Lp{-rq>Lgf_WKa#b`yN0q{21RFdy8BRNM*t^mEC@mEC2B65FNfk^T37x)u^H0 zACaCQr?xvJg9gR^C&75Y|M<}&u`dY?Osu*u7PXZcADuj~cH-%^l6|>E8@l1qcLo?K z>M*H`?|+*SyRo}49wt^AbsR>z`T@L@z!I((NDaeRR(#ifY9GNbd)DlAc_t+ zKAOoQ!<~C}5kwdJpSt3I&j^)O?e$c+pdW6U4L56)d!#Dl<21hkerz^ilJCIXc(Vv( z=^lLP)8wq0Qzd*yq=J5*ed|KAZ}g+D9XbL-7gAon)K(hg*Idu$i*|UbZC+nThnE&I zI6_T}XFXbZB3SjuRdY<;7?wtcl+hth=?S(+U0^0tchhl8T3?p{BWcsCL0}Rg`KL+5 z-!p5R<38Ph#E&U>kuz_5&t(}D_F=VW6fW#-DtP(#&cDxK+={VvQ$}RC^4mx4&WmSD z8WcG&13!VJvgA`GGW=3c{#ZOLw0PJFF0l;nF1VV|#DOnKckYFH8?T*Mq>w%YZtA<| zSzKd@Hs@a2_T2zIVQ9u4A4$1Cn-WL8_tf1PJ>r`e_dU^$7i$%7r_|d*s^D>q zIk|Ff2z;(bx^@aG;ByiG_Rlo}KG#Vw#y=Y-opuNjFVI^?gTLP0W}*|8OL68Ov$-4S~*Qj3D0LKJIUV;3$3O* zdkzpPI<^t`6}90Uv&FL2@8dJy@d8M4#{dxN$N%O?*RPvWDqdB08H1K?+qrgp>JAA= zi#I_w9b~Q-XJm?^*XOaY}x!%0dN9%HDv+ zUb5#fO;>@rqCZKx;u46S7k_v3ELVb)^Qz#A_L#_aThneFAT3HDL zz_@^k;~qRRV0g_6oDFwq{}}TAJ!iw**GjCBq%PrBqrd2$|NEUmj^xu}8=7BOX5jqZ zd-`?*XZ3itNA`+LDzQbPC5H3$si>qO*Vf5R&a#oc3-nr;60Rxt+1Md0DMtgY6BmBQ zqGVccqgBFifT`MR?jVZ3{x(?dC7xd{_sGd#0D-XQU&~0I3&DmnshzACyAYmDeED9A zk8tk-69dNWuFsgvT>#9fr&}+&Yo_k!DaH5U5F`!Ba&>s-EwV z;tRh($(orj>(6^U+!2PGrrwM669x^ki2v3g`!8UhH>lyDPNT!`th`hcxJVyWOVx7i9{wQwbNLV+X_2|)$B?L1%5@9I2HNql$4j&HqGZP2Zqb9 zZT29cB8c*rso`JwW}GOliD)wI;Zluls>NHQj8k_>s?S2?G{M%J1b~TO;0K)!`~p9y zls3!(5PV8%kB=WfyBt+RV6IHXGo#KQrJ*KNAoYOxR}beomCZ_J^Zbn<5)b(tAr)$l zBmPU${byL%UyGD7aw=Rc*i0|gKrnRut-$~`Q|npk*!$rD24xfkx`FBf-&U6$(nFBW zOrpO)h{5GwBL+Dse~uVr(am0c0IDHfy`wZ2yAxHJSWvMQV-(R(RA0jJ`EV3k0gj_N zmvoAkX45vD{pE%&1@f+A`2U=Fo~Hdd%1tm;7dnTeQlDL53dolYJ1#&Fb8Dp$Iudq| z9EJ7?-M$WlrQG+E>@7S>a06VQ{C1WvZKfm__9cCkwLb`YqNRWIME?zb_Sa&bv~p-( z3PmqE!4dLKk9g!7R5Gys`r7>uYiO*gcS2|FdFvCsD4S<`PX71QFS3yf|tqFp-OIzIH# z!jOi2CADl~k^u3F%yo~-rCQkjkZu6_`FGc#em?F`{rrE6-Csf{e><-O)dGk%;uSKA z07SeLpYi>j6d(GaU0xpOL0+W$(S-I>P{Byudk#S~@BDI){2!ikA3Y|q1=?cRnHBDu ztJ(ndQw;Q-sSlyO&O;Z2?LQGT1QCMq);`iy&Dvb8GNwe1=6)4)22}nN@3%&QZ!0s6 zWx@H$B3W=H60z-O;2M(q-&cqKIZPgsA!D?Nxq_jH3er~Ufq{uS8! zx1$>zI~2&kj_NfdN@PN}Q1!!n*~y9#CepPl`sn3m^=HnI@b!%`pjqa z`BmL0o9ElbM4M^$(ld872a4{o|Lf+%9yqBBRd2eRkFug2j%u6Lr8Ub_^6_*|jwYxw z8#?wtw}r9h(O*8&m@pMhmK^SydW}?abtqI;TLWIHd0-7{E&2QtVe$kdEGE(|0i=x( z!`C)5d&i0mLHW}5zb{{CPa?dPnNh+g_{nguCk8k1WVlw_=t1S~L|63ll2S4($+80E z7mS8d`hu2oie~?CoWu+|DM*CkP;P=d$l8i4xn-5#?fP~;?J>a1SqFMUCrZsfu@m@- zQ%|n(f10O7wknh1N|x`z zlK{Zmufa*y!Ayx%Rt*HHjL`qSweX+!c}$P5nS_i6oVw@m&O&Vq0D|B6004imbU__e zkQ%!Dg|hZP-*;y)g!Owwn;D9+=g=;A_fPDC*QE*F-h%G}NLkvTn)~U7TTCT>X^dzE z!aFVa;cOG8?xX5_mq0-v*h&>Zc3rRxEiyqdc)A0vdUmt;jL01b9 zqy9{p_2`+>eW>YowF+6+u0hfpVkCiFab0@Y@Ayeedoy~x-=y$J4XN{QU;e-8t*k0XVn6LMk&^N<$#R$jszII z;n_}Ido(#vSysOAhND%w|E5J*8Lc7k(cqWB5MjkcGP-APz89x#wIl*-jq97M3lL;| z|2{~9WAN*Xk&qmH$H7-_E#hyV%`#dZTMy=x?R=p)sQg&NNz))Tx!rsEIUv3!0O8O$ z(5>nE{*Jw%9vWOT=r|^5>(p3B5O@zE2l>y1_d5nGYH?f;u6y$1fG(o^#hq}sbjE6o z3`(=?=5TL_c;CM2ORfT7`F3M8RV(j&H&s?O`QeIM-cUQcx^t)j( z68aW6_9g(ci+-WvmQ!Qk4-&w8rku+!?LO-%ppH{h4&2Er)+@N}Uwo!=0g8nE0!F3c z3JZ`$>6l0DMiTmiYkIJB(MZv?#X4&4n=vQz^Btkh36KIro4)MJ=`G*{v4_Mbi@$Yh zBmnV?I&XjiE8AGrk(!6RZdyfs7wBBxU%nKlCEfe#NkNjwU@t89QRtQsz2@=^z|2CyRy!ay1`NQji~ylN#u+c&xS+$Y}eNZgxQyoRl6}T7q71^n;|HY`=BFXL0fL}xCHQ1i-(-5 zG~-UahF}?E71Q|=bG17)XNS0VR^P5{4)l-;zI~=6H#Bv+kA8aSEPz_P%O^E6DiFs_ z&5erUJW0mJ#W%TqLjWR54SFBlV614k>K%{`f58bYuPk}=`b(whq z);_E8V9l4MYJkh``CJ~NtJYrfX!vh==r(#OnV>iZj$3B&|LK=n?%kr!Hb#DRIOb0-r;mXds1!ysgJG2 z3QCxh{**9hOiQ{>=oSOaXdDors1IV3cmuru>drp}Z(L;}3_}o`fD?Ter+d$pF+g4@ zb9BxxA3LSQ4C9{1`|WIBZ{&@69z`I&GBopg4XHM+u;nWqBOc}1hb7M@kL|$90qAs* zj~|d1G5$mm`JRVw)1`1srw_<<| zLl#;i!yml{kMqzvz5wpjy$S#U^=YpwWe+^(MpQeBS7pqSqnTXdAP?{$8iCr>LnhlY64 z^E|vRbhcHCGURi48Fu>zh=27o_{YYv^p6xiVabKC6i=K76A!X)Pn+))ZOxk+lPiqy zouS)&wgb2|*L?gL0g~33nNBIboqnkXT!PmDfI|wnofgX#)Awi`#?Hb#v75ju%W@u0 z+}ADwBow6&Vx6NasYJ*{JMO&^^dc*5Q{35f7ZGS#NbY)Jw!OxQ2Wnf={|4eW%cu7a zS!3(*0{?wRqYZG@k(EEZHWJ9}8QOlzi+ZyQfS1PGYNo8j(uV*X$X-#0i1{SC^^VaF zd~5b;ex(v)FNmhN_@!?=u1Q9LB-D*s)Gl9~nZ7y7VXhu+%lRYwpZj_C|53yA#{T3WVg9r^%f<~S2FzN4pm+=V{*rL zfXg&HnVW3Ee?G#sh0Es;g@TIJFskEnpXH0#^w(JXZ(az zr)Jr3$1a6PGUP*4N7it-2P`a+`oBQ=k3R)a{vG0nTW@Rt($#c(#=&pe_;B0;8?`45 zku3jmm?|ZAaDaNMpoV}SIKWvSz^T@{rIf@8Y&{L!d<=MoLt;ea-t;2|k@*I94Fo*k z_a;=x6BiybM%5tr^fKr!JS&PV1ol)naL7h3KNNXA&ZK(NIUis+6|ds%y=hrXn)7~p z=WMPFnpb3BYn?9jw}xYVseG8VC&~OqWJdJ`+pDqu#{Pt6fOfi=;Q zaQvindv8sez##`%ia&wa1?jQ-hQY)%?u*K$ve9ETM3OSKM`jmmfhV|;s2-_#j-0v! zttb=1RW+q3!svZ{^=5a)=lb7){GJLDuuWk4S8V?B1}pw4c{h#gWPe!!SA;E7ci=3n z;H>J-*zhS8<2$LWb7`(UlC+L~PIcBqn~MXvlzFdfxCIGvS~UT;j)58(;G}+s5a!5T zrl%&R7YgwiSQBM(`TXw_k6{el$XDPeA@hlRG!Za({R4@N*u5khBC;E%cjhh0AhTkI z$efGFIdRQYfbpOj&vg$>)Qj;12Zindkx=y#;SpV}rb7q|J)=qXaS8~+zeCmGJYQ&Q>Um|o$qbi~ZPi7{lbbWyZnWwxK z`O#sAGqjHG!4^3RyrIEgsJGzNiQM$`@bp&~EQRTbs!{?E%#3d3AY|(Hyv>~$3h^- znI2r#>{$H@n?;lNjd&3SlGDlhrP>biG+Au{yO^65SEEGDZkCTmuQXG><#Y+UVl)b{ zR~4U^jgL~U22DP&c+50Ai5TV3IUntK$Sdof+IPpM#bE0F8mB30(kAB;uZD~Ms$Gc@ z6oC495gSn-H?-8xhimI=AOn4Oh9`4Tc}aEND-g#6=mY$V$BxX^W*{Jt{YS93RRj#I zggT4|vQyJ(Bge02N&)iX)xymgV25po>&?bw4DWNKBEW0lPQRo2DU~YTxJcw&xF+8| zam}0;HheldgYts7s4yMs>`0KrO_n4so;606YD8mEq7eO~S9+N+Gl7%-@i3cDr<9_% zl}_b~pveX733XHtc+HGDKO}V@r9ovn$I$j3QGW2EK8qX|q$Y*8MO;}lHBC;Tc}3w} z*m85!^KAn#HD}JJjIo^DgMG~nvx|{o zAj6X7cCj~qJEh}h(Rlp~*1wxjJtiKEM*j81M=*DKenj_{e@ou1{ zUdTsVy)1H-z_!5R^hY;H8=e)WxG@BO0bIOMqPqBWjgsj z$vNI5`mN5vymOI)Lp5#4nuUyOiud2?tMCQsypUxPz1Dv|j=BdS+$^e7tR=23qmiE(#w|b3D5^C@gP;h%& z-YBJ=YvfCLZc&`hKCJ2DxJV_2+6pp&v7|NtS_EzO36bdQqoK%kWe<6*ch=Nxf_CvXprzCgvucBKto!{@EJUUxvXdG z5_ld%AKcvSCJDo$MCF}-FHl6KjaexXP|j{4Oe#h_(cET6$uU;Xkup47>y?(71HMW! zQLWyOQ^Pj*8D=oDQ{rISN;$U6M1gJx0oMgGjp5<@zyYmy%6Rkjie#|hQY}!k>=6~x z&6$q=a@{xD?D`qWFNY+Z=s(eJ0*v~ZgoB}#Z>*Bq_hVQlOyVwG9c3$T(O|JZOHe5kGBb#>;-@XAJa??YaGD%YvXD6Skb$d@ z5}%Yg+nXlh4rANEz9b%f?!uBZYm^13*KxhK=c?^ut`nyKqtK>~U!xEyLfO1@fi0nG zmxeXU_Hrqeh^gAIMF?%emYS9VfMcxDndxs(Ljn6V=2dgXmLyeGe>$pJs?rCz z?L5r${8E*3D-(V(C*INv?R;@|YLriuKpS~US&|&niE4_^9cv08*?6L9kRAK5bo(eX zE5?%jXi=7rzdnzqLUx5%a9~&l*3RFz6}nOlRjQ(tx~;}E$N?AYoW#uaxHoRtj5ID3 z?_2K^*$r;NwEbtpd7qeTBri0N8|87*Iwp0}zjX$gpV2Qezpf$K*I&@Ts6zT`>F_5s z;Cwr-jHsod9lxLVMS&0i#A%+7oYIIcq$I+!YgUKpOqPwxZ{{&S3=q$qgvr24p$Y?u z)+?k<9hHq)CQehs^WF#A4OS91?@!WsL*#J@pwLa{fC`9xQ;dP<{XqVQ{DJ&G zu!R!%@3{YO*W-*K_Ujf9khKz&=!l|iyG|5XON;;hfWw|JzwZ$J=`U3$v=e)TAsW`A zp3aC0QDA-ktLE@$*?7-T-rMk21ZPCLBthM6WH?aN9&$0n-SsaNs^5FB{vhH?CrUfk z$`9NiQK5*rVJk@(#bTaxGzZnu#mym=#fXEbjPnry{{w@`l0FdKB+0_{OA<1jq)=|{ zU8BWV(Hw0{)6G|luWP_>l9M&cjIrG#cIXN1J|Lm|(2m6k{O~v;;uX0MJX`2Be=Px4Q}#DZ{0Ssoq_zMjGPTRr&#vw-wda z-c&xzIJ6JKMg*#r{b~=_fEiH@0?ddCwgGziXEP%8-p7`DIep}w?_iohC^iW#@?8F$ zeeITeJySXn5xx1PHxrQnFZd{dv=ye@8T19>njSz--N$4H$tuthu#GUo z=;~2f!mF?kHlr~kKlnR>NVbPIrnx#DWUG6{C`%X4XaUZgrMzzRk|!uq?Lz!LVX|$_ zQm`|Q(Ka^h-4~Rkd02E{)&7gbTgJ!M8Uf3MvTz%eE2V^_rmN3~dy9l-G)XH!TRIC! z=&|F;B6F0+B!;8gt0$fYemzJ(&&8fu0_Z7?9P5i2d)Un}n8UC$c})1lWFPN1at!U} z^>Pb^9jw`+>PHx+jk|T>63}}W-aF`6PtSHf?>I!_P-B`|lN{w}zoV%Y;rlNwfan{i zy|OMeu6X7y2451_kURi^hgc(kD!@;N)Hb!k;Tc$F0{hk5cF%}5dv*yue?q*NSjz`< z$h48Er1Fq%!)k8PuaY09z?6@wPcP>T?f_-$o*IN>Crp@l>Z)U~rCW~Uh-_4TVHN*A zYMy>v_xp$0l|EHPDOQeT5wO=$J_AlzLl{H5dkZd&jTklRV02!pU?)t)P=MSYaprrt zfZ6i`($r|#{$ShSxs}gnqkT>o0ed4_E?hnm7`U|Y{EgS*y?BX0uksPH@7 zpgF=fcf@hd$UZ?-RJU$MA;O#g3J{P*iw$y_Oh*am`0^3Hh?}G4d*{WQSIO$UW=|RP zdKadejRfvT!~!Gna8}nWu69pOW&1VGET0cu>((aY5Wq1hqWMhwg_=hFnYMV1!AZRL zn49#XmYKT8hcl}WqPuq(-J@^ir36FM#4!mleilt~Yu!zN;P|%UwKJtxrCv$+jaWHM zu;Jjx0W$T!XqEh*G5<$qbo61+*6n(4y(8^UTCx3VMdfdxD92tYz-ciFCIKDJLFA~J z1T3jHS`z?{rW`Ug)ec{YPQ10q^lC$f5_wnb>3_$wxJHrzi{;Yxf@gQ0@ZR^@7ADmS zh4ur+X^5O*lBPKAE(*ZtI;nN4=vv>LSCfqIvQXEJMeVqvnK{!@4nN~)>*JuI@a5!* z>7O?gup|ec+5$>kNvP2EITJ-ceHPqnRmE03&@^l4Smf|0Mf2YDfyGt0(M#PqFhN0y zQg;W6yJMjHDP8}7UIJ>he;>ujpW{nXmZk(gT-qC^SBTZ|2j=qt$$SI(oO7g6X0*XY zzD;k?`eAO8Vy5>GdfP5aO8$MGVkXhXC-~**^B9uhVb>oY%n9T60f=_JS}7&k?AwLu zftCDMIr?ufz5k3w`6V1AF=1J%cyIDd0xvc~#GU^W%xviF#t>BFyflSsoTxw5IRC#| zUBAKiULOY z5TFNGFM)arvtFYKFP03ieOuHHn|do>=FRLd8xX{~dsQ^v`NdUH@;%cfAsO&I?D7#L zH^J4dhqbGr0!Xw7Ei|_k{72@tAzA`p?pN&N?QpRsn$}Yt5Q9RmH4aS~R$g#ya`!MzSn+f7{mz0* z@mYy02ipFvU{GWoZEV9|JQ{5hpAlnLF-Bt!6nDmeFjm5B?EYgav1Zk`w93#NnF2gi zquqZ&4e~!DOxR$!z}9fi2?4!&uO!!Va;PLAV$P=E{lY&OUpZTG{cBI*7}W0vgFq~s zoa>D;zUrQ$n^p{oh*%4)*pI?iLl*-?r80=_HX=-OjcYtmUOsL zQU7Cjjek{qU}v3xm>aM~kOFdLcHK0|pnw`9)H6tk)~r4?0w24ww2~W|Eh@jB>UXHM zTmL>cCp}#Pr+k&lzAxvoG6gbW9}fMn>HUlzazy=iZk!jCC3$6k^m1vCNs8&JYEvC- z)R=6Gzf|)hS%OwFK4!S9?HEBW@9^gufNqZBK)ZPJ8|6E}a1n4^pmwFC{Yilq&xPAv z26*k{GrcW9Tx{%Xal16oS#vbZhAd=yuSICJ0Ldo@sP&USGyAY|uBLx?Gz^@?OLlz0 zFx8#yGtf&K;Ck9^e_-F;DV5lu1WKd??Ras5=8V2p@Sj!|N)pENJgr*zhFnz+2cD+$ z%KGCnNQCdMY+4OKev21b#R6L2-5WV*fUAtU$T58qoc{P94}P-x{*3!tkb>zr4yQSzp%#4X_h2{x2{s-~yS3@kB(pG1$d-8G5%Bx{ zjQI8=TB!hd=qJrK@QYuZ z82re>Ci|mAxz9h=eD%bN*fj7Rxj7bZE5dl}wfF_bE{PB&@=!g1*k?X)!xKv?U?Rxz zzstZ{X~A)jK);bqPDabcgXn83Lw-9VmOzq?#pLlVFvg7s`a31IxAVBofO`c52>{!1 z7O z!&99rNBFSN?Dl>G_wqA9`besId8b<{OKdVd`4BmteQQ0{v#i=MVL3az>X(B}(;%X0d!CHrlA6@PSYjst_))`{{bvUa|$cBHl7nHy*q zTAg!|a2Qh2yr68&=3TdjbEUe0hcspFoXgrn5VcI~&0DiTgKsQIqK(iDCr9lzgFp+2 z9FtKv2du{=0RW`d$L~wnrFb9-_|6nG8Lk5z1ThGNfF@f7F)k%^>8xa+!(hou@tv6;}fogfKPl$ zuHpByz}4`agu{}6mrugsv#72f)I2RV=@g~h%@znU22F46s}@vDNKGriFEJjMUy+-W zad!s8yAcWswR^u#75)JI?8_4!{GWq*Z-1H+}{cQtFGPPb%WH_&TC@hrHo22P(?kXGxT{EK9; zpOTpvFv}5uL0SdK*{9Y1XL9y`U|Rf~t>RndF1qxZ7#AUhtpzCjJj$=pWFk{OfbNG6 zAi=7R|IZTMA!I+7@cu1nqd&C{c{JZ@5|Iga#t=qPZ?*tv!zj7ZE9pun0Fspt^ ziznT<&hc?-&y=|`;E8P5k|C|si8i4WH?-A4k^@l6p0)ENGMrRrOuq=I67+tx_5C}C z!0R#=0%`46(S?q~Q64WFFKUK0$cS4CCZcvpM~R8AnACa;Wc3HjkMk!a9MGsZU?tBI zTb?p0-Yz|pnD)(r963ae7m?I|sn47$#|y{7s~MO8kt_XwByu(R0$QZxg@ZsJ9o=+? z1~o`?g5@$L&_|ET$J_z5NPiMWk4cNivUY3_Ud4j6LJdeN?!j=}T)ub$8#QVR49(OH z%1(;0;4jvgf9=y*4kDFSrfySQ)m@gd8QbGey8hm#tMHUt!aWk<4DqiqUhC zOOwCJzLr_f6yG(uiv-oa%cZJ=b#F?CB0e%21_ELT-@9U7q=&favhoj!{H^@`caIZaI)cQgrbkckGl0FIFuVb?NvaiOLxhf!W z=`0RL!ulNPx>SmgSUPYJyQD{mKcg>|+HGlnC@X+8SrolP1)?|pm(g1UMK65^Bn%%@ zgdvOexTAIv;ol&0IYTfOvm3p!kr$X%LG+U0I#4BB{=g~$Z9#{8$cJ$Ql(`c-nrhoL zlWuVwl*oSjJ5(q3-L+AAc7g$X@bh?gE&=^A4Wr;cEo$2CqNX|VwH+C9y^36t<@4g> zPLj7{UCZn`72m$LyQm7cJUWGbENXay$(e&tpA0|5MOYupE!NCK(eJ}Etc`dA0+)_O zf9O8Y)&e@)Ki7soxdBqN&V6Vg4)4kjnAcu>v4{&SkGz=;D(=v*LansBYHE`Hkz4V{ z2<*WI{oL6^n|>)UuN~3^R52&@#ku!}hK=##+m@8b+OSGu3gB)2+OOcRZb%1wXP5V> zoglju!Dfo)tw|RH{Pq29F5KY5{gMgp|5rbpA;Tg3pb$6-m?g#h46JzPYgtSd9Pii6=$oF2LuZ&}M@iV6Q9RpfPSru^Mp3Y5;Pc zIdKC+CZ0`o!De`$Kk39Po9?tzZk3Uw7k?aHhj$OJ!|WczoQqm~VAr^Wdn|U8cU=dE zmvH)8BIEAimF~gU`r}aHD=&ZT0%G|xaeY7-g}!oqX#vX?Gs(7DND{YV7ZJyb+eG83wpH3gp1UZ{C2fUctmgesc4XUF4}ntWUL zqk`Y$&M+dtJF&|X)`uwYWV&3c^rO&Avfj7>Uh=^;n^hjjN5p2x|CHOtb5g?}FS)@2 zh5d@)$2bY_q2jvFw5bm^Dv!1$DA!C@C5}0%>^21+0zIWmyW2WxbUeCE1yjPi#y+_h zPl2nSUDad31s^YJeGAxqET})wKmWBe{@30Mh#^E_NulE!#9Z`LObK@F z^K?u=C+X-49lmscj9!a}iu6R29#|pt5fC%-x|#MVrR+w(*EBb3$w0uo_q{LgLbaAD zu@wK-s8RlpKFFE2J-e}~TUk7IayK@MH$Qgvg4jH6ek=)mNne$vp*7T=(^aprhMUcX z;6^we_8+uM{@QKSbBB~h!jQD4m292n468ISq#W~~6Q-#t!L0kns>cT-t7WnH2lIJE z(7>?2wer>adZLrckh1L={!a$bwqF#u{+KGlK^;urvIOQlS)U)TZglcYLt(gEKxw2Kp|!etNi@&HZ^KsV{@gz_4@@t>nf-F z_wj)6BWVGz~>OYzkqT^(uty^Oqi04u~3GGUE@7(GX%x=IA3bV{% z!ByWrfFQ`-U7M>yOP0*INK4eIf7b*v2NeRi%>kYf^+&B@vM-6%%=5|;Fdoo3*bG&A zOd3L0%z)m?#kXXKJfC;7UkLsCR=QiF`meJ!}7#|y<7c~ zZ~AVt&^P_DuZ^bv?swr>QsWN+KlFAy99OV+a=#D?y*AhgUdZFz7R{^FDckog`vC(m zo0Mr`IR%jG?+B1107UR6(yo%Ffy1+8+M5E|pMg(tT3U%C=B89d zIYojX5wK|UNi6mrQGV?la%3)2VlFSa#Jel^&gj=r=%g8K@e{N{@8%MMvy+4x@Nk`M zavb;a^p$YRmCSq!&Ub+F+b{g?9Y_c18{f|Z?O%EgQVqG~)QWY&_XhoNsjo6U81iD! zn}`R%*Mj~@05$e;nXjYf;CWyR6kLnYca{JHt0>{H-=@y_H z0o1W|vsU>~r^^(`HkaD005i$bwS`c>o2-E2Q3(mKO94(qsO-+>lAY8zP-d}$v|?n? zWd#sds@FOzsU_6++c9OSlS}QAU7EY}AjQ=>#)@@2;4tjI35oQM#nf(Ql!Ncs0zupX zj{dMqry5@z@Z$Uc%uL80G-*ZGkFtf;ZZ8+K&iQ_qul)8k7ng7I_7;G7(2x5lBc7`?UZ zId2v&!W7u4{m6GL(-3 zKAUROS+$#^q7Qh9-9}bi8(wKPNx(xQ$I&At2|T!C%nmTbq+$~7$6P|b0|=DmE)5U> z8ak^0W2f}#I%u*zAJ@W1VV1!OR=NBEj9>cP(4P-^mjg;&Pdo42G#YnWC2wi>Qf+H@ zyxj&)rOE0veU8jN`LA%Pbp>)Va2)b`b1u0Jt1A^epWHH7q?(kT> zuReEPJn*qoxC+W>gV~g)0lzA?gfS>3CKyAHc(_M=O?)I%PC z;kfuTP-H5;ppqMV3-p*mKB1K(8vZjSrSF<{7H z*a9K4j_^6=RlpYoQVMAgnK;%_3d6{Sn8{tpH7^Anx^j*kyWT1@^!%;(AYfuGc2Q3>SZ{n)qCizTXLsiIZ$=a<0vA+Bu5 zxBsOYk2Enr@j(Nh?iS>?1Tj#SDKUf!I1fA{kx=GZZV#uYN1l80r~+erm%v< zW0-^U!TGPKo>1?}2W!fj3LlYGBy#YCW#qw~TgWlvnPhKaZ3{q4H=mpmXdaqvP{I|c zsW}~$&?(3i<~jfN*(LbqD-+Kx|7rmi(aq3-?*dVdGEzM2T#H@EO;807q%IZ zZh$m;y^ka1pwjm)9Ys3@Z~@0-O5)nre)Et3Sjn>G!-e4M@;0lpSz6iGmb1{dsCo_?o4^glXfFrI-Q)j{! zLx^NCl@^4Z-oo3(#v7*r|8*%MW%CtsTAXT}eow|NF?wW{zX6fg2~KH^xbM78)PS*) z3b0B7t2YqaWav2ZRXRtCy^965h>6Q+I@M!-40xtFV1acLe)I<$g$GED-}u2s!NyHp zcn&|R=hf5$wUZ5S2-uS1HS2MPn|z|2$}Ez>rsO4XKVUEsrj9XWlLs9q`GdqQ0EaJm z7uq9`CO+abE9zL^$OZ0C zd{N2X{xFPoeFV{Y(4_|A9{48#_C=Nd-9)+_G9hb3FB#JgxHM5(A7c(d;J0|tx$d)= zr#GncBwuZ@3K!*?XUlM2hD4j8>?Y!$ zC++$t8)fVwN=K*xKw06no$R@_OyMH;&D7NgA0~EmJgRu)>K=eY6e<+2;9R%;p#)C+ z`$2QFzSz*QwOay)xqSmIvUA+qsbKc!QQqKG3HsZGf1=vexsk2I__GPFN(GfX)BXmS zR=~uv_UCGzC_-2RAX?=Ei%C-f9>F7f<^3hF6$i~c)9AblgZQFcgQcmF?Waffny^i* z%kH^Id6MN8)6gycO+2aahl1Pmp`CxPJno zu@iO&CLDcS+twPxLZBzO(XZ|`J?_qKK{lApka?M;_7$t%W(8oECAz)BhArZV>Qf9D z+6fhgj)Wz$50}f0dVw(cIL)KXQ{gHZ0$7F@jhond_2AQEz}+d26tr^$s2L-KyHZ9I z(BzKfgZ^*7_-`T3%PX}xAqL1gv$I|289aM%hNAL&qjD0D`M`@2!bN@nk!8=mLA!Tc z_9ghc1ofH$rWe7huJP~)8Kn!Gb+WKTbbS{hCH$?*;@xemSzG9X2dj@_6Ng|yLjCkl zUU_V4+&>wWo9`6zEnS@?B_RDB1ZX~Rn@AOm*RuQlH&8jXeq`Azi58wOH410Lzy_89Is$NuhcJ2`TJlcgu$sm41x$27yZ%7|b*XFNLA zVLL9UyTWlPDdqD@Vw!i?VRW-tZk7H6p$)}LYj0{ZEf4L73bp1ZcD3UuC>_l;SG;%@ zjCA_Dtm>xpSTJrjc{Z=BNXMmyzSnaDzY$fHE0Etv)SvuDSP@xZi2f4EL1qF=UWQB- zo{<%Beofb7Rw5@{qcKcn)ZL>>hH_Raz9kZ^m{HLNKM3R1XqN8dM7im7w z!TYe{Fiq@!`cEa`e$dh9YIOg^uNy0X@DTd+(B|b}_3eIIqWV61(`$|9hHSKZ$W9jS zwKWD`PU(t(FM6amUZ}g-xk!?SNh6NzWp+HJjb5vI1|MLn5Y>0tDvO(@S$wk3;BDEO z&(23%hCp65K{R6St&=TFivF=!(df85?~rVq&8S~PCsDv41ChFD`u@2})hi>K-qRr& zejUNQzFxdCQI4!Tx?Y+$5s%2W=C2r&(+;x`1$;4@-RuiF@*k>;u1PF7ncYDB;XLsu z=20NPu`f*h;HdbI!OWs_+@Twz1`*=2y~=)CYxFec1rr_|1Y!~G#D=hAS!;S^OP|jv zFmHynSoKTglfOz?k z2KEfq?lrrI!Wx@>JEUpfQL;C^N#3%dXvZbeVbj+5!Tw!Mz`eaIw?lT@eCx~l@6&1U zrDuIu@glOXy&IlB{Ftv9AQH}T$Gm{jbaR7rcosB~e$kz7bwVYZ&>%8$8M5^X02}8M zeUwwwz96e7dS|J>k$=^7Qnqlr7dwplUUgBx zIp+bM3MW+16rO2sjgqOkYdE`>aWkuv=?3+CytlpJ1`Gp?;!>^{p1nti*5)q_v)n6hFQgTiqhkYo}D7Z3UoB7upAXj=Dp?bBUEqNyRCTp zJDI(H$&TOVwDb9!9%-iP$17fZ`NE{G7j~TYY@_V?j+p{&IUU2%_Z>Nrix*_VnFpdm zL|hFIENqnXewO7AP%Y1(E8Y5@G+wr11T0rRnAv_6?~b>~|L&c<)PFlq_HC?7&71CZ zYo8!-x6E{rTD%4&&irf1YbqT3u=Oe@mx#)G*T#FR#W!?Fsds`o+Q+so_}ZvuZHHO9 z9-FhB$1N8Wztm^ruFjv3QHpYMh*R(F%NO4^^>tPIki z5G&5J+v}QF>94tE=C=if+^qOk^dXvkO5BF2SJII*FFy!e<__aOcwXx=ekXcI5=AoV z=@^MkNtqL#$r~rN6Tl^Q*Zo6 z2+96^G}8_1x6R)N*VbH_K~Jm5+#wM{2!7wArc-1oaNDG4m$S_Hb%kp?^vGipm+vkY z*Yi=}Y7U;G<>R>vz&63kdyrkk#cADuKGEKmWt@KimA#y;codKVq2D;#Oq$X(@IhK_ ztjttn3{p(8+fR{bxtjx2bR~m*wWY;Y-$(CVj7$peO1V~ z=_QwY)5EmtFD3P6U&(&JTUeJ%CBnWp^pN<|2ItJMcSb2rIiIgwW6f-1Cym~adPM3a zgpFD(d&}K6w1V5J=J-b(j<_K~gDeqEO3Kw}-Uf04&Vb-^tG(s@_haoRgXtehT_E|$ ziQL+`Wf#Vl6lRqK(srW<3|zSb&sgv)z_EZ%+l~LjLy6D@B3Mhj5&lpcq(U$Oew)xWLOw>u3i&B8Q9YEEps2fAWiSO z+`t(S*wJ}c>#%te$-NsZRH~ zh>F7LIKgV`fe95tyx=kWIR%$3iH8`HnXv2-b6xJ7a(3NR-*sw+HWW!o%G2cS{n1$9 zWOU`o-6Hut6gc;@!MD=0gL}xjRIO|#TU6)=T}v$lOv>yBs*E16o7(g^ZOoK4gIZ=* zm>PHFfe7_SFtHcKck-TflSR*%$ACVdPK=se-}9VsMwLdZW zw1yEc#a!?KSyr{x4V%@c-D$;qbCtkhd%g!_F!Vkyn|*B3<)E8&zuWW&FNyE2IVSXD zl1d+}jHiMS^xNMQC0$jL+ZIX06Q}z1W1@r4gc|r623|<8Cgfg;8Vsd+0UVyjb8J*I zyR*}9gMn1H+J)|XA zm-e7nZe{8K_`JQ%?5ei6>2=+t3)^x@|Bt<|j*4<^`xOKv6+uBj6huU&6r`oZrn^G~ zkJ?K%Ju6JK-R{gEGZl=25 zwJ3^Y-lu(p$@ICQD)IEB=Ec~Vy5(Iv=IS01O$oCozf~^2wl@156~mV*eKDR#ER0kQ z<|+_bC+e+Tz+b-4GFSI{y$|0bEw#3}xvgaS9*bA)p)#|67qqaPR;aLu>ZnAeqRIUZ zQZ-c3#OeHgYojCiM2W&LQDwe_FS5E`!ebuEsNK`@S$%tx$I5~WDk&=lCF4+Gb}3r* zlLkiqW@oG2+TE9(`;1qdE_JmE)I2$m7^OpJTDS=B=082@&bD%3(WrT{JI^V)U_DGK z!z?`{a`LtTW|5hLT+RFdxf+VFcVqQi-6J)Ybd-;1jV?<%3pY!;N2h!S){W$q13pi} zpI+~}IK9I#Pa^sWzeX|4f-DYJ+kQy*z&s2T zrH=BTcR65IWT3k7R&u+6E;|2=KdD}Ca#rstrp6PhD@~ft(jgqv&svgN67k}>e3Hrn zYrkS-P2OW?56?e4b^4xXbE1<()5b{(HNMDZ`(dg)dcAYj^kO3%G8B|7i_fduvAotS zEmRCCW(*WDr`|PXh;$jKN?oKV1cm`2?l%qL4bzxum0iU_&K;2P9au7bnp7DHWFFo3 zj49YqjW!yg&RxZBdFpIhCiLZ_Zs_-9YleQRqH-pE*fHhQa}?2hCom@)+hXDWP@7+a zF)Su1vlGczdLTA@E_+s-ny;#vzIhb$S$n&TZo5XFN(Gy8SyDio7W=YL40Fc1ln+9x z?I!PMxyJjv&LB|NN{C+rBR(sLOIclCuxyPLUyc=F7n=UK7FS=}JCY z*H}lpE04!}SWs7cPSLtHq3Z(;_-M~}OaQ@?$}CWfUj!LO?f{IpX}_w1q!uY zRvV$wG%Iuk!7`JRb8TKnWQUy;EZV4<{4&kuGt62V69n$G->O+UPk(VZ>m({shQjBo zC0gH)Eh;r3YtBKlb6EE6PKq#xL=}pLuQg`(#KfNTu=w1sebyBA;(@U%wP@cdy=eNQ zroFGSj`8K6YIC^uqV{1ia6rg}hoS=mZqnytXY%MT8e_#@p=40kVw!J=Eh?Ej#b&uZ zvUE+Q$2aFR-N#rsLX{;Pp|;&Q_2osu18zE((oq$!oBbAm2S9}TB1dB2gg|Oz`Yy4T zDkgH~F|>uo=I(~+EHhe_7spWnSyrxf%l8jt2Q{`ioJX&35a`b2n_-ehPH|3ar1l{= zHnOt$taB#D@iRlpCaBRNh1tQH;uw+c+n1M9?z99fma}f!bMsTYP6AD`gDGzvkC?Gp zu!M)LJh$TM+n<|ty=yomapM4agg#e~e45uFWy+x4d}XIdnHl-5wNSkbSBDY{k$oxW zWlwX`q#PktS@SGyH1t(ndHKxqzLCO61gp7ZMz{={)XGsTxukt~`G6I*;_$-?VRp69 zOpJl5NxJdXM$MC%Y6feb$ry(zM1(#cdd6=V9_hI}V#@v8Y*siE6Dr0w57WaIuVHhoy0qkk z2KJ0rmt7m}7PMX<9ITBR6;;>pbK@tl*p#|lQTteN>=E_z?du*&A`7zi&o5w)(4KEw z7d1nyyIBwa*mt2bYh^zjxWb|#yS;i$fkI9FU{2}qJhZZh6so zJ(O&F&!&+xYTCqb&hp)P76M9%ZFmHf#J42UylnRbU$L3sNnjWX;5b1mGfr^rXe62v zTaoyM^wayz-=lxZY&&>n^As^*O7k0MNYp$8xTsQ=J#**eKN}w_Gg~%W&<>Vt<754w z{fz}*OvOt7P3+Q+y1ij9=p&l*p07Ct*)tk`tk z;?x;r4<)wwcyGF7cyvTVTz+UW8=Hh<8Ej>?4V@a6@J zI;Oh8{n!BGbd^G_^tyvhg;~zpjylyrgA}b(8c+PvUz%iIbKOW}XXm+!Sv-DpiNHvK zl_A!ipv+8ION>!kafLuSkC@4O8$K~p@a|1fWT|34O@g8>m#q2BsVG9{yR*$SAtIRK zdVtl1&D%}xb94CArdg+dVaPR}N|_R(mho;uFQeTi-9l9Gs;WuSI-6kNyTmpx59|;@ zGV4x5qMe|p@5=n8rvuM#{Ujyj=1(%c?d>#H5U8clG~jv)oiqx9DCVX><+ppE-o8VBB3?1n$w7^{>rY!Xpvb#u~=Q2d~jZTp4Hx9K@)qmJ^bvGhH7A8n-I zI8E}mXc2F2FdhN^Aw)|O8tscLB*#`1Fq3^ER2K1}BaP>}fwncv$G3m2C4k&ACSHu;OdL=fStaSrs!DPL~I1u9Nb8Cu6nROrn!(3$Jj zK2fjRJuLTLONrwN7CR_KWK8?i0FxH4XuT(AH$&ieMIyn(v?+5E0c8c%F;wmkecQ1_AHF$Ue;1?e|qRxs2H$W7U}N5=1n~p|{kL83l*R zlAcG^Tr*PO=i4Q>lUp#qZTw+(!`_&L#=U*cKa|$PO{MkfwatiD0z>F|c8!+H;{@iX zbqeUQ6;B?JHlw~PtrT0^FJ|ABmjCgZ&wTJJJ5$TdKIcIPiZ0r5oPMsFe186cWE&$` zrT;;h_PSc@EkTNJq;QNW)|R*o*rGfcFDtIFN|*kSSdvZgp{}@cdIyTSLb2wPsG>-i z?amI(goup5>(?f}OYA4-n>b9_SIPu~2EQUQ#sK9j(l=C1Z);cHDibw0#hgT(Utsi~Kv{966W<4QQKWckV>{ zYd0e*3WI+w2=xFYj8Hp%sP$GyK^M$)b7p+PsogAhQ*t=g;@mOim2xH>k``|XIWpKE zM$iA|UAa-W-z4C;0_{f=xtIXIyOK;Qa8n#_KEf$d?qHjJY{tMgR}yhZgi4PSTtBfN zy$4(Iyo~g7?DuOPBWc^O^zDg+ErFrb|81K%in`Xe=AAC6 zB88njLJ1L5fu?a2x7t5EJ~bq{4Fs)_PvUo1z(6qu*!Yeu@&kis0q{;-J}pKS&TrkV zgY@#4cA-&MB7zu2K8Ev|zcQ(N#^W+$l&F%Z^hL~*KUQ^vMO_EBz~zrJ)1Wq0+FTVn;GsuE&5UQGl^X?4qu7!C|~6 zA*}~@+G6U7`Z0zKK@jm5+&07WcOeU0`w| z0P7ya_q%oXK8LqaN$+mwY?;m<;Remz4EuYMcjR89m{RnOA zMV^J)(Il7KFT{;sz=jC=Cme##L3(!mW%qpp)fJr#10RQpPR(wYAx0r!AR|p*g#`6O zK)`6JrD!m-McSPCva(7g<}*zH@<6#jiD%btR9~n?{z|M>xoC-JH)Bp;yp{7xoK?9< zu4fNjPG8=MH5PA)v)<_z5zD@$UmRGL4d7<%V^#g>ILkc4Ad2b$EQ(`|_t7*UUP zY~SxStZcsLhBpHv&hdm)5F^P$b)FaQl%VM6p1)nv6x)gheHr`^VA z3EK_oUz~%c+0a{Dcq!VUT)IXuNUR~_iqs_E23rw+lazC1bDhXbtJNk7>z*cAcsD8X z(s7k$r^&`oK3Rb@!+Sh7y9^*vr}~nl<4`)4r7v3+3l`-dX}W<7OEXrPa7NO&U{4sd~eKwGa}g~ z-TP2|?AF&s;;oQ(J^OO{L~M`BOLX0^)8MsJZjw9yREPwhc>f;oV6=(;&N*MdvE>bf z5QcE+9*6u*n)_mxlioh9QcsVz5|2WQF0?8sDd{UnN6$bd_mZC8vjN^l#x1YsdmQxL z?0X+XY?LYvd2pv}g>)Q;PQC&Sx8B&=_mBNsjV-S+RMHRVPDMjLyj%>cl{_qeXIK>mfxQ#SBH>v^bZYH!_&tCZF2)m!wWm@lBtreC;DZ2%tqha)dp0WEx2j? zOLW720M?N}kY7uHEFFNcK_S4NSoPxE-F~3JlpDRborwJ6lCx+!QGvx5xV?yK>!w5f zQ_1GT|R(AV4#lmfH9 zsvhg6l9E2d58oh^)c5H5J~q&e5dCJ#DJ7Vwk-Pf@$KB;1LGG=v@~u+duq()TO~^0w**lCm(yA^+0&D~pqh&yw4pOYJ}uA>5#J zn`6Brjq!K6b{9E}C<%SJLCq2M!0K;bcSUtsG1 zS*<=ZGh;N`oXT_U(>^+w0Q^3yelHHOl{kV})8o`Qc9lk>&@pv*gPz~wC`@roZ%S5| z=K^%xNK@jY`Y^0ZIySYNELQ{Vbm6%yoa7v z7AW#i7Jzr9llR)EwFM*B;NAWfna;gAk4TJbeYW8rhv&-M`BQ4|NB+T`>gdsJe;_tnNNs4LN?4S$cm!&mHgDesUV{1>$IBP6xg@@>HF$<$IM%GiMy-t z5MR>|N7~8YQZIN_50X@qKR%KdI|fi?%?En4Tsp&?Eh3ifrx5q^&Y1MT`~DA)jK99S zooUgQdFWe#$cJc>GNx-IGzn20Wg?zh*otYqPhSU=MKs?H zH4*neMUDy&M@l#2aBy@qH&judfGk&oMCiU!?~1Rxh>7)un?wY%P~(CjNwjy+D(eDAky*G6U%BmTJwPT!iE0^jynpbR27CM!d{V z;CjI=7@S`PK7x;k@PbeHu*`2qJc?g;A`k4?4B#TGfF}M)#yI?OaY>$a4;KUu^3MB3 zI%#4KbRH-;LjCDQ(+d+*iRjl_n!0-X9-FRPVzN@U?V&sqW8X{LV9pJ3AV~c zm-vP}*sF=BxbmGkKw`Ju4xqoT&qqQyweRSr2IPW(6`Tg@2&FWg0>c#-79DUm^DB6j zous;qE(_}nJ+~_7dlpv2V8-^S{fiK|=FeV#nu}5p38NWuC+<%}Zsltnx6WpYnE5A$ z#7va>5Wke%9VsM8j9FW*1KVT# z;UQ}Suo_rIKC>4-F3}|@Y=f6?VaB%2ueX;~L15=jEeWo$c9JRy8#>|}5I23CkidN) z&Dz#aUSF-ejCkfy!|#^C2&1r7wNWC*K>W|+wqT)&c1V;7KXoa9f`MkL@XO3)Q1Fk{ zf)`%ni?4rsDX`)JdafZx(yz6v=sZ)}HWYQJzko7jjH8mo*I+~ZCfSoz-4_+T+b`|D z^A;QqR=nRz_OT{gUc}MjH|eG8$01J60H5XZ$58HQrJMgy9hTYh6dIsmA5HCZ%;$!C zDIOqMiuryb};aj0qe1r<#Xp-I-LAy&$K`h(%W>mkj!sln&sk<|$@3Vnz zBC#Ok2aTPAaadiR!<0++T`9!xL$}rAT4Aqs_mPI!y8)2eTCT_? zGIqQus6U8$#LzcZsjDR7nb5$7Py7y4$(?w^^#f%|1yMO6dvf?L3vU&B@D36jD#HTt z3oCF3XLo9kC5jyF}-LAG52cwR(0FheEq%01m9cThBt&14RD9XWI(&}+LBRJx*h^Q=dL3|3 z+m1Z4R&EMw8r1pcAN?FPN?0KQJEO_(j45JU=l zkyv~Oj?JebJROm zmKStTUqgZ-3-_x)tC%`1n57^;01ZA*-tH1V?MhpEQ^VVpc)EAl*}N(6em?U=IGP0B zxtliy)xBXJe}*O*d2_8dHzA6g$(|H+JrWYo;3%JYv-*IuB@KOEuORox1JlnSr}*$R zNEGyrx2={?h(`-4!NE3&h{-1Gv)`9ObVv zr@X+bHFJ^+`AcI50eJPD}8ze2F4sg zV&A-b8C)xc);(ecFzqT<1zlY#|1_n zosF(9GO0U&`1q$FgNk^q$b^&rfdS*b20;^V!i0!zyX4+*2ey-Z+eSDylMvy(^WOH* zbK`<1F6tb@XWo>fTR4s~gy7WNX-NDbffyB|12RMmtkn)+j0EJnbUYzHJ_A4L~CS%i7jLaDj*ONv(Ef^}!<%!j4YXL4y~zIwMV_y#eo5S%w- zMtQ>_nw;cR^aOq<7oQ~0ps~&Z<>qvK?t@;duNUu2%-LVw@F8Y^_^L}`M;v}rP@;}i z-b*Vxw^eET#U?q^x$9658UY!MeW^P08j8;hOP&Ph#zjuDZN;)ahw+8CkKJMkntGO) z#GGJ7ujVk`t_t}l4(pPoID{(74(7I0LuHRse09oik)!#ru4|o9s(xw7u)QBL2B%!= zpxfBVrAqO2X7iS``7;jyK6M#XWHcN{bE5_9b*a+A5*G+!IOFYhY_*_(7pTV6aEwL3ZM6Ibz^k}u}WbDQ-u zq7ciil_a0LOXCvFvV310@vx^HAjp^xISIk(WU=Nn$u6S{a>NP(LMK0!bRN#>BQqZs ziM2W-{;s5pDW{KtlM%AI`@tm;JR0kK*C1z)b8fg&Q|{rB9b0aZ4B=c@62g9kOcm?4 zXaoy?)?q}ALUX<*lHdN#sN6FNfBj7QcnY^M&5IS*jkQ-hlr zU>cca6TF@uI3rqA($xupdaUBh^GXdfeX~P#i((ND3rV$^Ce?7ej^tIAo^p~Oe|(Ffqb%_ANo%;ouE*x-q zwJ5GTp&tDb?Y?AJ03){s0KrPLcIdfIf!!yKnyGxFQHLg}-2N_c>yBK@;-QoBFy2c0 zSrLSe{sOUe{(1#=vG-tfpqtnnqWSXg>)UC=v5!x4*K{e1#MW1La{h_CTih0bgY+$csZ;7&m=5)}jfVL1*3(0gF~95vPf{ zi455Y{ufz&sDu9<$r8km;J17T;3YNas=ERuo18@%RSIECE7u@MS?Z{z>v2L^B@}6P zeX+qKlDP(VDHGHf{sZERRbEQFm=796TgjW8PKO8&O!b#Fd2#vUs!9DGadX4+Rj3SPY!9~^@e!}%vM#_m03M=eL0Fd`daKq+pmu>&YEih28I1-1k`{0Pf z@bGlk7kzdd2!>8O9fuLQDC6c7P2S0{WcvPvk;Y1&!d@y%;^EMyyiU8jpTha7-L74O zl=uLQ&)eg1Z4iNbiTdS%1+s%i;Z{LLj~f>y#Kq%e!VZBhe+EgAHf_Bk2=E(3^xT)GG8D%rZlwZ4&bk!siNlY^ z=R(v9(r9iB6z`jv+8nbdc{(QW4r$qZxUm$y@c_xz9RM9E1cTbPdkX02nGyn${y5Sp z8woxT;sUwAbv6DRBZ(NiyJB8BhVSpZkd`tRMany|qKxBGhfY9@GD(+=4A-OeHH}HC zQ^E`-r|FmKiLHOTIyoy9dUCQUQTgITc*oIHi7+1HA|{y)Z8vDq?jE}Qs_x3$02g+? z;n22a@EZ@hWH3{*$CM?BvFUAiwZO~Ut?J|u)qczO0Mb?y|YJG7ERAho>!SReYKVzg-G066Q00(+B0)#Q+2+J{=S zn+XN*_93Bg|a6q`(~0GIA)>P8LLqUch)(Y#cg41cj_^I0u>2A z4AL;Va#Iv>(Pbj|Wr=p~*SMACwc%O6IDZf|%yFHcz>pAnLR9(YvId}qCK!^}7Z)eG zic3pXQnmONZ1jdQws{TJd@17|NS$5mQ^If9k4Mk|ci{5fiHn!bqN3bYc zK2O-*Z#^4p`a&bG9vMx4eczC#TUh>R^kF)E+(dhhGz3OW;3&c;79FjF$%*W=fh zC6$z-M%Caqy!OQL_RrfASwK$GYbbW74B@wx*NOYbDJ9+@;qzHeTLxduJAS$jDMgce z;nY8HRRIL2PFRA={My?BBJ%FpkNj4h!!-svkU?IIJiF$*a)Vl~>NCbRT!zdYhzh!S zQiZ#h@9B0qRA_WTVVG$ixQeH{8+&y_?=-XO!EL)ZDY`B{K{`BigJ_!GX!zdX*^bqv z*;okUUSz35SQ9yXqPvb6P_e2e`>{howPH;eszVXYfnqo3A>5o>-%#lH@EpwbUSB&W zuy&IX@um@@Ph17X*W*xMTvRjoJQOf$U)~0GrF3?1;alk7#L|lGmPZ6}Oe}lIsk5Z1+aA=&>h-{_3mi*(y)*x{fPK8vWH-{TOFLo%ZK6t0MYcZH z0#ce*Rbs?_ttOmxZDnz>BtvZa9kub&%s{5oq@MMq^B&D+puEpXHG@Ac4@4u&M!K)L z3GnGN%wrT^8=iySj}v_`nI#xP_h125-nW( z$evFALD!yvpTHq5$qM1d?u0Q;-S*DZ^T}RpUr!RLE!iDmk%`E?TTbUXJ7ri`7DQ6k1!vc0G)oo4wBQ5u$mM~(f3@6 zg?O&>YujJ>#h<_W%`PPVOu`Yi(GWuG(zqiO|GWPZtSUg$oQ*DC25tzK(ok;w?*EN% zg*KFNSFqwd9?K4idd29m?qcnk*;!*KhuZ&TM_F=B?)(j#yhFFiS~dS|}qx2W^yo3mAFA@JSA0LkFGlq4Kl*%uL)2fb=t z>^e`ifQHhpX-irZ;@-T-(rm-|eISm;PVP9r&!RJ55y64zyHERPLmu-=Is|Q@R$b`Z zwR(UNu`BOug*xg_a=3NMB$%xIH{}{TCCr zjE(=wH^MEY@bj{n@{wJw48V0U-+*uS3MLWjT~EqVL?!mX;Uo1WNKZ71C_~(1G|^eW zvE8CRD(&ZQ+}c=iq%@1gk*L?85wz*`Mi4NY0gp9OoJ~|>O@wU2&oR$HRs&9m${%k5 zjx~VpVYO2g{ItdBfGZtp z!-<(;O0LcWkD|X30e`F8x=JTH>%qaj!2~KdAMGiOTUG(Wuc^U~~LUAaUZKZHBoF-apg-?W(=V zB)tSs4&9Sba?EpfME@fjzj=C7Qb-N#IF1_FIUi`IEj$3N_%hLAx=HHkTu8lUb=5r# zA`u-RE~2TEQU6UgL8p2m(C;TWns+je#?+}X@|it$fPS!y7GGcdFz9PS$fcAD?2PFI zZDIgBRcs?(y5*JrBKic{hzb6N!vue0$}p~`S%MnuD~Jb>jPb!*iBMcC3q!(McG=&P zv%P-aVp8+>cx!{x%maDLq%)d76nPPLpB*z`>Z2spb4QjZQ!11wy{P2NVFUuTIt>I)o?N<(*ijQAZF;n>?_!&E>2`qqk=q>#OTj&tudpt20p z=_Mh(t$5HiUK*ETl0g&`1_ylXZ@u{QD@Z#VDO|E4cif#qAJb-aMzMn?RvgFo{Xryhw>lPeBg)HzeG^1Shy_#{?0JE*e zk@`+Mjzasd4}t(vaRFm-@9M=v&^tm`}9h7jrlN@T}ie3AjdiL^xcde{B5L+y7*{aeo3-rWJ5N z|JI#`OTjXEOHe_;d{~gEkXVi4vxUI(1k1Y(2XW}EpB@RykMuaok3V1gzpwMZ0`Q|} z@24I7d4K;EfFBS^+}HVE0r=50`>PuKOPv2hIQxfB{A&UJwE%x>qkk>Hj~3)VxCQ^Z zKtH-`{&j)2H0r-D5N>1rLxTQ|0sarh0Qm|{#vdQwcIzM{B(&`rlEoePY`Nn;W2E`P zlE&&si?gUx;)VvQ>$&zo%PF^%{0}G*PA9?J8lTHZp`@#57b7tLd52qtA|EqDzx+6j zTM2ys770f}ovN5j_Q&0ABx;SbGXvLY?}Qs*LZ8Pg|EE>H5x}G6Ioe~@eKQCMDI{d` zq>+#Dbtw+~plrYyTrd?Pi)`C_d2);(dBkPOE}QI}YzDUabK|j}J?y_crLC3sMtGCa zJ+>B@l&U017K@4Nh($gYK9OK|`agXvSQi$VFh&~dcV_kVFjW8QdpWY6E> z&hM|LWZ(Fcf6R+me16qe^`JuIcmwh=Stk{t1Hb?A-;Y_928&d2^luOWo+bqTHGW1B zPJ(C7;m+Tt`}bGhj`W@(8tAB5`Fyf0X;;FC;|TJxIFY3N_kaK6zcSr zkxz7=&(+Fh81e}R{NMrze-_Gpp-`a-_)y42qj1&a{#tPf3Eb)>$5}B4nGEc9Gqf%m z`PfL%@yads2Ha2Ewig!sF-^hMgiS-Xt~&Ik?ol&eRsDcxa$8^@+{^AEfLrZe=yFgP z4jhlKi%a(?)`B1zdbu7zG4zOT?c*SE7$aZ==|BHU?7r36=nW2c#x-d`$HYQyqTYJi zErj5i%OIU*o|W$FEcFBhNvq=dX2Z!@qCelb0uJOT8zB2hWRR`1`Ge5v@?dFpXocqt zGOm#WP?Q;yP&)pIH0gX3UZ$mRzDZ6;wMJ8Faw!IBmN%)5G&HHJu70Fj=(yJ{S^(D> ze*d>BV?+W&D}bJA+sgsu&QPTg_1?b&j2mL4!i9?f4d8B$f$|IwG{XF+bxV9h_DV;I zO@rdi^#bHj2$J!>y*!fmyh#sQ}89^ASd)PS0M4qX9XDRojm%gN!K18>*WtbK6=O`~V8Z zFU&xeQ;R{=SJnJ+iR0OfaAEHM%9qGye7uL*wyJpLSnnw!9|JxLj-7v!*)zwH8$~5r zX148o^`;gQ)mVch?pc#JfXFV)yz-W)4UI}3Ysv@XPx~aIbCek9HPhsU#>cPl$Qg10~%IV1VCs^#Ce35w1z6$MPTmJ`kLc?P5#1 zzP?tj1)Y=rTttuON-EM)Fa!1H4}d)|0;CBb9Jrz1X18vTrdO_Yj~*0WXu3GQOeO-+ zsJRyaH3sxImP1zoR1p#-;&d6>HDjS(*pOqcKGKnwj$n65>XaLT0JQP}n)DAL#E>Q_ ziwiIn;1V=?AYiKQ0zim4V3U8w0k|)h&NZYz8SsPru=&be+cM#^J zTRniWGxAiJ@B`78GXV9eLMLSR3hMMC%X`jww%FK0nb8g=F3~J$GhuBr*Qk zLj-&=Gf+*FO8}!7qP)!jYsRa_H`g?kbxIo;5Y7QDt^LWX0=l*8QZ3HR3ox2p z!(zek)fxbt4*~j3KL6enAUN5gVO{-Ghs1XQgoaalzqM&Q(jW@d4@&*c9?x5xkPcC3 z5od4jb`XK)lH#y1w1f3n2!{sX1uYshvtN6h)6<9HK|G$T->Rw&#re*PAt-);s=Z%` zT{#bz`E((O$6oXB;qFr!j4%o%?}qImbzh8OWD%|^{HB*@qkw3!Sf4|GsrYF>Rb3j# zsopZRq>FDcu>QMZ%Dj%!{Ie>4EdWS;&p-XlJjur(Q#RTq>AB8WTv;Ml9fNft=$zth ze2zUJsGRip?X7d3^K?0{>tba0h?#j{AC@eyHaiHQT818DQ(9VOqb-vqC)M>m_7+yV zr4BWj3}BVe2uc*;{eW>H9`$r-w{y?(`crpB)UIPpzgJV=nXCf6htWZot62h-;h ziFgEdrZ&WC+fQh5?LmF^RgJPttj>=sgeq69b{v0hS7(9!01WpD^5}3Ag+>cHo>$Hp zNz7W%?@)AtQ%|ZZzIxRWPzOQ6*rS@+*B`jg)nDK-Z|0b!do|sz17lrgfIon?0|j`A zYS3dXhLfWVb*E?V>9~%>zF!#OJv)gWO*5RlP&y4LUe~&fd!F)MrNCfOWb&+{Bt2a{ zRg0BVJC`Fa?#b>6fDz*JGf)tv5t4r~ zb#gfdI{Tfg7ht^9wBCtipMF5;hEKI;r-DumOMb1LnW0gs9=d>WyW8!cmMCVfUdhoN zSG^y;qvgFLD_VfU0<~b9Df@O>UTo>rMcqJX;1m1S>t|Rqx0H$Wls{Nx2f*jD$sLM6 zd9h47h%LrkNE7|2>SQdNZ91FsD0WegHQ7_}+1JOfKgRZJJuLnk@A6^w$sso|4%kc77qb z13>BWiO@XI&p8=idl8d+J48$jS`!e1qyB(@`6~eJ9|-igXSJ>@S3r653*o4((*gHc z&^z~5A~h-a=Yx7PQ#xi9WGs?kOHI}JKAU#xGh+2I*X$;O(`B< z6%b9YeP{f~+#_)*5|>E7lmI`c-VX7k@78MQey83MvYH>}$)(?i<#=;8f@>*RI?wD4 zP2tF_K+ci%shi?^+G6XJb)lgCU~$f$i*XgEKkaqd0QBwRvvm6Ojp6O6=2)T|^&Nn? zI2&!9^7>)Xz zO9BE<{xV%(y4enh2M1@u!>w18-^vxGo8(bp<*F|n@FDkXSzK=0>#H}f+w3o9EzGO3 zMRQC+(=js?bng{CnKF4XOPzA$@KqoR_{%!{{EAX|(+T}d@?91o-mEuJ4NdeIetz?- zS{*b~pOZ>`n2U5WXVr-@EN0vnU(eUDaYO8M&-y}Mex-SDl622`eqB3_=ry`x)kLLp zo&Zf8&MWGfcZs3De4an*c_J@gpCvLLO+cH+wFgFt9f$Evfu6}ziyt1bReydiMW$c5 zaxN5W30k^Hmj?!p7kmj|AYEi7I|vqf;f7dy8MN!h0%4SGs8zFOjBqlaqc*hE?%A{F zUCFrri)9_m*@)bFx(a&X$_^P{RaEm(M;s7K#vOle`}4E@6m?5DBSEuH!{09RIX_j3 zlk=J{Uq^_IDs4x%w#B`!95Ii=gbxe7rhfq}Oi|~;K_|!n^hLS9xQfh!CNZG+f$Vlc z3i_9M8;cw3(Rt)EP)UE;6N2Dv0GIxve38n(g8nl0Q){c%=deOyKLN)^6F;--5uu^9 zg~_d>KvyJU<(*hP0@PbYQ{RlruvBLxHIGiM&=)YWjlkmtv2Sl|1SotWst~H7v`SqcqAH)U!Omx_7#xb#q%vkF&pCh6Qh}{{vfeHoxl#K{6;(gf6R6G%uhS>5cC6nC@=!%r0$B1-}$JFww1f z#dJl&j|95oq{*B{n`^YO{QNhd883=Muc&o&7D$nfHM>JBq6EZ1*5^li%lz(+r>mug zLqE!{_aTD7i;$BIjajYJ%K==*Dbn;Eom7O$%d4#quieI?u1koauub`)kB@4A5!5fH zD_)lAf+m{^%ZQ1H5u#+^@ehWk`8X84FD5%*aNnOEGIsMJr?wt4++e zuH##SA&xZ2F;a0BGKk(ig?`Q@o?Is!C{R5QoO2<7f8BKG1iBoQ9e4|-D;NeKG@I=< z2aw)kQSI<{#8ag^Vc<=^U+{AThQ1hlh5m=q2*1~BYw66Pk_tCDi`P3F+*uSelIm=` zAV#`f9Ltb02upRzOHZrAy@saTm}5jODjHmdk+POw;;Wir5bRC}3n~# z1X+Fzacdt!l)Du{u32Y`T00Zc{~iN>I&H0Lv0OZBU*b#PuDVp?Nq}I zfNy-uo^``TI#hO|ySPxmPtycn0J>Y}gz}e%SnCB%s~vnXDpGx|cMod>?)~zE$@HHV zCfIG$9SJ1;dF@GA``Fb|jxK+y0spd*p24i*0JTdow9wYGTJ%}c8=5_(vu<2xVK$xf zgaIVd%fQ5!QZ*(UHS!I7*4so5XlQY@? z*gJtX(7;h-qa1xnt6>3rSy$=F3XuuuLwgL}jAGqf^(V6AqJ+VQ~@AI%llYLArVlPm4G-> zsrlk4mp+(BDdV|1y^-bAW9z6gzwxbyvVqCx20cEW;Zdd7OOsu@8kQ8#Dx9U3+;i-p zqJsEw(&g#92Q7{&;weffBr%;6CVmm)%T!F*aFIpy)%rw%b4y`FS43A^Td{HUtn&@G z$xo4spHA12Y!unm%4lN036w6?2ts>W#DbGaM!4@F=)97V_5uBd%qmlt{>$2=l`tqH zKuvykO72HPHavU+VoD|n?`^-l=;u(_MW<4GzcY4#AX)cgN6FXu6A6ULHGY_M*p%s< zbdOZbPK0r>uSr?na$aMpHv7gH*d6tfy@P?1i;-&^U_1Pm0#;G91H|{ zh@7TZZ6>ZaM|X%#`NVm2(WS)2EJ3=!6zw=79sWGrx4wbAw_!ab+SIfcX#yLV_&RAu zlsqzNM>-_3T-&U6@SPma$D~X!^>M9=fw6+`Ye>|zy;b^5M9Iy{%w`P`L&r%J4W>xf ztSSXMT;4CfOv~$bm1blg|JF6>Nn5$!{*zMH`NnBT2E@Xso!HzFUQX)B_B@lq(X_&^ z-rILxcf>+61A`rFy2iHGa}JAP2Mz#Ah&^1ZrDRK^-6jeQzqM%{6#>!>XP6!Pg7a>6 ziXEvcba_w5xyEuQjzbUQW%0Yw@VEz{zYd{5;xF`Eu8)_G{%Y}Mk6~U;EM3un^uD~) z>ETe02%X!Agl3`aBF<8h-wzc2C8mr3%j}oLW>;oLF{ifmoO~1!$jHR$SZ`8s-LE+h%S zq?(J91e`sH|NRa`Nv9+kGYOMfFPw%rDfIw*&k`oxlMi9=oY9r-X%x{{4$NM)S>lI2 zWUgZ*Ex{W z$6nIO(Ar+Cy30>11MQH3w)s;2HRWB1G&UqaMazt@MoWg52L>4XeH+!^(2Bgh44jEi zQwonQi6wv@lN&~X*=GO5p^&y>JiWS(ZY`qwH& z7{uaO1Jm=r-nqsnE&lM5%Qjt8HJk9Vh>(lx=^M-Gajd9D5p!kl)q*+?t3=}v>6W7& zKt^tQ-fF42&4fMuNr}Gm7yp`W`HmUq<*0SN_!YS<^~Q~x7PthTlIgPs{(P_`=25;u z4v}l}0iqlO)Ci2Ez$Oq<8j0i*uUnhgf-@E3{8sKPBrPbM~ekcah9%t zB~jI(67Y{2S&KxPFA%L8JIJ`cTDHqmf!smc{AoyoZ1rqzPOQ_rxW$y*9Cu5t5N9AM zu}EIxSwvEM?-$>T!4&Ylb&$QgzFN1jD*x&;FXwh0(@~3WPgyl&7iS@FUb630{9;LN z4)mF@?}R*#;Al$LohyS7a9xG0-ASku#fHCXOEPUX`z}7J>!O@P?$F9bW1<%C)RCJD zhC=!E{a@|nmcpIqM$D$@F)}stm^|zi1$7pURG+jD?qo3i^&X;G8{C8&?J|JvFbJcj z&Dk)M0Su2WwsF_pdSb(uj+ni=!gDdp3@A|;KE%pLi@kRWwaTtP|6u)<)8kye60`o@ z;Z}W!4>-37oDlk)T(1%1adEOj)=NoHbJRl^x>dgN)IcrnScwS#{CiN4hCn`IWq-}S z@$Jf)f7tk~HKW)z*ZiDxYt4Ck-8rF}Mnuqs|BFlTi%pS`Ml`2Zw>{?u4IJiV6LOf% zZzOEVa7rcOU-;}KZjvIx_*5n?C;DQNoFw;z(sbFpr7-W6YcC-zGwa|F`sF8n*i=MhdELKiFI;Gb73_ir8G>VAm*i_iw(= zk6%TrA-6MaZ&e=JfKR_p1_K93rpAj(J^$xtN+N-0Qss*zr=>}`CQ)5@Y16EB7pEQg zRO-${(xZQs|O>YT8JUSJ^Nb;L71LyOMm7&5AXVRY! z-y0C}JtvtoYFc=e`TT^bBmR81@bLG-pqjllJo~9OuhwnJsJsN%KVC&V7#QbX>HYoY zl4E5o=>L4viB%|rB~{;iu(_BKj>kE9gb3CB;y(xmaaGI=<91(+yC|eRLxKF4g~TG{L_c{c9Zh?~VT1SpBOyeo=n^ zs*Ycb(!ViAc6{1QR@oaFphb^L#-I%2T+-0POtI@gkKtCsE(`1}o@!IGD0 z=V{Z6ec}mhhkx}h2<-L-OAdUb`^~XU;tKY~i0iF6j@iA_QZ1zw_?(Y_^;y*3wdI?D zf-G!(^s8s>!$l3>BhCI#rydp)YcQWbdtPNtuT!Fr^z=+235?(z*ONd0i>e|(t3Y2a zcltkFcb_COVzuOMLeZ}<-9_|8gy`X4PK^?Jy6IF(QgAGvNh8&jcJ3AfWy-=WtA*fS z%%(()G=vpPnWUSs=TA;|0@~Xk2dn#;#Y!Thodw+{MfI}=+2+Hw8=kWIIF6^>jOOp> z2-xUd>7fdt7wIa} z2_=*Wh|~~T2n3Sc8Q0!t3){QTbN=4v{?kYEeczmO%u(NW3~2Pc4c(msi@QNt9Q^+i zi~C_iSN(|ensxWfc)W~_={+{kp-|I^zRXtW#g02m$Ge+qi78{FJe|TUeYM#2KA1`| ztBg+=#IsPTw7n+K3NuBt>P@VdH#YU(mygcv|ps;Ia z6K5(Oy0$j&L|9EO-xTvFgv5*a2Hj)BI}3>%tqL7pX26cQWsTS+9V#Wr1-jSeYwweh zUK2_%nRN`l<7bm^qh)J3@YhdWs%xTBVuTZ!KO=n(;LJ&H3O0oA1SEkd-JZDe}_^>A)A8_dGW(1PX1rinF^9}sr&y< zDE+@(YSxLmM@_G*mhw-mp~TCq!b68&oEa;4Fia=qfC|}O;+c%P$&WF9*I}9y1yLswHM-xn8XoP z3daMDn74P78yTnrJPJpAw?vIy6q83-jmI8`EZt_D-XMpN?cR=Xj}pYd zzB)s?AVqNU|Eo1Hf=$%<^u69jhFMwSJa^kP!i)4^biL2yQ&&;RRpdq2(wb{l!z z3zcey2Z_J#OnN6}W6u6pxde~j8*$Q^PJ<4G+Nl-W?LxZvV-8(_;j%5izqU4!{AoQR$r4UJ=G1naNpaoxamcGC%#&LKs5NMFK zb)B-AmjlDt#FK+}khVMt%sq-y!Wuj6tB{OnL%3(Zlk=N@--%!VQ{Z+i@1*kw4RT-BU^{O_Ls&s4v_jA`fV4+W1FD%FoMGDTb*f8%Q@8qt$vO*Ie!j9}v(C`#BgQzD-nG;C`mS!myb`I~b3Zu_GBKpDY6pv#p?%?82{h>v38{lJ2 z0^apw`)3fe3dSUjxtJ}4qjCOyf{?N)sotY*GhtCrP6^CZhzGUmfc2{4)^}^ht*&#C z7bOdKdy-s8>I$Uo7T3Z1Jdv16bhUBX9GGhM0?-H)Dn@NOWPZfIsllNT0b)&1VZ6qM ze=Y8V>zPhbo?33%%$?xVdx`Si8g;A9WlY8eCCxOYZOmhgJ-$B@V68urVCQ0ui*U1E z{F*`XlYUE)5ce)#F^_rwL$)>T0M(`k`4JHGHI3|C+6@ZH981OIhWW8dvo;6s@84R7 z=h_1sxj8z>c)wCquM(I2;)7pJ3H_%#z?owB-q?9CWjdIlB2aLT7rNlTAa-}kjvz!f zm79!7o5gDrOlkZCO}w|+YgS&RqmM!F6ennfO=Qz-ABu~h=4c=^&V zm()bx5I!F9?zqX4!m_)vYql098o=jHw3c*SB6LCx3THx)(9hn>HTDZ*oekU2<$w{R z(x%uW@o178p+YJ6F%v zRBiTSnaQQCpI)VjrVb2AYRlBFwI{WKE^9n|w;acz^-(KzJFkZ=lj^D3Dt1q=*y+su zYKtDLoCdm_lfLurasAHA`8Z^HQEc53Me(S3AJ0txz^EO2ty9|vHE{^4np)Yf{Sg2S z1ir2(^7uRYJHBA`$uvi#lz_Htf4Ky0M2J~``My2cQ)HH}XFv@H?W^zYp~)s%@e`Gv z0W0oiMvDYQ4Vd8#R~*=DFeMK!S7B^tW>$OXqDe>mB$Y>k&9ZvMyErEtijNpMjTW1c zuWb|1ZB_yh0Eh9WGy;u|ltOvrZ3rHNFR)W{c|bN72Bg~~{RK^ZXLsT+TRg~QE9Fny zOOPSVGsNW&_i;r}D2OqOK41i+q)>g?-M^aoe*|*^buD`?HU`YEw$NRIUo{c-9X=2A zUT_ip#ju>&GHta(i=bAUWb@vu4Y83ws-G+8v-%C*X5Fy)2~SA z7?LiEyU`t5xB9(6W_w^sPtGZ!<8IR;6kd1+G;!{@(xch52zXPLDV{?Spx5?rUnBW> z7fP2#-`JE-5etMu?|~;$)2i=X+ZqvTb|%U3gKwE17jbQxVPGbKI;S;9B*08HNiOZ* zY7n?&ZooO5eGJjRR$}z@eqk|{C*r$F9OZwkGhXbvr=Esm&aCjbNTnq`Yggmy738HHft#n0=lE-p{GukAxRr6EwO5ca@^5AGe{pS zF{0%p*7ZTdd4ncf=e1-|xo>B8C}0ABo}hyA{>L_RfEFbVy31E@PqSzW4jScLcE?e- z&46Z*mxeTyzl0*o8P@>7hZS^9em2!2eW`)xgJBc1@??;Iz|>ycb2Zu=TPT}|dcqZf zm4;os>ugLMv+bc&pXpr7FeZZsdGxWxlkbe2h!R!^KjM8Fed^Pktm3xa1*|2csr9we zM}2N7evMGS{(>ebv=$vi-2o%xhX;nU3jyRHuTK-^=Q{2&fGC}Mn$#K{vW$pb?7gk224Nxwd?Ha%oq94%P1?fNxEK4(F!|MfdSmveL-?b!@!(=PGr zzw>??@XMY7Ok>5ucwr^UJlT6PENpeX-upb5ejyVC&~8ji0NPoXz0tq^4nWx50>1O* zNMHoR%S zhIKwWvvWyXIji`p86|E31@$H;RKr@NXlokD!)!n{OVmCODH^!Y+~1S9$5^0WY<#x~ zx^Vi^v4UX|HlFp0W!4C^7(T*GQ)k6(?uiP8?)}u@@1HFR zn#sPG(ea2g*}BxNA2|K2W13>`7{3GD(aq8h?p>CiKrpeDg17t2jI_=#=ChjL)+^3+ zo7XYXI<7(+bW8>ip5V16x)pYN=H+@kiMRcQZ=uXd96*++)3U8k*0K)>Az$%D3sbcL z_=(+e)y&FTxBS3#J4dld_b0_~kFU=xmo+ASX?vqds)q0*QI-)-G))6n+lT_W6E;a) z&(RJzmYn(KbmTLJ4kpt5W-=4mImWE)J-WDd zK4t{JEot=zL*-STx3S+DegmwE#^DM(5s1?v?Z`ALPsu@(pYE94rQX~!9r;DAprHDr za6qN9-7^hyB3MN26Gshc^at*ByY~=ngc|q!)yUb_zM$YJM%(sS zb`i*BWq_I+Z>B|sTg|25|0)i|__tkWG4U`Jd!YynMpDR>$GI*q;&um}Xh8R+qxTG0 z;?8R9)>}ymjL7}bt6yT2f1kTV#K!_XvQr{yMeLGi;f@VK0^p@GH7zSEpc2A%ihuxB zP7-drR%6_|*_b$-Hc8hm`ukEGn^%$$Vl#^p7x?}gQ#_){Y+(HKwK4~ z)M!@q-WvkW({V*{Yoyq4!y^TNohtz#%v=(22YaSr3)MLjZ>8~EM`P!~4|CjdnKBdL zv*DYCMy$!O_oJr}>oc)Db=+esiWe&;9kc{hF@Fas|5U%hMHK%YelT^|+je+$S6234 zy#Nj)t&a?=l_XBa$T*<}n|HNbUU&|h6i0U|V^MGjv)mwf!i)}3AI+^QRVZUXoaY`S|APTkalP=@Ys}G*yhbsqWpfVR%Ltmbkz*z4r_^0Z66o&&VE1asq`};^{+$>ghmES!H*bATnF5BDhYK;G61A>Da=&?2;#fTuFn!+ z9cj9K729pwT);9;`ukZGlxYNs>w}R^8P`7f>RT``6I?%^H(;DZ5Yl@8467Cx>=Fl; zPMu4cRLH7GAXrjIa{AsYKn(R(E#@1?EtVuX*JPJ)uHv6}vgKGu4qSkkJpUYJeY~8x zpn_Ic?R*ffP}-6xtRL0o&G7&gvmhRAE@@IF;ZQ3PTF2M4h#Mw0mQa)dwi}zH@@Caw=L&Vmx>dH3oaaYSfevy(yh~T zZL}vyOlfcGeG<}17?J|~?to>h@LR^>YurBHR;uRTZM;Kx7i4=^W_uUh5@L~_*fe)N zG2KFkuCE$jl^h;F6G~NxS|+eOSPcVc?Ohsd{GKB(gala|0jN$R$gORT`wa?@%5F&# zW?rGykBjTW2V`fPpvJ+_g>L7a&sFnf(=??m;lo0hFPjMlC5a-bq^au!YI=JIKpP>Y zrn-qTV}kO+vm#ZD0%1Sd{NuC0|*1QO!zD^n1H1`1B6tdZVBPCCKpOwtgo;I0u#w@ zMf_m%hwLS0TP!e>{7yxn4IqG0%jEjhK0^!4lZhA_iS zIl0neZ@1W^mNR*OmUxGf^l=r^J5vz*Xa2Ummcbcx7H}JSLyrmVj_q_8$ALC63zdz5 zLE|v8NnfpZ+K6>r{qs(|MxsRNPPoudo4O9+6+$7oX?_-=J_D5%Xa zUEnV;iU`&UlOE5iFv9$m05z8J$x{D$#Kzs6BY@q%C z{NOied_$xaaF!$3#3!GDP6y9e<-P9Fa>)q=ZMVQJKRym_^8-N8=RE;Go>|yF zu@l0i4)8X&XLwv90C=DI@JJ7f?Lv$QX{8q1&adq2F_pYl`XkYy1N@zX;lhi^=z#BU zua)eP`t2q00+7OL5qsRzRPxf-Dm?0$>vG&J#>1_%Mv6I#@*p;#kr+UC4OQTVdAPwP zAx>xTMUM-DPXA04-(nLp=8`u67~iT9SUzWAh&q1`z9idQR5M8`ONK!KGv<#y0;nXw zA{bh05k~VcpKJL$FP+Z44w^7{WQ%$!!+lTr7eGCD%Lv@?3JIdTwjGFS>yN5IqD$I+G@+ z5@R_v%5H)&kmApq?)g=MI757%5?5S<5Qh7Un%7%ZnNi6G$)$d4ZDxYqx9iv19W2MI zq$B1s>7~Nb(@2}6>SbnXkvZx~M)4N-uIAccGAHQmAiAO1Xu2sfy2eu&jzc_4We?)5kr2NxZMOX>#GlNUDB3VhNtZ>rvxj4wB@&e<90VE zinY|$Rmxt@*02dGHwPy0p{JGeZ^K0Uht}Ec_3rzgJV<{7jc2Z>|OB(IhwGX z%Sm4K)7U|;rjVV|A)~H1S`m}VJ#pdD#1#uLFC&btP%w_iXwjc0LC0^viH4$hRNrp7?N7_&KKd@Ejxn$X1DwTcF)MfKZ6-*t6@T4 zX+pTgDF?MHz>~VkjoCMSmG^t~T)>uio%8(0*aW36saezwt${y8{!E&@R-w@w=x+qT zcoT~Y=ugwO3-LY6hjHu$HZV(JI4`K9KJ}|`PzstG+#XJ|ZOurVIAB+oh!pUKg-?_n?=OayZL7FIz>DSUSrO40w-rRt3o5JB{_%5@bst6uU?PVURBKc~EA?cf!N9Wnn&bL$cXW05$4L=upnCOsWS6hgw7DkRSz#`TZKK$5Er)}@bvDlCc0)qX8<@o)wqKswyP}o(R94SewG`#DWmQ#Z}$~F&ar^ih`dn0c@(bBh}^Z*GU%C%p;(NIhqJQ&Wt07n9)(ZLj1Lt5 z0V(V`vl=I#({8qs$W9&T2BGEdceim;@*8N4m$EQTUTwp@8>Z<*+Rjxw{A&CqUQ)`& z-HZpy#utx62$eN0pi*l>PIdDH3)$N2ii?VrEo1H=@fULdn#eZqjC-_t1Z-UX{Ao5> zi6M0PT!UFfkZOVaFUiLAGe7Hn`TOgA=^ZD*;|bTn(aKk zv=wa<&92=@*B>Phn;A7+n^$MBoTDpd9>d62aS12y=O9e*Qv7+t{Wqoe{V^e;F~@`` zq%bnbZnG8$ls8>;ps#ag0=z}J#BF(dN^)t}0#&tJX`0MMXQr%FfeMTnnfWA%lIe8* zb%W@0pHlK#U<-aN5vfEou}VU9T~C-}bqY!J^ac8jbbifT$Mu&QFYHLqVB;WpDNgt3sxW>yO2|p?<@}P~wv2z;xbjPAl~mD`PEBso#Wh zH1)q+ehNQ9g%8}sb&TG@rJTJ8OIrlt%+UMP`dA(i?swJ?$lxzPoRS-ouQ&&dyJK48 z#jGtH{l6`?u@&I9J=|@CUNlZ9%1#CZ=`Z`uFP3&qhAys&jf?+!P5hovmgCHPU^%Rm zeD-GU(>QX{E|-nC5(R{sc%}SIic|VB+QpTHOL%V zpx_fWGLtK5IT1-1?y9f7|LfLm-uPJ_7ujDPpFeZ>8WOc)(*$+GYrY>(F-P3@`>^Al zi7!$jh3AR+j@OpKG+(vOn>yd=m@!QNyb}#?f>aA%z{L@J4^IP_s18?@?sK_-evnD7IDp4Rfm; zN3m{lTcVOb-mrnsy5{@%XREokq@J$l6e)4H6@K33Z6sSUYWs-R=sgKdH+J6q`98Hx z_47NPANUT7W7_eFHu>33-#@*&hGbu{2OLdrjUy9@=7?aqvgY@7d(nn#dko?;xaNC9 z<%NhKGQ=q*NX)LV0VH%MOQ%0Fz4Io|zBeeIy`;Oaol!2a*6hvF0kV*mL-Hpr3Ey|b z5SJso`s1s`Y=1PfmfNI__>3JYT{tb+@v(WmaX&pWSCn!h#Pj=4#O`)5V}5o*zWq~Z zVjdK35&M2Ay# z^U;y=-Z?Oia_|EATb`{+VhT7XeYg)jOE2$-U zgRmmND+@q;1U-?zR=9zgjie+zDq#nV{3+g-lR1fp>o%2;>mQ4FxrG*?pU4B z@01L{UINe+gU|WDmQumxW;X)~XBVCWmH0(dm0TkZlaap5h`pj?@LBc-?-=kU|MZW3 z(Fu{FlrM=s@FkvtFGK1W^D8RK9Ve7@Qzm?Dn+MlbnF?YZwkP!{HR#uRKZ`W~zKRkJ z6xE`1K(!2Jsrcq5#*z`9X)2~*C0r-}?<>*x+am!3hCgiQtZ=8FcBv}7EU9>x8(Yp) zFf^5(+hPnZv_(#}^{?uO{+t#c#gUQ+T^WA`SZEml1+=nM1kR`^z9BC5fkxxTaUGqM z{tFP{JKM{5DSNz_ul`F=LfK2Ik80;8O7WU^)T@>0s5Rw} z=5a|6h8);AiXm15Pf&Z+O=K?c>IN-Zs60hpJg21K@(*WeNeYu49MT?NMg(ef@5}T4 z{q~o7U^DcJa9$zEA(I-<;;R#d<01eH8vNjxwoLprq(qB$ZjB?V|Ha^XR?q+~y8Qfg zj{QgX_onVpkS}HTVvg>|=jpM~=T|hI{Ho#97$g3>^E(f=Pi?(kZa<(RIG^UYUR;;x zJ(G%8;{U#=|6)ZR_sF6+Q6>jG4_sQqqi~PNBh@tFq*GHkQ=H;!V+|rY=)cMVW>`R=L z*Z<@7y)%?cbNVN8@ZSI-l}fN$a*GLfc6R+?Bza`}02cS>cgIq`d+X@|8$AY=jJ+R6 zd80E&kjVZd(NA$bN2#NVo4`*UgBO1Az-oln_}8060wLl$!>Z`#k*Tccxr1iRjy;V0 zenAWKY2I}|0P^RS2QO00AMSwp^QczQJgui@0VUkCfC^|~+&ozR-lqN6pp<#rT{bQUQ$r`O0%zWt4Z;-rBE;UPh?Af11BgeK!~ya!D93 zsMTdXa&dpa^K;jM-UdtddUHU$1`bC8tRZ#2zzyLpEsR9$e;g5I>wX_-c-&3zyM~Mf zZpBk*&P$2koRCU-5oMpf()PdfT$u`ASpH$;*A|uQzj@!OpGH68z_}(IWk0XWlxwpX z^fwZ5|Cw%Hq-^bk@cylxFHzxp0$z05yO!#p=z(?l6;Ql_qJu(zJALxHUNAJgX6*BC zesr9XvX7^a9MDX)$L?k<0$-jb&cw^Gb`074>@d=3{uusu!l(4pt|sl@lyno20Rpl! zhOF24&Ec!0hdiduKdkZedI*x%&R1G2cY|4v(hdN7V$dr0l9r_3Z*(vHCpDn^yM70G%cb` z$E&;}s^#>i{owujdE?{+&V?@iX&&(-1j&33Nl9T}pO8F3Z}%`5bSYxu!G7ozfupQI z7dN^7v~ti)PL$jC`zss#A6icBQ`EnEq6y`*SoHSGe{a5u@RR&3wz662?N$a&Mi`B5 zr(VE5-~OM^N*AQ;`{Dt^5PLQ^OiOZN5MV`kMC&)T%XXJ0YP}s1Nc~qKNC0jWxfR7} zxaa8gn&)@IK>z7J*kL}KjJF3)GeM>BNxt&l6x|GH<@h2&+*Yc#ar1h54dXMe-#y18 zgJSL;91yBEl!we>^_*&|KZsH)s;WGJ<&M$Czk34xyd32UR2)#H?Ool zyO>I|AF~cqN^!n#G=8Va(|La?iTC>#a}J0$i1zhy_?V#0_dJnvX|SM#`q!Y4H)0hn zyrIDVKUVwKKcrLCRX2^4izvit<$jDAm8af_N%^6p?G0C?Q-gf`jwn1+{q)Bw3SQg} z0FcaM&b3ao>;-BVc-js4alfRx1n59cAQ4S)XjN0Lr-x!Q}X39^#{{KJupB4cB#~l+a zQ%||9oc$t=XMkVyuTgR4_FBN0nGRF4yfbT~CttJYcBXz;gojAI`8*h2L{FipI24RY z6E=+-p!mUD7H+_~sajCAn)&BJOD(nz;NlD}Z0T?0h;E9M_lsG3)pn`*}2$`Q=q%r~U-R5M=lK z?BGy(5S`lti>;}$=&rw5Qj^&_ncN04I0_lBKP_g37mtZj6(jj$)pV{dJxbDvx0bga zuk+cBtQI51$WrJtkie9N(j5)Kj@a5l?Ql(9`AQ*3VM@az{vALR+mP0Kwcip`Bwt1I zr&|YLyA%NV(n6@;p2NoUXaDTBC2}H{11(6yfRt)MX21L@d-iP%1&%;l2WSyWD4`)cd(O#9Q$pf>j@K zX=Eq}xb3@z$%ZhHL?A4Qd;ePf`)8~k2I#}2EZ1n zw+w|R4dSyMX-s2SX&Em$A4KZizNtA7!^PhVl>_+vq5HPgdcQeA z$xFQq;<-i3G?Z|^qRdN^W6=KT;&o|_=N3t)|H9B2@ev9K;VIgYfhaXijcERrct#G# z3T@6u(?)$cW>z9$$+n`v%nj&4>HnE+Zes~tlR%-4-Krh6*yy=V{=;h|3Z;6=+>cWr}V4|)NRqS~iaLxCJ} z6kJ#QjRFq7bbK?DHecT77uKs@Hol65YLGj=Oq-Wv{4lTm~9{3CzrOTun0)jV()bib!=q_rQqu zMhc$fxBseOjSc69C!dF4c)2F7^XQbIuFFqa?L)%AXsQ*CZG?!#KwSv;_E@FzctM#3*||C@;-CRdVXzyd&3C^vYJ5_^Y;1Pu19%gqco`bL8No$o zC@u|8HJZnWxkq4))v-nskCqN8##rQmyC!neQuPSfp|O>(jpMSCB)8$g3e87P0JD_o zcE;kFG{YRV;tZJsTlX?r<-Lv8&b&w6b92OuO?9PR>s8`1sKpY8>$xawAqw?zyvxtB zN!2Bhb;CyFFEaM4r~mX!B>`A#!1-~c2dZR@3xG1C$?H#@(l)VtcoNjHpgM>=;uftJ z=Uc>Zd{Z~L5`A&+NKrF7W@|5nFn3+Y3RP7df=rczC`j-7qQMjuJ{v9M@=smy%-z)bzUJml0oAJd;ZsFJE{&A<4){wi>7ac9X@yca%*w1${T2 zW{w970Z|l!qV{iZFoE8#l{PBCJlmK|Rx+VZ<{LwcTp>Uf zS8W6M5%Zf1>MvppOwT{1HRPw@<{&{kvsQ#n?`g!aDHJaWAR3Qoc}Qv2d|>>GPC==+{!y47-$H4QwOVar0G}oR>m7J<9PYuKvN|ibE<# zhn-}RfWHX=El3Hj3R@$gppj|H#4;p{M;=qI8bDk^JIKw_!Kz9j7-N4Naw@qRJnbxae9w`4*TZmHMMIx8!i}xMZ?;vc7B9k0z{Ukoek!WQ)Dbs z=36Vma>j|e!qX^vR1|^{GnwGy0bn+-%9HwI6|PTIn1NU_OYTR#Jyr=kE@4Ea=LAZY z2>TYUw>~WgTe4}LY$e$O?RjRQW_5lc(8G!Qq36xJY2zu1|m-=)`OOzAJld!j^$hj<#YsVE`+|dqoz(oZ{ZIFzbpsKf`+?Uir$=)ma#Q(BpZ7RK! z(;-Oq`7p3za==4B1+U$=GK8JdzJIiM_$*hQStyvpe;~9OT7t-+8Uj^?z&RV!q^x`&QXBWFBS2Bp=&FKDar-m z;C*f1@Bqv90NU)RsIebh|2G048MF za!UFIXjW@br`iO)$xXfVfU+`bn*Ynn@Md_gd#NSowsz*_QeG?WeCMM;hh_luo7>p2 z)U(zBAq^pt@S-ou8CO3B3wD5>gfoEnAgKSqHOpo=4`6X($tK#jm5QpSv!`iC*V|3l z3L0?D%RbwcGggC@Xcpi?*FSOH2^?I)13;~dE6P5HDK^-YH7D8aZnEEEUZK)m0AD}J z{8jX86*rH4#d@N{Y5Ogq1s<9gJY@YTo|6Ui4#g9D*skxNT{-}fg%O}&4X20=g`$Ni z?cbi9u$lpVGzC({9Y?6zTup*rksmZ_P7xebYkr&*Rp1b}sna=Lg64r2PnM4MEKGFR zKU>-Co&g~@qb(?U%q~o{*e5K<8v8|2z-Ps8x|5`w?ozOS5p&L^ZBkttuwBero2>>` zd=jv8BFY*%HGNzvwqG~bMujulzKY}VpG(OTa~S#xvClfR1~6)*UGDI98bKkhUdZ%v`m%N2MqQ%CASfDdzj{$Iv%(Zh7AT=0~D78OQPggq1E)bf3%$3p$_Zr`+MGlyZ zls@}7e+9XhJ>@lkQ#YIi^et6h-Cf5i#T*_7@4~I4a0*E5>l4M?%Sj%X5#Kq*8I*%) zw0O_nT~3c)i>{)w0uYDtZ3(5qW@DsT4wrf+AuwOeJ=@>zxbAAUx0c${MMw^HHH^EJ zaTJyE05*fH+rzaY0&dCR`0Nh^d&!Vm31Pjnr^8lhqi@7xKp2q6itMr#?fYA|=T+%M zqB106`?t1d=sIEyNSXAP8z6i2<8Ga8gr!E&Zkd_A5y!H^=ezwd?RWxcP>iS&^0K$} zGh5{i=LExZ_7bvTLaSc{MVUhuqgz2HME`hR&$Hry*%y}oNI(VPYvbDVjhzgjlSMr` z8nXE%oLd~Ot4*ry0E%ct`%&!_oy@o1W<<9=^IzHCygA-~lsg9*|QEroZV7MNJt?q*)sW(c$*$6(GWjssiT3@BGS!$9t7b z#wY#)+^JFD_$k7E%bw=6Gqnb_r;Vjy`#RKo$%;3$TdAj!bW4&D|^WqQD}jpYd}?-hbbHX$ZD8 znnTmHBoKDlAKr{cb|xuW`YCVND!E@1vm;H>6+^uWVuL9D9-snKx_f^51K{4?WuMyT zP|@eo1>gFT4!!!b33Ko5P`}MM)5t=+vEnVpW@NV`;M7D#h{%a@`^~3XCS+U>XKK!l zW5bJp;@=#@J1DaZPd=ql0frqD}c0 z77dzp!1zhH;+&bOaIAnx4q;Xl#t$;ZY3s%pgRox!X#HCyc^A6uJGcm-5h?Dbi|q~% zRUH69Z0O68q3c^@$oS^_QrFgmmiOqpg^k{ogGGsHTnVK{eq`+9LxLUHAa6|_yR_Ie z5n+S+ho7gLg_d-lcP?aq6gw83B`n;xyB5u3$~wSsQe1~zDWe21vvT}IMFOy}qA95h zWsC)+&79$Oa8{DYcqu0+?}$S7HvZ2pjS2f4#pi%x2}L&$rx)Sml0ogZ1uWqR-8dIW zLs|wb>exyk!62ktYlb#miA>5npONarem@c#J7nixT%xLZuWDux-{g1w1$W?`HNSfp0qyAxvd;h2>*BN4LYxx; zEpAQiGOq?Mj25?Qgcq_cxuo{=C)$7Rg%=U*T|ajZ>H$hGrx~F?tn1qAb&&@Er9`7J z;FeFu!?0bJ06mChvS~Nbfkb&ewD{bIk4;mT>e0 zZD3^29IJv4;@Y;KQ(3PRm*yOo``0^RS((bix_AnW4GiRI{}&0Nym6?3a;wh#Uv8Bg z)?Ct4GchG@vJ)^Ql`lQv1zeSD^`Hp?UssSrwXh(h{`+f}HlPP|Atd~Gv;ReMqvb~b z>kNU+yI1e~rCFl@yVbpW$0(C66wG<>&+L`xa)XyYfg(bYU{GA+HZ5*Djht35A6!|A zBFm8YHWVISI1%~!-4yf+W0y&!wnT2|+m}y`7h>=J$;2z6BVm`S#s2p8AUWGP@nS;Q zSF62Ym+Bs8?94J4sAY?B-&*xG72R4@a8O+GE%x*$}wBOzkHK61yg=2E9@#!bQrkQMw6D zJ-i;$%}7Z`!iU26x)FC{5=?wqHZ82-blc8QZb^|#5n;Nd`$62=vtoB|2c1|CExsJQ zyyn)U*wQSjS%W_cGKNpgC2LOE(my(L4&r(LJ?+7{PkFpAHQ02o`~{{$T@}q7NZ&#* zKGK+Y*ZIN}7HC`BnzzE0=PziZYR%Uj$-=sFGD|yAz`)EmTpJv+o+JLXnTL0Ptlp zrmZylAWe&PzGey5er3xAkQ6SsDt!U;WnQq&_S$S*T2exF{dd505)Y+vD3IE9`lg-6WRrk(r>!+xKw}VSir#QeUckJGFtdvRr#UKM~xD3v@rt8 zQX|5K1~WymOd-g5spP94Z$36h-i*9*Z~#c=Qx!RDtOw1xK5W-1*sy5(k6N^=MyHL#@q7!r?VfD?eE+_!mB6g!@#=xe_t(SK zGX3!!nBCPziS!2ImZ{s`n$y?Rve2&AXxlGfHmmw&VnYJX^vYg;roUzhd%fCm{r6*! zs1C6l3ejVc{BrJ>Nn4bmz%SVkFTA~aK2)X&dUo@g+^IzaRiRfOV(#9K7=WL@v6T@{ z_35pE3(7jcQx?6f3R=+H_NdK*n$t@n-sK{gq7rqn#$i~5<7upjN&8U|9n6)MK2WI} zs~kILH~OK?X6T_PnARzB#&aS31{HjXJR*X9VfrTv8lstxR9`W-6OET?JWi+YPNN$N z)jkg~sq=X?If+HT`36y?&DAVF}?AA3kxm!YpnzU0lDU#%WBdtbFU4 z*NK(nErnAy$zB-0`cXlg+b$TH=p@q}nGp9CVAa^@RSLbe4X5E!bxN-5smZ)AsI$DFL5FObE5}_bO(hu0xE09Xt8U?c)-R#Y6GC<=6c0o3T*5N*P^mR z%Oje;nVHMT3mz+@z$oLmgmd7*VC@}tXlOKjP{)w{&R*J z97+8(a3tC1dgevt!Nk&Q&NU0pdD_my79?J`Uuuvc~OqL>sCNN zpvw^&Es#^z?sBPtTVe^9r#|3B$<5%Fi7sTz<6a?g9I3^*I^yok|wSfqUYpJPqnz*A1iEpC*g|q!yjK4yqMusgsXP> z{)fO^1bG32qPlwL$~(h0U$;MXR}GH#$*CF9r7ZXnaV9s4hpqFX71klOEO3!m@I$u6 zrj?(;6j$3~b={KuUB)yYOXIPjV<&@Ni#8_nq9!9m8?F`AEXw)25CtpEJ#mYA7j8EW zD$KCU66Ry2bWN+>s_V$4u@7JjvH116%48VHx}qULT)7mt(0Gb{GFG6>R(2U&z`o@& zxf_efG4u)_ysRhmI_&<;7eV=v=-m}#6EG_R!$~F{FVc)1uKO89$NcC@;#V7Bn2gdr ztxqN_iSD|?m1pvTT2U^t$TVR?l$q8{n7O2(U$WOho%PM^T*{S;WI5mBSLTvfn@Kfb zD@_qTNp&)t-u7rjs&3wF!#xZbtMDfSztVY!p}jNfL+LkKVj@HqEp4Aa)p%0>Spb6k zmS@$mC6&&0`J098)(At(-8~}UDe+{fMr%kt>ic%nw$a3+FK;VEK%0>WC zsjoC$Eg^L92zI5uyFLqshgXO{KxnGHy=ZZ*|D+SBww$AO59L^rpOB5U z;QFMBf81`H)AUF>=~hQyaG^#zkLRE zSwubDYZdAG#MMr6Vce)o@!Nu3x|e6OuE;+evJF3Z=U=@5Qq|kcB}Z>%u3!oMYCY{p zp%me7h6;i~9xos<8hNy9jRs|+Y?IRE6<7=71BGCmuMJ`Dl5+CTrVC#@=G~FTnGhu zo=B$4*mic12gc-3`V@^@yv_s$ZY2vhDGPNehbbwn&MXd;$^A$oyat%(i^D< z4WvdC%~#T#L;XJY7=K5+Qp=o(@yvpzXx}-PtfaoZT76zSV_m7gp)>Wo>A>9Q@zf{g z$S&c!K@d)S0T?fOlHMS`Mqw*bnj~)>f{a#5wA``}RQu7jd)I4ug!c%Q!daTKcS;Y> zrW_AJVuv+erp0w#R^u%$?Yyn(@NSDTG4_3mMgmq-c9xN2-rk2(*j)V~m|qE+GAIax zCK6BBr2S*FKs%ndgL!3Xr!W!_WL7}v3`Je}Uv#+5!dZjI#u#k9E$eCcC?Z;nHhXlH zgJb*H@X_L_U0!N*+iw1$>A%i|E(f8ftfLgxS<8CX8?PN&U7b2Io8oO6vxVt-)ewlK zHOY5cnf!m+d+&Iv-#30-R+O1kNRcEvgpd_wWyY~b$zV2&0uj|@9)sf}2(ki9gAbFhL zv?bk-@U^nIXnPP;@)+q!Pz(ED`w`-YBWPYb>k@NwbIh(R*~QK}m@G!N*v9KtOEyde z{EM^f`((hgJSS%iHcms6I>XYzr3okYIw zTN2qFqP_ffUFhJ8-n`dW#SMy=wnp79a(H-e43I-jhFFj+iFXUX#tMka2a(XQZ!KnW z5oh-iJh>QrzFmFmN2%0jvyM6ugX?$cdB>Di--mV);{K2_3N%eg-EGX5b|8YPgbhVUNV!Od@ z(&>aKI!UrPWbc80v-ize_{ljE*?7uy$@{Al`{AJ1NVNgoj&2eoY@rlBS-EMNbvJR( zugAkJnDjA!BPrP~R4-?kWjjADUt_WnYLSgD0^RO*?c3!%*7?!U1X|#w!bZ0`@3p)J zW5JOidu6m9x%auq>&w&S_L61BBFp$D_Zm{o?n$(;Blns`$KWeqDR-|@T`4~sJ05$s zIB8(HlCat`fG~89+aYaw2IW%^YRd0#Jn51s)XYXUal5`X+S;e!yXX4haRLXeofTB| z!<8q|Ps7a0r#Pk)M7i^;o+cSwDKQX zY8zLv*40k6=YzSzxs#3;yWYNB3g=JQ5JJ70S_CtejX%FzqbyDbgJiXZoi(mvln8C( z>79+LizI=|w08}FdD>Iy6$kycl|?%3XgbD^)-yMA6x;iWbiQNNI(0kbOs@k%aHZt2 zZO|>zL=V#$lC~HFS{9I?`UrZL4OC~&&wlbN?xFbc)`5$+y02-y42+uA0XY)Wn6LCA z>$mAvUea$r)xV@^rbSo>ri8J%4EEGgt>m|K(h2qy)UrK|E!|puKfAp=2|CFnfd~7dityaj5N&hL3*`Y zg(#DS|3}x7u9O1lleKf#hOa<*Mw!~Q^9IU>%&R)8Y|58u+a|#%*X?Ad+4c49azr3o z$fzO+fRs7C9s6M$M)-sFJz;S3^RC>K{f}f9cHUgKa(1+ne-L_hK19q5(VG%vTfVGh zfnHd|(UqD9z0*?lZM0)-(XArI0Y?0SjLl5HTp|aqIs$k4jU=_HEd~6{$G!c9_^bf; z&%?LXa^2;G7T(gzCB5xcB-$fy>BvfB4So5vKZL6=k;5mGMXM?CVoHQA!%Z#gZpW6E zUCMNS3v0rV%ThD;m4rRUfIA>)}Gq*^?ezgzgf6mZ6&3U1eB{s<= z7%C~kdZ`ojz|cpu@5wWCFSDJ@OHVHK2*Pv_n|&M4vx(BopG(LH6BXdOgacY(EUYB% zH}ddF*hYZvU)<^fse+O@v0m$l1;*YKq>%Gsr>5cVjnwzf(J4V4bb?vMNJ`E0yljvu z?=hgmhY7>hN+$=OUCln0L6}~5ai|vm6M46Tra@2j0>O4Ylkzhz7~Ofq+gz>+x*xA# zx+;b?*^U@DH)EW~96Y^7!kHbg!~rl6P$Qy?{C@u)Lh)N&#MNjwI8k_xAZjJ`zg$#^05&q|;E||1OzUkh+ps58yfrG6ozDnd zuwSy3i{RvddE>f$+G(S8Rn}%Mgrubli01eCNlxOb`msQ1i_Gz6(e6dD zYl@X{{K$AWf^hQbrTI|(s98%=JI(04tz+p3EK9hBunG*>8-jhw(k~k;9y;?T#giSX zoq6#|^XyzbKpNk5B-VE7@)CF2>|Xj{T<7eZt**bK6(jc2N%|zDuuD< zM~SEbXQ82IREYxf+h5lmo}FU8D^WhcH7eq^60`0M9PN0&3_ew$mlLt{1uJrn^1UX1SfV%g$gAJ?P7a_#HGV#{BOOutQ5OGc?05<@<;+x^C~$g>AL z6At>5XUzfbEBQzh)a!ZsUjDVl%KNuJC&U&npYRkk&h%Vuvn(w!6itVuJ}m#r`yEte zy_lMK;Z+Z!2{@cX9OMu(g2RSNkkj>Ftr`fL|X zHs~v+-gDWHN1=N}x%pKp_O`eLUU+_gCZ`62==13=$(NDLs>;x_(`MZfaSUrW5=Q|Z z6|4w(WqU+ezDcXLxJDu0nAQg2*;Y5w8!cjGDZ4wh zbcwy9N>VHit`TS>r=LrHro`VhZ^vXp1P81f`-(DoO&gg^CnmT+#NuUz0?33{?eErd z_<3kZ=U_aQSZG;T1Eowsc-aCin@K|?|GRaavc-%Ab~^m5&Bw@*<&7;iBFJK?*&DZ_ z(GB5!V8Zo2&VM{gNT)Mf*|Ih(czwIN(rV-7e7}$aR~ZMpI#-t~@s?@~HCe4@no&0$ zKQVb+jneIMX06cSVZ6geI1a7_b9giGMIAv0ko#$7WoE+UGm5j$4PO|m6y|G9#=G;^ z6I5S?`iJ%4M{{>`TvXIz<7~-dlPOGpJI`^E7(#sBs_h&t;f0Tr4+fSdb5>R2*=*Xhqr)0Ho0xj3wG~HSaqgZNB3l!#dM*Vb;}&5 zZA?!aSjRaJdF|&Ri$Q3&?5($Zuz6GHPcewfnaB6JF8Yh=!62DefVmNaMEN9k`D)>f zfFNHlcmASWhM|FD8>{oj`FqM02=u8MA9TT_zJWgq%9k3~nx9R4dNvLktt`VK?l}Ri z>k%j=gdxPI!A#Rf+j3GG1F4S9Z(FP-e8yhY)$eYE74SXKAIKATDQGDrfQfn-u!-;Q z1n9X8a9;UTc}+c(s|TjAF~O|mwLLHAOng6{2FCYr)dLJE&)hbqHwHQhm3 znhrAIsQ=SNnD0lgmB_d6Uqi-<`Urb>?>h;kR*G+qIloibUPHciTdjO;P_f5smf+?+ zwf%N*j9H7G-|}hsa&5>*a$9~Rz9EIGzIT8|=3UTuLHjFW$NJT!+sTpvwI8dLcqEs{ z-|foi_g|uoMFxWu2{2v8KA-H9Pm$MXd@+(v$$$GK1gIDzxre0tPKo;TshdIoy!}B& z8z0N|d#%ebz?bIV#iGS&zI=WmhB#nrHf%4F#&(ZUCQ((9mR^JBI6>u!v)zbk?oHS4 zYOJbxPv5$D(XaZP$bVs9qP&z~#2-G)Pfx(huO+(tg{tX7s_xvWa)hQi=uX=tmLh7M zMEk?xQUFR>49Zp$JY>5u2D(jsG%Xzvt)fW2um3X_bhZ7~fT*8~D3x3&Up*$Y_fz0q z2Le8i$#6*&Sxkv|eUO9`=2a&oQnIZ69C^kPghXe&s5glruXeq4B`*TO5Ol>>XCrV*G*Z4gWEjG&$ z7EsqPz$y@w94lYd>G68(6#Y2xgvVZS_{+q-UE7=%r@hS>C=W`+u}o|9cAZwDnEt$d zCm1q|33~^x=b=ti?t3Sk_guP9ITXm&JOc0zWLG@{v10+TL=SHfYJ}_G&d~|)Rb7Z; z4RG%&gPAmMTcv!pKqWsu`5AS!M$&O}tmO(P)#^SkvUI>oq35~JqR-jPyC1!kb7Ip) z6KPuF)2l5*&Tu|DE@;(t*Zs#7C1o0+XkxvQ#>O!KZb4fcrRU$&%+$G$`_<_^;c^@7#5KO%%$?ec zufE$0BEvk$r9tz6hFoLA6M;Fx0qSP2yI{pt+7Rcrr2K_@k^wd`nBaw(q@^grGh5ia z0+oTf-ojrkz{FjhLD)n)7R{#7@FX!6N|O>g1|VL_V!Mv9)?q2749k4#R!0I>zUZ~E^2anoKLW_qzO0DfK3lo6*ngALWyt2bu)M) zqZPZfr8Ebqic~)Ym~`iu-TjG}tlBOOJv)%EVK!~klc!B9a8m^YFS=V3K4EOo7h!#k z@X^)p?jX`x@}`6c+eC{zT@OI-Ny4D0DJj{wpCy9OX~`!@P$>8Ld2joub8JvIOVpyS zNf#Y^vg77^`w5N0WIg6vC@m&GBmQWzNi}_jdjTldEzX~|-}y~t5vw~w%h-d zTiz9v3HZiie4&Vs2pP%B#BbFlkbg0xhbE2Ih~CH3iVBB}ctA7eAwAFcW1DN}MC(sF z$+D{H8nBn7By61tW$A{u-H5iNKMrF3J>+D^QBxoqJuwEFXD& zqp}*mCJKJrAOHofvU(}<0VXgJ{d}3HLnv+Gqk7O2Z*_iE69%biRF6MB6XM$MZ`O>o zvZ-Lhh`8Ny^+*}t__V*os!*9e{D!my>LS1mLI44?xiMwYGh6-T>A0 z)YQO;WY+o3o5@O)n#$_1WRU70Lg%JAGLhC<%nrft^-Wf~)O>Jxq^x}1J0o-Am{Z(}a z*SLu7pr&chP{bwLC7{~7Q(P2cxY#4Ec!f`h7VLAS3QeIiU<%?h zkJ$+_g9+khS@Dg(ProLSPju`URHHsnZN{J^I8nbizIl{7fI>;)% z`gKLA(9B?#FqhgUgO=j>;D>o^4t_aP-$}w&^n6>}U&BSjmYL5>g?rx`*68Jv$+2S- zF{fJ`dEHz`a^@n0%R1&WYJYVF?M62iv#-l-!hR-V!kQgq5=^(gi*TIjO~mIdx5Vce_a*f3@vpNpZ`14Dp}o#(6@aQwornm*sm=(YHBkdF7k+nTYUv!> z-G#T347>P5kdAC+w@cVYBJMHXG>OkG?9~|;>pW4PlU_CzJAZZ7u6R@i*R7Be$n0aK zE#EU)H&g8TAp7c)Db465HK5tl2A@<%S)GotbW)ICK6N{y@EdC%mp>|UUYPbmFiy3l zwkx=JXG=Ce)cB=ELj9jFiPY!oRhUW z2Htr<0p$Mw6^_9Wu+pcD5Azm{w=`A(RZ>I%8B;5va}t9w0*=(bV~l?PY6>_nVY?27 zSsSO|cZx?yaJhi?*;%Km&M;I??LF72^yu_0U!y3V@GgwDECW4c z{~a7AK{#HVNCaTZ!l>FL=La7sz_J&91!sT`8U&F3HtQP!zJCf2mLEYOOL~m=YSpfE z&J4OEU4XEViY}0XLCb_Bxe&1W#{%$}ZE9%n^#Bj_JFf;{8?BZ^;gXy4EdCl*2}*@L zY4P1xpGbfPX)dxt*C}Kb{D!KAuBIAl-=h^Uc@gY#t=h7LaFMdU3Osp_H2v6N8sPXn zq{h&n?-2dz;NBv}^rvXpt~t$YtK~kpZP8zntDdeVWKB)W&n}Y_o9RLf$@g2L^z%Fc zyicm|@7TKUaxR83&5XZ_BSZKIE~idG4(>QICI+NTWI+qyWH2nze3crgBMBF0)d+QhUB9Pj!i`=?Jk9ri|Yvn%3bC^^Tno0^7+nh`sn!Rb5 zTqHpO|G0jy)g50=GfC+~J3cJE&#U14VJ#J>t75d%ZD7_9B;^V6)C481o^3OYQoQ=xwe77#nYEg*Kk zC=Ui--xsv}@(JEs7CjXn8wRw9^Gk5;{PnE_Zft3m_~T&t-KE5cQpX`t!l$4ZFw@w7 zDE|BmCtJQ*OY#@5L$1`T?x2f!)}bTBXRpJt3x=+kQ^X-cVrC@euUJZO_yd{MOKr=aX~`OEvuU zE3Ya>FJ^5Q)-_;g0_6X%n4LmF;ag9n#miVaV^|-&r3foEwuAP^ItfP1TYhE1lB!$y{&r5yZ-t1{ z6XpNuaQkBpH@hMhAD$4Kmt+KUoow_wn&m{v85lWEV8Y&!fJl?7L1FwKJ`D%+X|J$8 zE!^(dXVeoa2fTlflq9f|p&k|kPCu0Z_GmJwj^j9~$I#o4|CgpYtiwDpbm=^O_`dU% z@FN2AmoyzG*hVb<>>Q|$-gpTk)|4IU*fNSs1;hv-!yw9f-9KGz+!Co=gd$EDN(suf> zuFt=;^>+$}faiFulm3uq&mqmH4|5hr-_3}|g4cwZWHTctc7xaudV ze`BGa8tk&f478KNYxkqo;HOj|bIOX8SPZ-z1sJI2bUYN>Wc~9?nE)eP&QTn`l^CXh z@{Gt*FsC^34QV*RqT+4rGZ6{cAlJir{lrle@q2L^b0g1V-N>>!;2)6xbVMGT2M z#28IxgZ)i~m~V0+50(0hUB8lqf$6{k;={TRUK;?aPnSIQWeQ6U@n9Kc`QW~Ny(t62+ex`?kMgOB}ZYj&;s^tDi3avkLoUM zT1OtV_FEX^LGTn7o5lf~?qwm(uN}!I)<4*Ub$5in25e&OvA#}wB%2}+*kp~(CT+kb zj05@dNH)nIu!#$cP00m-P4X@mLuHO+6Y~L^4!KO=|EXcvG+q{d{IF8|lfew>2X{R= z7Hj9X_|2#VIdhPOHQ6?fH_~=Ef z2aE8q7b$^7^N7gfN7{x-Dwx+>!7>aPIKj2lk*Di+^+i)MIS%% zU?fRkHnPXwC=;_0!o%&x(Hlu)Ho`tk?HDf2m%TYIeI#q&VGa{W=ltT&f^rl}Twq5; z*$g1$Adu+Y2ukuMsUy##ukTrKuDD;klP7{moetaxlGJxy@23IcDoX&wP<@KI_vw*h zxN;zdR9Is8Fj9gdOTczeul5B^glbE3%|C1k^mSyMZh;zbw;!HZ@*s3N_P~K1(1aln z5a?dXs=4ryrz{Y21)jw=t7%w);&?UCEQRg<9T-2u7_08fnL>(cOZ8ZPk?JI_8&(S# zOEo#bHwb22_p&hTbyO5H;^wvENA3xTsSo<3mC5djfu{5=EeaNdg=g=uVPuk5DM`23 z<~;^ICtni#c%TLE$TtXwf!ird+6@7M8_IgmO5aGvpVPFwjd_41U@et9)a*#seo_SD z(8Q?&LdoX51V2Rs5IDJ|)Zitc^6at1jUhZh zLO2M94-;~{Zpaue0RmU}-_`iB`AEQl5u3NzZzWABTWkjkRpez>`EiLs3|f(b1BETH zDnyu1YQs8Tz7iyUr4Ezw#Wn*N8xtTl-kj6CvJW^lw#cOO26W+tO`{TTk{*5&3Dc99 zE7s??uLzMqJON)(>Ucesa_Ju`YpzOTxc3w5vXEd7=!3b;CL(#y~ok7$>C#-Ey z{Ve#JwF}9+*mT?h_taW(V3-R&OziZk_VEDWMBmc~cSgMi1CeHaUY_)RaytNj$yeOg z?T`c#OYi>53|rM3g<vbEN4HN0;HS zgMfoehAUZ;p@2Kv{wduKe9SHGwpvFFYf=Ztw6O%57o5w6MWWvx@e&s?l)*&`AIWGo zP+a%YB! ztrd#*OaY6M6ta{DssYQDIfj3Vfr5jyzue8C&6l5s%pdh|il8@K4oEk&v+pK5mUJTy zr2GDF(#_!aFs=eL4@`RbQ3wA$DDS)DH)4f-0jjA0X4fUjScx+E_WE|7$A(S%J(tw)8HDE^iz<*DuR^0t-U z^{*j5`SW1n+?`st#R_>%xE5R0Zjr?EGXpY~U2x<68SUaFkFRua%Zpa}?ukL9$mE@qF<31S%=^dwARd zz)ZR`rsKFNd?NX?3pU%5KVsPCjTL3V!42dXW->wr zzcrAsJsxv$9E0}qJAQ#p)cr>U0Q7{1(g}7uNCmxk1xi{KDC$G$Ha-ds^$~ixSDVC` z$bnokD0{dUOj6p>zkhsNd%rzrV=kXNoIQJv=ETPJrP=P>3OzeXGfY*~_}=y+1?k+6 zR-+%FUhmDSW^YTiVX3GK3^Ly&x@U1~lzFA|UUo0ejjOP1T8SA4tNNcitBc*vfr%~i z6@;~*1S-1C2dw;OQr;ncTwqCXeGWi-O#&sLlgxGlT)Shua{EHm8O}XQgwhZ|bm2gG z#}!LACyuoP81}keYgo^QR%r3S$+LSLphEBk0E&%#V=>H?Ec1qB#~5C)jXrUKS;S?5 zge$iSe<|B%z|326JWxO>plgHuXsBQiZWpKx7v{?am6y)74{L&s%f|_%_9dy*WC5M> z8nh!r=GG-lorljfc7n?CZ{JvoBS7sp4NJC1FR1M5E=CEi@%Jtlu zKE8s|H0)Nf0aYMHq&f_PlLRoOY$!-93QuB?yd@VN($;3*Yt?kUfwDa!QxKqW7Sg3gW@IK|=%{*DL}`Cw6`cn;70us_if{Oz@gUZ* z3pKQREU6Qwpy(grb%}Xx$u>O?Zm?pC0rdgbPbkTF#S3^p6Ga!TCNR5wF!)(T z1*!orVc`2vXtx>|s4mHBu(QZ)?o?~G)Z2YZbDcjv zQehw(1LTYVc`URy{i1`_Rdng9w!jxU@x6H!-}I<{yBca|foV{iZvB+5pF05!DnQg1 z%JakYZCn5%Y|ctWDbWj^H9)Cw(kdLzXlZ9Yqz5Wm$~YE>tHb2-O=bG-fer^KnC3Si zEJmiaHUOL?^Nmtw4$4YzVAzVT1OO^)QhI2Q2?`kHCU0Iw{0f2)@en__`x7GU)D)rdUMi{OKG9_6 zGxfZTN7#QEQ!4QUO5QE1I_hPUDT}8yhp88+j@ZFzQLWwG4a0ekJMp`M zkH3#;DcTvFD8T^P_L7A(F5treEZZywm#k`{V#9#X!*7rU!Sa!dPnYhlOKwG#|7g!Qof!Kd zP~J(wmn{Pjt9RqjZ-fmoY+crXL888kSY5uX70);6Ky`XO4>w^C*CLC9||sqp3VP9g$~!2DA2e1)Gug2U;lcj__CWMZmN!td!3$<}>EW?x7k z``bgdjd`Cn1N~9uH;V@~&DIUvr>C7wmrruv&Aq=^h<4`P{<`v$s=D~u#zc@RP3{>@z=#I^1%cm-xBEUsM!Xw66fJBTx}$scPJh^NY{%UIBb zMmfET=gi2zV@yv#4+qU#E|+y$&vvCkQ>+$4QP(|`%-Cn(U*7@~>;imZyflDrc1}G$ z#eKd|5Xv0C&!}IPzyi#h7bLXT*-9=eXP?z~d`{fCPtuh0LZbwwRa>_1dG9W~zNai|l|6Bsv4RUP z)7aO@iThF|w%5#Hd=MKI{p~u(^8+PflO=T-gc(~hVa(#TaL^bxYxwITZ zx*zUX=j$z8W;H06ZjC%|xC&fyK#e$pmZ7vZ$@V-GD;j|J$CL`%H?W!!#*9rD8dh;Y z1sB<;QL~2j6VOQ+&NA;^;d~!+-E{Tz=g?{q{_lvl0jOuZ?mtxMK3P)God;@|M}b(J zizTsN3Cub$T5@`s{pW3d8{tx=D(qNt`FpI9+(?}51tYT)v)@)*Y^}{YW=hhua`P(0 zvgf;Q#fu~|vukp-@H`@0@7$df7m*LUusbK}4_dOWot^HQe;_ieZH9A}Coc08oKW~T z1gbB_0_OQG645S^1WwnL$X@eAOz$M#YV@~v68x^^(gZM_svtYy0g~&00_HCWN@;}D&PaC)ms;0hrllbIwf0x- zlfKV?4DbuQe5USwi%yV?=7ml#i@Hz`h4mh@i=f4gQwDMzgnYj)P{BMp2!pwt;JfI8 zY1MTY!;>a4CYLVwv^`pXS9<%(O)Zpmi}1-CH!n9^m95nx$g>D%gqq~_D?QVR*INWf zEaW;|s8WEVR+QPh?y*q%ZLUgK^s}E4YGvu{VW%G)>T{UTK@A9u9N_r=%I08-wLu2< zu3dLEhoO#j_5tw;VE}O>pwQ?X4ScQ$KBiT@+AFi4>Y7V`hE>z z1L$FCX%;pkmIz9ma{Z!%yl6jfkwDhGg{juJX3L%y<=(HsYicj6lW%)}dptFvPuklgK>W}uD^SK%5$??)PcTsS1oxl(lQRJz*GU?@n*FyXBjU=d^65!X_cxe|} z2J-B7zX*<9={BZ%RK2(Bft>3}gff&qJMZI3*RihXd?!692Iy=4V{_FXZ4-D#ta!BZ zC&)`@GFInSH9*e=!lb5Hdh1!Y$#~7Cm4=NtBgbO>TCI&NRFcy|KlDnyJ{KtcGpX|p zdPDnpp*weAVcNa_F-x59Hr?iO-QqlNnL^Co1xh$5?7arPZc$EJs(dpyl+)3yO z#qrmm9>>-`IL9!EF%dGX6cs>X5nblh(zr#6b|h~DCE8JnaqEo}Z#Ro9=^aAy1zVEJ9P|`N4JLyi6>oSAPV|q zTi`XAAkWA=X>nhOvz=&2Q!`@_NDk5y54`D$pq9BGzTvL?UIK!Lqk)yWXv+|=o)C%7?bjVDmjyy=1&saQeKWd6T_{okN`#fTP2>GRWUAP z%~ok}B>7WK&fdRg ziO!~eR@p{?gxY&j_#Z^~~Vwm{N0+PZ% z!K>9op_W>=^K&%a9<+6WiYhy`perezrtTWV>(jM7o=G?BfmSZ-A^8KDzzJ@aEq;FzJ%ELfMHDjg)SxYDYj~R_!NLLN1T5MO};hSU=p3nF}0sB z+N(ANC8D12wrPiW&PW zK7nZQ$rrJ7znS577{Y!zg0MsozT^*Xjme-fqGD`T5+ioJXiOI~9P9}tHHg95oo+r( z=rmH|po!reXs-s=gnGY^ng$e)74!NQf&Ka*hvGtl1zx~NFz7@Z{&IpR;tog_SRnj^ zu-Ap#0%Wx()g5&ZzOGiiaq(Pd;Ape{L6RTfmyP)Ox1K>hh5?Fh{a{x1$FdTSzTJ7H z!8$47*t)y4Amx)heS#KyokS(DwN@0*sUEAg#FrWIG*UVHQp5$5bHjjm{ZXA$EILpz zOq!-6<|Z-rod(!c2jJs}ve6Xv6kr|6wD|=-3&5C&kL>0|*z5igP9gz7%S4cS9}>+? zwg9(3DjTj`#%2zXXL`Kx;O&+aAS+}-^(OOz^OQQgDjN@Lf`6n_P)yYnG~_#bm*@t5A%}1v%oU5uut0` zfIqP97YwS(Z`AHN$XGSGP!bl}s?Hnp8C z{yW5=X6SE?^DAZkQXBtiAy8k0ebfI+sQ*OhKN0%v+W&w0CO*?sXcfbul_NpRZ#kB) zGxNJM{VTod?LgUK72cnA5iMN6yj22}PJLHTxb<(x4(O*RC+7w$bU~C+Gfpzlr^Uks zJP1?odzRaPV;dyMBSH5B-FWqXC~Eoh;o+sYAf^{3zV}mVrYke9lqew);LP~a6r5n1 z*+pM7Ael32Z-48t{;L82ZvuQ-@!NK;n;?oA$m0_Q`e-YHKHv@Nb2~h0MkMmGMaEkPH7R?=-1kTt>M@-bZVdgKJycJtv!@@g6(qd7stZ{Bpx>;%w0{H6or zsiiS|7_o#?racH0zvt7mLT4$K%siL51F&J+b@mO0Ni*hWQSr}h6fxDTE#i|6*SIVkc*768(*LR4;qSLY{1znA>`82&=(zyJ1sm6Cq@wk(Qdts9p3lKN{E@P7g*HIAC<{r6@`)OPTq%Jw7X7XR;QHz@4j7ha^{{_AF$ zzQ%=XQA&S5_=q1q`S}{J8A((B8xbkY3C_bZvmK8Zf%`vtkl~+)9cRjrIAV{#i0CW9 zKCET!m2$+0(*M!Jj$v3!^qeZeziy^;tj=Zfe~BiaA<`1>XQ l6QTb^=${4c|FQ@fIgd4%3+vT|0Huc`cUwt1>z0xK{|A*)=70bI literal 0 HcmV?d00001 From ec6dcdb6b88b35c8645beebd640e7dda816f2828 Mon Sep 17 00:00:00 2001 From: Qiu Qin Date: Fri, 20 Dec 2024 14:31:04 -0500 Subject: [PATCH 58/58] Fix typo in docs. --- .../user_guide/large_language_model/autogen_integration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/user_guide/large_language_model/autogen_integration.rst b/docs/source/user_guide/large_language_model/autogen_integration.rst index 64d2a018c..7c8c17055 100644 --- a/docs/source/user_guide/large_language_model/autogen_integration.rst +++ b/docs/source/user_guide/large_language_model/autogen_integration.rst @@ -163,7 +163,7 @@ allowing you to integrate AutoGen application with OCI monitoring service to `bu from ads.llm.autogen.v02 import runtime_logging from ads.llm.autogen.v02.loggers import MetricLogger - monitoring_logger = AgentMonitoring( + monitoring_logger = MetricLogger( # Metric namespace required by OCI monitoring. namespace="", # Optional application name, which will be a metric dimension if specified.