chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import ast
|
||||
from types import NoneType
|
||||
from typing import get_args
|
||||
|
||||
from pydantic import BaseModel, ValidationInfo, field_validator
|
||||
|
||||
|
||||
class BaseModelLLM(BaseModel):
|
||||
"""A Pydantic base class for use when an LLM is completing fields. Provides a custom field validator and Pydantic Config."""
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
def parse_literal_eval(cls, value: str, info: ValidationInfo): # noqa: N805
|
||||
"""An LLM will always result in a string (e.g. '["x", "y"]'), so we need to parse it to the correct type"""
|
||||
# Get the type hints for the field
|
||||
annotation = cls.model_fields[info.field_name].annotation
|
||||
typehints = get_args(annotation)
|
||||
if len(typehints) == 0:
|
||||
typehints = [annotation]
|
||||
|
||||
# Usually fields that are NoneType have another type hint as well, e.g. str | None
|
||||
# if the LLM returns "None" and the field allows NoneType, we should return None
|
||||
# without this code, the next if-block would leave the string "None" as the value
|
||||
if (NoneType in typehints) and (value == "None"):
|
||||
return None
|
||||
|
||||
# If the field allows strings, we don't parse it - otherwise a validation error might be raised
|
||||
# e.g. phone_number = "1234567890" should not be converted to an int if the type hint is str
|
||||
if str in typehints:
|
||||
return value
|
||||
try:
|
||||
evaluated_value = ast.literal_eval(value)
|
||||
return evaluated_value
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
class Config:
|
||||
# Ensure that validation happens every time a field is updated, not just when the artifact is created
|
||||
validate_assignment = True
|
||||
# Do not allow extra fields to be added to the artifact
|
||||
extra = "forbid"
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import datetime
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import Union
|
||||
|
||||
from semantic_kernel.contents import ChatMessageContent
|
||||
|
||||
|
||||
class ConversationMessageType(Enum):
|
||||
DEFAULT = "default"
|
||||
ARTIFACT_UPDATE = "artifact-update"
|
||||
REASONING = "reasoning"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conversation:
|
||||
"""An abstraction to represent a list of messages and common operations such as adding messages
|
||||
and getting a string representation.
|
||||
|
||||
Args:
|
||||
conversation_messages (list[ChatMessageContent]): A list of ChatMessageContent objects.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
conversation_messages: list[ChatMessageContent] = field(default_factory=list)
|
||||
|
||||
def add_messages(self, messages: Union[ChatMessageContent, list[ChatMessageContent], "Conversation", None]) -> None:
|
||||
"""Add a message, list of messages to the conversation or merge another conversation into the end of this one.
|
||||
|
||||
Args:
|
||||
messages (Union[ChatMessageContent, list[ChatMessageContent], "Conversation"]): The message(s) to add.
|
||||
All messages will be added to the end of the conversation.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if isinstance(messages, list):
|
||||
self.conversation_messages.extend(messages)
|
||||
elif isinstance(messages, Conversation):
|
||||
self.conversation_messages.extend(messages.conversation_messages)
|
||||
elif isinstance(messages, ChatMessageContent):
|
||||
# if ChatMessageContent.metadata doesn't have type, then add default
|
||||
if "type" not in messages.metadata:
|
||||
messages.metadata["type"] = ConversationMessageType.DEFAULT
|
||||
self.conversation_messages.append(messages)
|
||||
else:
|
||||
self.logger.warning(f"Invalid message type: {type(messages)}")
|
||||
return None
|
||||
|
||||
def get_repr_for_prompt(
|
||||
self,
|
||||
exclude_types: list[ConversationMessageType] | None = None,
|
||||
) -> str:
|
||||
"""Create a string representation of the conversation history for use in LLM prompts.
|
||||
|
||||
Args:
|
||||
exclude_types (list[ConversationMessageType] | None): A list of message types to exclude from the conversation
|
||||
history. If None, all message types will be included.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the conversation history.
|
||||
"""
|
||||
if len(self.conversation_messages) == 0:
|
||||
return "None"
|
||||
|
||||
# Do not include the excluded messages types in the conversation history repr.
|
||||
if exclude_types is not None:
|
||||
conversation_messages = [
|
||||
message
|
||||
for message in self.conversation_messages
|
||||
if "type" in message.metadata and message.metadata["type"] not in exclude_types
|
||||
]
|
||||
else:
|
||||
conversation_messages = self.conversation_messages
|
||||
|
||||
to_join = []
|
||||
current_turn = None
|
||||
for message in conversation_messages:
|
||||
participant_name = message.name
|
||||
# Modify the default user to be capitalized for consistency with how assistant is written.
|
||||
if participant_name == "user":
|
||||
participant_name = "User"
|
||||
|
||||
# If the turn number is None, don't include it in the string
|
||||
if "turn_number" in message.metadata and current_turn != message.metadata["turn_number"]:
|
||||
current_turn = message.metadata["turn_number"]
|
||||
to_join.append(f"[Turn {current_turn}]")
|
||||
|
||||
# Add the message content
|
||||
if (message.role == "assistant") and (
|
||||
"type" in message.metadata and message.metadata["type"] == ConversationMessageType.ARTIFACT_UPDATE
|
||||
):
|
||||
to_join.append(message.content)
|
||||
elif message.role == "assistant":
|
||||
to_join.append(f"Assistant: {message.content}")
|
||||
else:
|
||||
user_string = message.content.strip()
|
||||
if user_string == "":
|
||||
to_join.append(f"{participant_name}: <sent an empty message>")
|
||||
else:
|
||||
to_join.append(f"{participant_name}: {user_string}")
|
||||
conversation_string = "\n".join(to_join)
|
||||
return conversation_string
|
||||
|
||||
def set_turn_numbers(self, turn_number: int) -> None:
|
||||
"""Set all the turn numbers in the conversation to the given turn number.
|
||||
|
||||
Args:
|
||||
turn_number (int): The turn number to set for all messages.
|
||||
|
||||
Returns:
|
||||
None"""
|
||||
for message in self.conversation_messages:
|
||||
message.metadata["turn_number"] = turn_number
|
||||
|
||||
@staticmethod
|
||||
def message_to_json(message: ChatMessageContent) -> dict:
|
||||
"""
|
||||
Convert a ChatMessageContent object to a JSON serializable dictionary.
|
||||
|
||||
Args:
|
||||
message (ChatMessageContent): The ChatMessageContent object to convert to JSON.
|
||||
|
||||
Returns:
|
||||
dict: A JSON serializable dictionary representation of the ChatMessageContent object.
|
||||
"""
|
||||
return {
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"name": message.name,
|
||||
"metadata": {
|
||||
"turn_number": message.metadata.get("turn_number", None),
|
||||
"type": message.metadata.get("type", ConversationMessageType.DEFAULT),
|
||||
},
|
||||
}
|
||||
|
||||
def to_json(self) -> dict:
|
||||
json_data = {}
|
||||
json_data["conversation"] = {}
|
||||
json_data["conversation"]["conversation_messages"] = [
|
||||
self.message_to_json(message) for message in self.conversation_messages
|
||||
]
|
||||
return json_data
|
||||
|
||||
@classmethod
|
||||
def from_json(
|
||||
cls,
|
||||
json_data: dict,
|
||||
) -> "Conversation":
|
||||
conversation = cls()
|
||||
for message in json_data["conversation"]["conversation_messages"]:
|
||||
conversation.add_messages(
|
||||
ChatMessageContent(
|
||||
role=message["role"],
|
||||
content=message["content"],
|
||||
name=message["name"],
|
||||
metadata={
|
||||
"turn_number": message["turn_number"],
|
||||
"type": ConversationMessageType(message["type"]),
|
||||
"timestamp": datetime.datetime.fromisoformat(message["timestamp"]),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return conversation
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.functions import FunctionResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolArg:
|
||||
argument_name: str
|
||||
required: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
name: str
|
||||
args: list[ToolArg]
|
||||
|
||||
|
||||
class ToolValidationResult(Enum):
|
||||
NO_TOOL_CALLED = "No tool was called"
|
||||
INVALID_TOOL_CALLED = "A tool was called with an unexpected name"
|
||||
MISSING_REQUIRED_ARGUMENT = "The tool called is missing a required argument"
|
||||
INVALID_ARGUMENT_TYPE = "The value of an argument is of an unexpected type"
|
||||
SUCCESS = "success"
|
||||
|
||||
|
||||
def parse_function_result(response: FunctionResult) -> dict[str, Any]:
|
||||
"""Parse the response from SK's FunctionResult object into only the relevant data for easier downstream processing.
|
||||
This should only be used when you expect the response to contain tool calls.
|
||||
|
||||
Args:
|
||||
response (FunctionResult): The response from the kernel.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The parsed response data with the following format if n was set greater than 1:
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"tool_names": list[str],
|
||||
"tool_args_list": list[dict[str, Any]],
|
||||
"message": str,
|
||||
"finish_reason": str,
|
||||
"validation_result": ToolValidationResult
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
Otherwise, the response will directly contain the data from the first choice (tool_names, etc keys)
|
||||
"""
|
||||
response_data: dict[str, Any] = {"choices": []}
|
||||
for response_choice in response.value:
|
||||
response_data_curr = {}
|
||||
finish_reason = response_choice.finish_reason
|
||||
|
||||
if finish_reason == "tool_calls":
|
||||
tool_names = []
|
||||
tool_args_list = []
|
||||
# Only look at the items that are of instance `FunctionCallContent`
|
||||
tool_calls = [item for item in response_choice.items if isinstance(item, FunctionCallContent)]
|
||||
for tool_call in tool_calls:
|
||||
if "-" not in tool_call.name:
|
||||
logger.info(f"Tool call name {tool_call.name} does not match naming convention - modifying name.")
|
||||
tool_names.append(tool_call.name + "-" + tool_call.name)
|
||||
else:
|
||||
tool_names.append(tool_call.name)
|
||||
try:
|
||||
tool_args = json.loads(tool_call.arguments)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse tool arguments for tool call {tool_call.name}. Using empty dict.")
|
||||
tool_args = {}
|
||||
tool_args_list.append(tool_args)
|
||||
response_data_curr["tool_names"] = tool_names
|
||||
response_data_curr["tool_args_list"] = tool_args_list
|
||||
|
||||
response_data_curr["message"] = response_choice.content
|
||||
response_data_curr["finish_reason"] = finish_reason
|
||||
response_data["choices"].append(response_data_curr)
|
||||
|
||||
if len(response_data["choices"]) == 1:
|
||||
response_data.update(response_data["choices"][0])
|
||||
del response_data["choices"]
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
def construct_tool_objects(kernel_function_tools: dict) -> list[Tool]:
|
||||
"""Construct a list of Tool objects from the kernel function tools definition.
|
||||
|
||||
Args:
|
||||
kernel_function_tools (dict): The definition of tools done by the kernel function.
|
||||
|
||||
Returns:
|
||||
list[Tool]: The list of Tool objects constructed from the kernel function tools definition.
|
||||
"""
|
||||
|
||||
tool_objects: list[Tool] = []
|
||||
for tool_definition in kernel_function_tools:
|
||||
tool_name = tool_definition["function"]["name"]
|
||||
tool_args = tool_definition["function"]["parameters"]["properties"]
|
||||
|
||||
tool_arg_objects: list[ToolArg] = []
|
||||
for argument_name, _ in tool_args.items():
|
||||
tool_arg = ToolArg(argument_name=argument_name, required=False)
|
||||
tool_arg_objects.append(tool_arg)
|
||||
|
||||
required_args = tool_definition["function"]["parameters"]["required"]
|
||||
for tool_arg_object in tool_arg_objects:
|
||||
if tool_arg_object.argument_name in required_args:
|
||||
tool_arg_object.required = True
|
||||
|
||||
tool_objects.append(Tool(name=tool_name, args=tool_arg_objects))
|
||||
return tool_objects
|
||||
|
||||
|
||||
def validate_tool_calling(response: dict[str, Any], request_tool_param: dict) -> ToolValidationResult:
|
||||
"""Validate that the response from the LLM called tools corrected.
|
||||
1. Check if any tool was called.
|
||||
2. Check if the tools called were valid (names match)
|
||||
3. Check if all the required arguments were passed.
|
||||
|
||||
Args:
|
||||
response (dict[str, Any]): The response from the LLM containing the tools called (output of parse_function_result)
|
||||
tools (list[Tool]): The list of tools that can be called by the model.
|
||||
|
||||
Returns:
|
||||
ToolValidationResult: The result of the validation. ToolValidationResult.SUCCESS if the validation passed.
|
||||
"""
|
||||
|
||||
tool_objects = construct_tool_objects(request_tool_param)
|
||||
tool_names = response.get("tool_names", [])
|
||||
tool_args_list = response.get("tool_args_list", [])
|
||||
|
||||
# Check if any tool was called.
|
||||
if not tool_names:
|
||||
logger.info("No tool was called.")
|
||||
return ToolValidationResult.NO_TOOL_CALLED
|
||||
|
||||
for tool_name, tool_args in zip(tool_names, tool_args_list, strict=True):
|
||||
# Check the tool names is valid.
|
||||
tool: Tool | None = next((t for t in tool_objects if t.name == tool_name), None)
|
||||
if not tool:
|
||||
logger.warning(f"Invalid tool called: {tool_name}")
|
||||
return ToolValidationResult.INVALID_TOOL_CALLED
|
||||
|
||||
for arg in tool.args:
|
||||
# Check if the required arguments were passed.
|
||||
if arg.required and arg.argument_name not in tool_args:
|
||||
logger.warning(f"Missing required argument '{arg.argument_name}' for tool '{tool_name}'.")
|
||||
return ToolValidationResult.MISSING_REQUIRED_ARGUMENT
|
||||
|
||||
return ToolValidationResult.SUCCESS
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import ValidationError
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
|
||||
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
|
||||
from semantic_kernel.contents import ChatMessageContent
|
||||
from semantic_kernel.functions import KernelArguments
|
||||
|
||||
from guided_conversation.utils.openai_tool_calling import parse_function_result, validate_tool_calling
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginOutput:
|
||||
"""A wrapper for all Guided Conversation Plugins. This class is used to return the output of a generic plugin.
|
||||
|
||||
Args:
|
||||
update_successful (bool): Whether the update was successful.
|
||||
messages (list[ChatMessageContent]): A list of messages to be used at the user's digression, it
|
||||
contains information about the process of calling the plugin.
|
||||
"""
|
||||
|
||||
update_successful: bool
|
||||
messages: list[ChatMessageContent]
|
||||
|
||||
|
||||
def format_kernel_functions_as_tools(kernel: Kernel, functions: list[str]):
|
||||
"""Format kernel functions as JSON schemas for custom validation."""
|
||||
formatted_tools = []
|
||||
for _, kernel_plugin_def in kernel.plugins.items():
|
||||
for function_name, function_def in kernel_plugin_def.functions.items():
|
||||
if function_name in functions:
|
||||
func_metadata = function_def.metadata
|
||||
formatted_tools.append(kernel_function_metadata_to_function_call_format(func_metadata))
|
||||
return formatted_tools
|
||||
|
||||
|
||||
async def fix_error(
|
||||
kernel: Kernel, prompt_template: str, req_settings: AzureChatCompletion, arguments: KernelArguments
|
||||
) -> dict:
|
||||
"""Invokes the error correction plugin. If a plugin is called & fails during execution, this function will retry
|
||||
the plugin. At a high level, we recommend the following steps when calling a plugin:
|
||||
1. Call the plugin.
|
||||
2. Parse the response.
|
||||
3. Validate the response.
|
||||
4. If the response is invalid (Validation or Value Error), retry the plugin by calling *this function*. For best
|
||||
results, check out plugins/agenda.py or plugins/artifact.py for examples of prompt templates & corresponding
|
||||
tools (which should be passed in the req_settings object). This function will handle the retry logic for you.
|
||||
|
||||
Args:
|
||||
kernel (Kernel): The kernel object.
|
||||
prompt_template (str): The prompt template for the plugin.
|
||||
req_settings (AzureChatCompletion): The prompt execution settings.
|
||||
arguments (KernelArguments): The kernel arguments.
|
||||
|
||||
Returns:
|
||||
dict: The result of the plugin call.
|
||||
"""
|
||||
kernel_function_obj = kernel.add_function(
|
||||
prompt=prompt_template,
|
||||
function_name="error_correction",
|
||||
plugin_name="error_correction",
|
||||
template_format="handlebars",
|
||||
prompt_execution_settings=req_settings,
|
||||
)
|
||||
|
||||
result = await kernel.invoke(function=kernel_function_obj, arguments=arguments)
|
||||
parsed_result = parse_function_result(result)
|
||||
|
||||
formatted_tools = []
|
||||
for _, kernel_plugin_def in kernel.plugins.items():
|
||||
for _, function_def in kernel_plugin_def.functions.items():
|
||||
func_metadata = function_def.metadata
|
||||
formatted_tools.append(kernel_function_metadata_to_function_call_format(func_metadata))
|
||||
|
||||
# Add any tools from req_settings
|
||||
if req_settings.tools:
|
||||
formatted_tools.extend(req_settings.tools)
|
||||
|
||||
validation_result = validate_tool_calling(parsed_result, formatted_tools)
|
||||
parsed_result["validation_result"] = validation_result
|
||||
return parsed_result
|
||||
|
||||
|
||||
def update_attempts(error: Exception, attempt_id: str, previous_attempts: list) -> str:
|
||||
"""
|
||||
Updates the plugin class attribute list of previous attempts with the current attempt and error message
|
||||
(including duplicates).
|
||||
|
||||
Args:
|
||||
error (Exception): The error object.
|
||||
attempt_id (str): The ID of the current attempt.
|
||||
previous_attempts (list): The list of previous attempts.
|
||||
|
||||
Returns:
|
||||
str: A formatted (optimized for LLM performance) string of previous attempts, with duplicates removed.
|
||||
"""
|
||||
if isinstance(error, ValidationError):
|
||||
error_str = "; ".join([e.get("msg") for e in error.errors()])
|
||||
# replace "; Input should be 'Unanswered'" with " or input should be 'Unanswered'" for clarity
|
||||
error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'")
|
||||
else:
|
||||
error_str = str(error)
|
||||
new_failed_attempt = (attempt_id, error_str)
|
||||
previous_attempts.append(new_failed_attempt)
|
||||
|
||||
# Format previous attempts to be more friendly for the LLM
|
||||
attempts_list = []
|
||||
unique_attempts = set(previous_attempts)
|
||||
for attempt, error in unique_attempts:
|
||||
attempts_list.append(f"Attempt: {attempt}\nError: {error}")
|
||||
llm_formatted_attempts = "\n".join(attempts_list)
|
||||
|
||||
return previous_attempts, llm_formatted_attempts
|
||||
@@ -0,0 +1,251 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import Enum
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ResourceConstraintUnit(Enum):
|
||||
"""Choose the unit of the resource constraint.
|
||||
Seconds and Minutes are real-time and will be impacted by the latency of the model."""
|
||||
|
||||
SECONDS = "seconds"
|
||||
MINUTES = "minutes"
|
||||
TURNS = "turns"
|
||||
|
||||
|
||||
class ResourceConstraintMode(Enum):
|
||||
"""Choose how the agent should use the resource.
|
||||
Maximum: is an upper bound, i.e. the agent can end the conversation before the resource is exhausted
|
||||
Exact: the agent should aim to use exactly the given amount of the resource"""
|
||||
|
||||
MAXIMUM = "maximum"
|
||||
EXACT = "exact"
|
||||
|
||||
|
||||
class ResourceConstraint(BaseModel):
|
||||
"""A structured representation of the resource constraint for the GuidedConversation agent.
|
||||
|
||||
Args:
|
||||
quantity (float | int): The quantity of the resource constraint.
|
||||
unit (ResourceConstraintUnit): The unit of the resource constraint.
|
||||
mode (ResourceConstraintMode): The mode of the resource constraint.
|
||||
"""
|
||||
|
||||
quantity: float | int
|
||||
unit: ResourceConstraintUnit
|
||||
mode: ResourceConstraintMode
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
def format_resource(quantity: float, unit: ResourceConstraintUnit) -> str:
|
||||
"""Get formatted string for a given quantity and unit (e.g. 1 second, 20 seconds)"""
|
||||
if unit != ResourceConstraintUnit.TURNS:
|
||||
quantity = round(quantity, 1)
|
||||
unit = unit.value
|
||||
return f"{quantity} {unit[:-1] if quantity == 1 else unit}"
|
||||
|
||||
|
||||
class GCResource:
|
||||
"""Resource constraints for the GuidedConversation agent. This class is used to keep track of the resource
|
||||
constraints. If resource_constraint is None, then the agent can continue indefinitely. This also means
|
||||
that no agenda will be created for the conversation.
|
||||
|
||||
Args:
|
||||
resource_constraint (ResourceConstraint | None): The resource constraint for the conversation.
|
||||
initial_seconds_per_turn (int): The initial number of seconds per turn. Defaults to 120 seconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_constraint: ResourceConstraint | None,
|
||||
initial_seconds_per_turn: int = 120,
|
||||
):
|
||||
logger = logging.getLogger(__name__)
|
||||
self.logger = logger
|
||||
self.resource_constraint: ResourceConstraint | None = resource_constraint
|
||||
self.initial_seconds_per_turn: int = initial_seconds_per_turn
|
||||
|
||||
self.turn_number: int = 0
|
||||
self.remaining_units: float | None = None
|
||||
self.elapsed_units: float | None = None
|
||||
|
||||
if resource_constraint is not None:
|
||||
self.elapsed_units = 0
|
||||
self.remaining_units = resource_constraint.quantity
|
||||
|
||||
def start_resource(self) -> None:
|
||||
"""To be called at the start of a conversation turn"""
|
||||
if self.resource_constraint is not None and (
|
||||
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
|
||||
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
|
||||
):
|
||||
self.start_time = time.time()
|
||||
|
||||
def increment_resource(self) -> None:
|
||||
"""Increment the resource counter by one turn."""
|
||||
if self.resource_constraint is not None:
|
||||
if self.resource_constraint.unit == ResourceConstraintUnit.SECONDS:
|
||||
self.elapsed_units += time.time() - self.start_time
|
||||
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
|
||||
elif self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
|
||||
self.elapsed_units += (time.time() - self.start_time) / 60
|
||||
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
|
||||
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
|
||||
self.elapsed_units += 1
|
||||
self.remaining_units -= 1
|
||||
|
||||
self.turn_number += 1
|
||||
|
||||
def get_resource_mode(self) -> ResourceConstraintMode:
|
||||
"""Get the mode of the resource constraint.
|
||||
|
||||
Returns:
|
||||
ResourceConstraintMode | None: The mode of the resource constraint, or None if there is no
|
||||
resource constraint.
|
||||
"""
|
||||
return self.resource_constraint.mode if self.resource_constraint is not None else None
|
||||
|
||||
def get_elapsed_turns(self, formatted_repr: bool = False) -> str | int:
|
||||
"""Get the number of elapsed turns.
|
||||
|
||||
Args:
|
||||
formatted_repr (bool): If true, return a formatted string representation of the elapsed turns.
|
||||
If false, return an integer. Defaults to False.
|
||||
|
||||
Returns:
|
||||
str | int: The description/number of elapsed turns.
|
||||
"""
|
||||
if formatted_repr:
|
||||
return format_resource(self.turn_number, ResourceConstraintUnit.TURNS)
|
||||
else:
|
||||
return self.turn_number
|
||||
|
||||
def get_remaining_turns(self, formatted_repr: bool = False) -> str | int:
|
||||
"""Get the number of remaining turns.
|
||||
|
||||
Args:
|
||||
formatted_repr (bool): If true, return a formatted string representation of the remaining turns.
|
||||
|
||||
Returns:
|
||||
str | int: The description/number of remaining turns.
|
||||
"""
|
||||
if formatted_repr:
|
||||
return format_resource(self.estimate_remaining_turns(), ResourceConstraintUnit.TURNS)
|
||||
else:
|
||||
return self.estimate_remaining_turns()
|
||||
|
||||
def estimate_remaining_turns(self) -> int:
|
||||
"""Estimate the remaining turns based on the resource constraint, thereby translating certain
|
||||
resource units (e.g. seconds, minutes) into turns.
|
||||
|
||||
Returns:
|
||||
int: The estimated number of remaining turns.
|
||||
"""
|
||||
if self.resource_constraint is not None:
|
||||
if (
|
||||
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
|
||||
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
|
||||
):
|
||||
elapsed_turns = self.turn_number
|
||||
|
||||
# TODO: This can likely be simplified
|
||||
if self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
|
||||
time_per_turn = (
|
||||
self.initial_seconds_per_turn
|
||||
if elapsed_turns == 0
|
||||
else (self.elapsed_units * 60) / elapsed_turns
|
||||
)
|
||||
time_per_turn /= 60
|
||||
else:
|
||||
time_per_turn = (
|
||||
self.initial_seconds_per_turn if elapsed_turns == 0 else self.elapsed_units / elapsed_turns
|
||||
)
|
||||
remaining_turns = self.remaining_units / time_per_turn
|
||||
|
||||
# Round down, unless it's less than 1, in which case round up
|
||||
remaining_turns = math.ceil(remaining_turns) if remaining_turns < 1 else math.floor(remaining_turns)
|
||||
return remaining_turns
|
||||
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
|
||||
return self.resource_constraint.quantity - self.turn_number
|
||||
else:
|
||||
self.logger.error(
|
||||
"Resource constraint is not set, so turns cannot be estimated using function estimate_remaining_turns"
|
||||
)
|
||||
raise ValueError(
|
||||
"Resource constraint is not set. Do not try to call this method without a resource constraint."
|
||||
)
|
||||
|
||||
def get_resource_instructions(self) -> tuple[str, str]:
|
||||
"""Get the resource instructions for the conversation.
|
||||
|
||||
Assumes we're always using turns as the resource unit.
|
||||
|
||||
Returns:
|
||||
str: the resource instructions
|
||||
"""
|
||||
if self.resource_constraint is None:
|
||||
return ""
|
||||
|
||||
formatted_elapsed_resource = format_resource(self.elapsed_units, ResourceConstraintUnit.TURNS)
|
||||
formatted_remaining_resource = format_resource(self.remaining_units, ResourceConstraintUnit.TURNS)
|
||||
|
||||
# if the resource quantity is anything other than 1, the resource unit should be plural (e.g. "minutes" instead of "minute")
|
||||
is_plural_elapsed = self.elapsed_units != 1
|
||||
is_plural_remaining = self.remaining_units != 1
|
||||
|
||||
if self.elapsed_units > 0:
|
||||
resource_instructions = f"So far, {formatted_elapsed_resource} {'have' if is_plural_elapsed else 'has'} elapsed since the conversation began. "
|
||||
else:
|
||||
resource_instructions = ""
|
||||
|
||||
if self.resource_constraint.mode == ResourceConstraintMode.EXACT:
|
||||
exact_mode_instructions = f"""There {"are" if is_plural_remaining else "is"} {formatted_remaining_resource} remaining (including this one) - the conversation will automatically terminate when 0 turns are left. \
|
||||
You should continue the conversation until it is automatically terminated. This means you should NOT preemptively end the conversation, \
|
||||
either explicitly (by selecting the "End conversation" action) or implicitly (e.g. by telling the user that you have all required information and they should wait for the next step). \
|
||||
Your goal is not to maximize efficiency (i.e. complete the artifact as quickly as possible then end the conversation), but rather to make the best use of ALL remaining turns available to you"""
|
||||
|
||||
if is_plural_remaining:
|
||||
resource_instructions += f"""{exact_mode_instructions}. This will require you to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier, \
|
||||
or where you've rushed through the conversation and all fields are completed but there are still many turns left."""
|
||||
|
||||
# special instruction for the final turn (i.e. 1 remaining) in exact mode
|
||||
else:
|
||||
resource_instructions += f"""{exact_mode_instructions}, including this one. Therefore, you should use this turn to ask for any remaining information needed to complete the artifact, \
|
||||
or, if the artifact is already completed, continue to broaden/deepen the discussion in a way that's directly relevant to the artifact. Do NOT indicate to the user that the conversation is ending."""
|
||||
|
||||
elif self.resource_constraint.mode == ResourceConstraintMode.MAXIMUM:
|
||||
resource_instructions += f"""You have a maximum of {formatted_remaining_resource} (including this one) left to complete the conversation. \
|
||||
You can decide to terminate the conversation at any point (including now), otherwise the conversation will automatically terminate when 0 turns are left. \
|
||||
You will need to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier."""
|
||||
|
||||
else:
|
||||
self.logger.error("Invalid resource mode provided.")
|
||||
|
||||
return resource_instructions
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"turn_number": self.turn_number,
|
||||
"remaining_units": self.remaining_units,
|
||||
"elapsed_units": self.elapsed_units,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(
|
||||
cls,
|
||||
json_data: dict,
|
||||
) -> "GCResource":
|
||||
gc_resource = cls(
|
||||
resource_constraint=None,
|
||||
initial_seconds_per_turn=120,
|
||||
)
|
||||
gc_resource.turn_number = json_data["turn_number"]
|
||||
gc_resource.remaining_units = json_data["remaining_units"]
|
||||
gc_resource.elapsed_units = json_data["elapsed_units"]
|
||||
return gc_resource
|
||||
Reference in New Issue
Block a user