From d21c84e52637380b0d9014b7eb18d07ae71b7449 Mon Sep 17 00:00:00 2001 From: Mollie Munoz Date: Wed, 30 Oct 2024 21:21:00 +0000 Subject: [PATCH 1/6] Fix extra line that got missed --- .../assistant/agents/document_agent.py | 58 +------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/assistants/prospector-assistant/assistant/agents/document_agent.py b/assistants/prospector-assistant/assistant/agents/document_agent.py index 3e29f99c..0c5cb836 100644 --- a/assistants/prospector-assistant/assistant/agents/document_agent.py +++ b/assistants/prospector-assistant/assistant/agents/document_agent.py @@ -295,10 +295,7 @@ def _set_mode_draft_outline( context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, - ) -> bool: - # Retrieve Document Agent conversation state - state = _get_state(context) - + ) -> None: # Pre-requisites if self._state is None: logger.error("Document Agent state is None. Returning.") @@ -860,59 +857,6 @@ def _format_message(message: ConversationMessage, participants: list[Conversatio # endregion -# -# region Helpers -# -def _get_state(context: ConversationContext) -> State: - state_dict = _read_document_agent_conversation_state(context) - if state_dict is not None: - state = State(**state_dict) - else: - logger.info("Document Agent: no state found. Creating new state.") - state = State() - return state - - -def _set_state(context: ConversationContext, state: State) -> None: - _write_document_agent_conversation_state(context, state.model_dump()) - - -def _get_document_agent_conversation_storage_path(context: ConversationContext, filename: str | None = None) -> Path: - """ - Get the path to the directory for storing files. - """ - path = storage_directory_for_context(context) / "document_agent" - if filename: - path /= filename - return path - - -def _write_document_agent_conversation_state(context: ConversationContext, state: dict) -> None: - """ - Write the state to a file. - """ - json_data = json.dumps(state) - path = _get_document_agent_conversation_storage_path(context) - if not path.exists(): - path.mkdir(parents=True) - path = path / "state.json" - path.write_text(json_data) - - -def _read_document_agent_conversation_state(context: ConversationContext) -> dict | None: - """ - Read the state from a file. - """ - path = _get_document_agent_conversation_storage_path(context, "state.json") - if path.exists(): - try: - json_data = path.read_text() - return json.loads(json_data) - except Exception: - pass - return None - - ##### FROM NOTEBOOK # await document_skill.draft_outline(context=unused, openai_client=async_client, model=model) # From ccfe3ddc81117a6442e9daf2d149e0381819840d Mon Sep 17 00:00:00 2001 From: Mollie Munoz Date: Fri, 1 Nov 2024 20:49:04 +0000 Subject: [PATCH 2/6] ready for merge testing --- .../document/gc_attachment_check_config.py | 191 ++++++++++++++++++ .../gc_draft_outline_feedback_config.py | 190 +++++++++++++++++ .../agents/document/guided_conversation.py | 54 ++++- .../assistant/agents/document/status.py | 9 + .../assistant/agents/document_agent.py | 158 ++++++++++++--- .../attachments/_attachments.py | 27 +++ 6 files changed, 599 insertions(+), 30 deletions(-) create mode 100644 assistants/prospector-assistant/assistant/agents/document/gc_attachment_check_config.py create mode 100644 assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py create mode 100644 assistants/prospector-assistant/assistant/agents/document/status.py diff --git a/assistants/prospector-assistant/assistant/agents/document/gc_attachment_check_config.py b/assistants/prospector-assistant/assistant/agents/document/gc_attachment_check_config.py new file mode 100644 index 00000000..3083d740 --- /dev/null +++ b/assistants/prospector-assistant/assistant/agents/document/gc_attachment_check_config.py @@ -0,0 +1,191 @@ +import json +from typing import TYPE_CHECKING, Annotated, Any, Dict, List, Type + +from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit +from pydantic import BaseModel, Field, create_model +from semantic_workbench_assistant.config import UISchema + +from ... import helpers +from . import config_defaults as config_defaults +from .config import GuidedConversationAgentConfigModel + +if TYPE_CHECKING: + pass + + +# Artifact - The artifact is like a form that the agent must complete throughout the conversation. +# It can also be thought of as a working memory for the agent. +# We allow any valid Pydantic BaseModel class to be used. +class ArtifactModel(BaseModel): + final_response: str = Field(description="The final response from the agent to the user.") + conversation_status: str = Field(description="The status of the conversation.") + + +# Rules - These are the do's and don'ts that the agent should follow during the conversation. +rules = [ + "Terminate the conversation immediately if the user asks for harmful or inappropriate content.", + "Set the conversation_status to user_completed once you have provided a final_response.", +] + +# Conversation Flow (optional) - This defines in natural language the steps of the conversation. +conversation_flow = """1. Start by asking if the user has all their documents attached to the conversation that they +would like to use in drafting their outline. If any filenames are available, list those to the user to demonstrate you +know what they have already attached. If no filenames are available, let the user know no documents have been attached." +2. If the user attaches files, be sure to let them know all the filenames you are aware of that have been attached. +3.You want to reach the point that the user confirms all the docs they want attached have been attached. Once you interpret +the user's response as a confirmation, then go ahead and provide a final response. +4. Your final response should share the list of attachments being used and how they will be used. In this scenario they will be used +to construct a draft outline, which you will be requesting user feedback on. With this final response, the conversation_status must be +marked as user_completed. +""" + +# Context (optional) - This is any additional information or the circumstances the agent is in that it should be aware of. +# It can also include the high level goal of the conversation if needed. +context = """The purpose of gathering these attachments is for the future user need to draft an outline. The purpose of this conversation +is to make sure the user is aware of what documents they have uploaded as attachments and if they need to upload anymore before +the user proceeds to drafting the outline.""" + + +# Resource Constraints (optional) - This defines the constraints on the conversation such as time or turns. +# It can also help with pacing the conversation, +# For example, here we have set an exact time limit of 10 turns which the agent will try to fill. +resource_constraint = ResourceConstraint( + quantity=5, + unit=ResourceConstraintUnit.TURNS, + mode=ResourceConstraintMode.MAXIMUM, +) + + +# +# region Helpers +# + +# take a full json schema and return a pydantic model, including support for +# nested objects and typed arrays + + +def json_type_to_python_type(json_type: str) -> Type: + # Mapping JSON types to Python types + type_mapping = {"integer": int, "string": str, "number": float, "boolean": bool, "object": dict, "array": list} + return type_mapping.get(json_type, Any) + + +def create_pydantic_model_from_json_schema(schema: Dict[str, Any], model_name="DynamicModel") -> Type[BaseModel]: + # Nested function to parse properties from the schema + def parse_properties(properties: Dict[str, Any]) -> Dict[str, Any]: + fields = {} + for prop_name, prop_attrs in properties.items(): + prop_type = prop_attrs.get("type") + description = prop_attrs.get("description", None) + + if prop_type == "object": + nested_model = create_pydantic_model_from_json_schema(prop_attrs, model_name=prop_name.capitalize()) + fields[prop_name] = (nested_model, Field(..., description=description)) + elif prop_type == "array": + items = prop_attrs.get("items", {}) + if items.get("type") == "object": + nested_model = create_pydantic_model_from_json_schema(items) + fields[prop_name] = (List[nested_model], Field(..., description=description)) + else: + nested_type = json_type_to_python_type(items.get("type")) + fields[prop_name] = (List[nested_type], Field(..., description=description)) + else: + python_type = json_type_to_python_type(prop_type) + fields[prop_name] = (python_type, Field(..., description=description)) + return fields + + properties = schema.get("properties", {}) + fields = parse_properties(properties) + return create_model(model_name, **fields) + + +# endregion + + +# +# region Models +# + + +class GCAttachmentCheckConfigModel(GuidedConversationAgentConfigModel): + enabled: Annotated[ + bool, + Field(description=helpers.load_text_include("guided_conversation_agent_enabled.md")), + UISchema(enable_markdown_in_description=True), + ] = False + + artifact: Annotated[ + str, + Field( + title="Artifact", + description="The artifact that the agent will manage.", + ), + UISchema(widget="baseModelEditor"), + ] = json.dumps(ArtifactModel.model_json_schema(), indent=2) + + rules: Annotated[ + list[str], + Field(title="Rules", description="Do's and don'ts that the agent should attempt to follow"), + UISchema(schema={"items": {"ui:widget": "textarea", "ui:options": {"rows": 2}}}), + ] = rules + + conversation_flow: Annotated[ + str, + Field( + title="Conversation Flow", + description="A loose natural language description of the steps of the conversation", + ), + UISchema(widget="textarea", schema={"ui:options": {"rows": 10}}, placeholder="[optional]"), + ] = conversation_flow.strip() + + context: Annotated[ + str, + Field( + title="Context", + description="General background context for the conversation.", + ), + UISchema(widget="textarea", placeholder="[optional]"), + ] = context.strip() + + class ResourceConstraint(ResourceConstraint): + mode: Annotated[ + ResourceConstraintMode, + Field( + title="Resource Mode", + description=( + 'If "exact", the agents will try to pace the conversation to use exactly the resource quantity. If' + ' "maximum", the agents will try to pace the conversation to use at most the resource quantity.' + ), + ), + ] = resource_constraint.mode + + unit: Annotated[ + ResourceConstraintUnit, + Field( + title="Resource Unit", + description="The unit for the resource constraint.", + ), + ] = resource_constraint.unit + + quantity: Annotated[ + float, + Field( + title="Resource Quantity", + description="The quantity for the resource constraint. If <=0, the resource constraint is disabled.", + ), + ] = resource_constraint.quantity + + resource_constraint: Annotated[ + ResourceConstraint, + Field( + title="Resource Constraint", + ), + UISchema(schema={"quantity": {"ui:widget": "updown"}}), + ] = ResourceConstraint() + + def get_artifact_model(self) -> Type[BaseModel]: + schema = json.loads(self.artifact) + return create_pydantic_model_from_json_schema(schema) + + +# endregion diff --git a/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py b/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py new file mode 100644 index 00000000..6b68d8c0 --- /dev/null +++ b/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py @@ -0,0 +1,190 @@ +import json +from typing import TYPE_CHECKING, Annotated, Any, Dict, List, Type + +from guided_conversation.utils.resources import ResourceConstraint, ResourceConstraintMode, ResourceConstraintUnit +from pydantic import BaseModel, Field, create_model +from semantic_workbench_assistant.config import UISchema + +from ... import helpers +from . import config_defaults as config_defaults +from .config import GuidedConversationAgentConfigModel + +if TYPE_CHECKING: + pass + + +# Artifact - The artifact is like a form that the agent must complete throughout the conversation. +# It can also be thought of as a working memory for the agent. +# We allow any valid Pydantic BaseModel class to be used. +class ArtifactModel(BaseModel): + final_response: str = Field(description="The final response from the agent to the user.") + conversation_status: str = Field(description="The status of the conversation.") + + +# Rules - These are the do's and don'ts that the agent should follow during the conversation. +rules = [ + "Terminate the conversation immediately if the user asks for harmful or inappropriate content.", + "If the conversation is complete due to the user wanting the outline updated, set the conversation_status to update_outline.", + "If the conversation is complete due to the user ready to move on to drafting the paper, set the conversation_state to user_completed.", +] + +# Conversation Flow (optional) - This defines in natural language the steps of the conversation. +conversation_flow = """1. Start by asking the user to review the drafted outline. +2. Answer any questions about the outline or the drafting process the user might want to explore. +3. At any time, if the user asks for a change to the outline or updates the attachment file list, consider the conversation complete. +In this scenario, your final message should inform the user that a new outline is being generated based off the new info or request. +4. At any time, if the user is good with the outline in its current form and ready to move on to drafting a paper from it, consider the +conversation complete. In this scenario, your final message should inform the user that you will start drafting the beginning of the +document based on this outline. +""" + +# Context (optional) - This is any additional information or the circumstances the agent is in that it should be aware of. +# It can also include the high level goal of the conversation if needed. +context = """You are working with a user on drafting an outline. The current drafted outline is provided, along with any filenames +that were used to help draft the outline. You do not have access to the content within the filenames that were used to help draft the outline. + Your purpose here is to help the user decide on any changes to the outline they might want or answer questions about it.""" + + +# Resource Constraints (optional) - This defines the constraints on the conversation such as time or turns. +# It can also help with pacing the conversation, +# For example, here we have set an exact time limit of 10 turns which the agent will try to fill. +resource_constraint = ResourceConstraint( + quantity=5, + unit=ResourceConstraintUnit.TURNS, + mode=ResourceConstraintMode.MAXIMUM, +) + + +# +# region Helpers +# + +# take a full json schema and return a pydantic model, including support for +# nested objects and typed arrays + + +def json_type_to_python_type(json_type: str) -> Type: + # Mapping JSON types to Python types + type_mapping = {"integer": int, "string": str, "number": float, "boolean": bool, "object": dict, "array": list} + return type_mapping.get(json_type, Any) + + +def create_pydantic_model_from_json_schema(schema: Dict[str, Any], model_name="DynamicModel") -> Type[BaseModel]: + # Nested function to parse properties from the schema + def parse_properties(properties: Dict[str, Any]) -> Dict[str, Any]: + fields = {} + for prop_name, prop_attrs in properties.items(): + prop_type = prop_attrs.get("type") + description = prop_attrs.get("description", None) + + if prop_type == "object": + nested_model = create_pydantic_model_from_json_schema(prop_attrs, model_name=prop_name.capitalize()) + fields[prop_name] = (nested_model, Field(..., description=description)) + elif prop_type == "array": + items = prop_attrs.get("items", {}) + if items.get("type") == "object": + nested_model = create_pydantic_model_from_json_schema(items) + fields[prop_name] = (List[nested_model], Field(..., description=description)) + else: + nested_type = json_type_to_python_type(items.get("type")) + fields[prop_name] = (List[nested_type], Field(..., description=description)) + else: + python_type = json_type_to_python_type(prop_type) + fields[prop_name] = (python_type, Field(..., description=description)) + return fields + + properties = schema.get("properties", {}) + fields = parse_properties(properties) + return create_model(model_name, **fields) + + +# endregion + + +# +# region Models +# + + +class GCDraftOutlineFeedbackConfigModel(GuidedConversationAgentConfigModel): + enabled: Annotated[ + bool, + Field(description=helpers.load_text_include("guided_conversation_agent_enabled.md")), + UISchema(enable_markdown_in_description=True), + ] = False + + artifact: Annotated[ + str, + Field( + title="Artifact", + description="The artifact that the agent will manage.", + ), + UISchema(widget="baseModelEditor"), + ] = json.dumps(ArtifactModel.model_json_schema(), indent=2) + + rules: Annotated[ + list[str], + Field(title="Rules", description="Do's and don'ts that the agent should attempt to follow"), + UISchema(schema={"items": {"ui:widget": "textarea", "ui:options": {"rows": 2}}}), + ] = rules + + conversation_flow: Annotated[ + str, + Field( + title="Conversation Flow", + description="A loose natural language description of the steps of the conversation", + ), + UISchema(widget="textarea", schema={"ui:options": {"rows": 10}}, placeholder="[optional]"), + ] = conversation_flow.strip() + + context: Annotated[ + str, + Field( + title="Context", + description="General background context for the conversation.", + ), + UISchema(widget="textarea", placeholder="[optional]"), + ] = context.strip() + + class ResourceConstraint(ResourceConstraint): + mode: Annotated[ + ResourceConstraintMode, + Field( + title="Resource Mode", + description=( + 'If "exact", the agents will try to pace the conversation to use exactly the resource quantity. If' + ' "maximum", the agents will try to pace the conversation to use at most the resource quantity.' + ), + ), + ] = resource_constraint.mode + + unit: Annotated[ + ResourceConstraintUnit, + Field( + title="Resource Unit", + description="The unit for the resource constraint.", + ), + ] = resource_constraint.unit + + quantity: Annotated[ + float, + Field( + title="Resource Quantity", + description="The quantity for the resource constraint. If <=0, the resource constraint is disabled.", + ), + ] = resource_constraint.quantity + + resource_constraint: Annotated[ + ResourceConstraint, + Field( + title="Resource Constraint", + ), + UISchema(schema={"quantity": {"ui:widget": "updown"}}), + ] = ResourceConstraint() + + def get_artifact_model(self) -> Type[BaseModel]: + schema = json.loads(self.artifact) + return create_pydantic_model_from_json_schema(schema) + + +# endregion diff --git a/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py b/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py index 1dc7a84e..dd5ad040 100644 --- a/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py +++ b/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py @@ -13,6 +13,7 @@ from ...config import AssistantConfigModel from .config import GuidedConversationAgentConfigModel +from .status import Status logger = logging.getLogger(__name__) @@ -43,7 +44,7 @@ async def step_conversation( agent_config: GuidedConversationAgentConfigModel, conversation_context: ConversationContext, last_user_message: str | None, - ) -> tuple[str | None, bool]: + ) -> tuple[str, Status]: """ Step the conversation to the next turn. """ @@ -54,6 +55,8 @@ async def step_conversation( resource_constraint = agent_config.resource_constraint artifact = agent_config.get_artifact_model() + # plug in attachments + kernel = Kernel() service_id = "gc_main" @@ -96,7 +99,45 @@ async def step_conversation( # Save the state of the guided conversation agent _write_guided_conversation_state(conversation_context, guided_conversation_agent.to_json()) - return result.ai_message, result.is_conversation_over + # convert information in artifact for Document Agent + # conversation_status: # this should relate to result.is_conversation_over + # final_response: # replace result.ai_message with final_response if "user_completed" + + final_response: str = "" + conversation_status: str | None = None + response: str = "" + + # to_json is actually to dict, not to json. + gc_dict = guided_conversation_agent.to_json() + artifact_item = gc_dict.get("artifact") + if artifact_item is not None: + artifact_item = artifact_item.get("artifact") + if artifact_item is not None: + final_response = artifact_item.get("final_response") + conversation_status = artifact_item.get("conversation_status") + + # should be returning str and Status for Document Agent to consume. Update doc agent logic accordingly. + status: Status = Status.UNDEFINED + if conversation_status is not None: + if result.is_conversation_over is True: + _delete_guided_conversation_state(conversation_context) + if conversation_status == "user_completed": + status = Status.USER_COMPLETED + response = final_response + elif conversation_status == "update_outline": + status = Status.UPDATE_OUTLINE + response = final_response + else: + status = Status.USER_EXIT_EARLY + response = final_response + else: + if result.ai_message is not None: + response = result.ai_message + else: + response = "" + status = Status.NOT_COMPLETED + + return response, status # endregion @@ -142,4 +183,13 @@ def _read_guided_conversation_state(context: ConversationContext) -> dict | None return None +def _delete_guided_conversation_state(context: ConversationContext) -> None: + """ + Delete the file containing state of the guided conversation agent. + """ + path = _get_guided_conversation_storage_path(context, "state.json") + if path.exists(): + path.unlink() + + # endregion diff --git a/assistants/prospector-assistant/assistant/agents/document/status.py b/assistants/prospector-assistant/assistant/agents/document/status.py new file mode 100644 index 00000000..31a0b66e --- /dev/null +++ b/assistants/prospector-assistant/assistant/agents/document/status.py @@ -0,0 +1,9 @@ +from enum import StrEnum + + +class Status(StrEnum): + UNDEFINED = "undefined" + NOT_COMPLETED = "not_completed" + UPDATE_OUTLINE = "update_outline" + USER_COMPLETED = "user_completed" + USER_EXIT_EARLY = "user_exit_early" diff --git a/assistants/prospector-assistant/assistant/agents/document_agent.py b/assistants/prospector-assistant/assistant/agents/document_agent.py index 0c5cb836..6bd49037 100644 --- a/assistants/prospector-assistant/assistant/agents/document_agent.py +++ b/assistants/prospector-assistant/assistant/agents/document_agent.py @@ -23,7 +23,10 @@ from ..config import AssistantConfigModel from .document.config import GuidedConversationAgentConfigModel +from .document.gc_attachment_check_config import GCAttachmentCheckConfigModel +from .document.gc_draft_outline_feedback_config import GCDraftOutlineFeedbackConfigModel from .document.guided_conversation import GuidedConversationAgent +from .document.status import Status logger = logging.getLogger(__name__) @@ -33,13 +36,6 @@ # -class Status(StrEnum): - UNDEFINED = "undefined" - NOT_COMPLETED = "not_completed" - USER_COMPLETED = "user_completed" - USER_EXIT_EARLY = "user_exit_early" - - class StepName(StrEnum): UNDEFINED = "undefined" DO_GC_ATTACHMENT_CHECK = "step_gc_attachment_check" @@ -570,8 +566,39 @@ async def _step_gc_get_outline_feedback( message: ConversationMessage, metadata: dict[str, Any] = {}, ) -> Status: - # pretend completed - return Status.USER_COMPLETED + # Pre-requisites + if self._state is None: + logger.error("Document Agent state is None. Returning.") + return Status.UNDEFINED + + step = self._state.mode.get_step() + step_name = step.get_name() + step_status = step.get_status() + + # Pre-requisites + step_called = StepName.DO_GC_GET_OUTLINE_FEEDBACK + if step_name is not step_called or step_status is not Status.NOT_COMPLETED: + logger.error( + "Document Agent state step: %s, step called: %s, state step completion status: %s. Resetting Mode.", + step_name, + step_called, + step_status, + ) + self._state.mode.reset() + self._write_state(context) + return self._state.mode.get_status() + + # Run + logger.info("Document Agent running step: %s", step_name) + status = await self._gc_outline_feedback(config, gc_do_feedback_config, context, message, metadata) + + if status is Status.UPDATE_OUTLINE: + status = await self._draft_outline(config, context, message, metadata) + + step.set_status(status) + self._state.mode.set_step(step) + self._write_state(context) + return step.get_status() async def _step_final_outline( self, @@ -598,38 +625,38 @@ async def _gc_respond_to_conversation( metadata: dict[str, Any] = {}, ) -> Status: method_metadata_key = "document_agent_gc_response" - is_conversation_over = False + + # get attachment filenames for context + filenames = await self._attachments_extension.get_attachment_filenames( + context, config=config.agents_config.attachment_agent + ) + + filenames_str = ", ".join(filenames) + filenames_str = "Filenames already attached: " + filenames_str + gc_config.context = gc_config.context + "\n\n" + filenames_str try: - response_message, is_conversation_over = await GuidedConversationAgent.step_conversation( + response_message, conversation_status = await GuidedConversationAgent.step_conversation( config=config, openai_client=openai_client.create_client(config.service_config), agent_config=gc_config, conversation_context=context, last_user_message=message.content, ) - if is_conversation_over: # need to get info from gc on if user-ended early or actually completed - return Status.USER_COMPLETED # Do not send the hard-coded response message from gc - - if response_message is None: - # need to double check this^^ None logic, when it would occur in GC. Make "" for now. - agent_message = "" - else: - agent_message = response_message # add the completion to the metadata for debugging deepmerge.always_merger.merge( metadata, { "debug": { - f"{method_metadata_key}": {"response": agent_message}, + f"{method_metadata_key}": {"response": response_message}, } }, ) except Exception as e: logger.exception(f"exception occurred processing guided conversation: {e}") - agent_message = "An error occurred while processing the guided conversation." + response_message = "An error occurred while processing the guided conversation." deepmerge.always_merger.merge( metadata, { @@ -643,7 +670,7 @@ async def _gc_respond_to_conversation( await context.send_messages( NewConversationMessage( - content=agent_message, + content=response_message, message_type=MessageType.chat, metadata=metadata, ) @@ -652,7 +679,7 @@ async def _gc_respond_to_conversation( # Need to add a good way to stop mode if an exception occurs. # Also need to update the gc state turn count to 0 (and any thing else that needs to be reset) once conversation is over... or exception occurs?) - return Status.NOT_COMPLETED + return conversation_status async def _draft_outline( self, @@ -676,8 +703,9 @@ async def _draft_outline( # get outline related info outline: str | None = None - if path.exists(storage_directory_for_context(context) / "outline.txt"): - outline = (storage_directory_for_context(context) / "outline.txt").read_text() + # path = _get_document_agent_conversation_storage_path(context) + if path.exists(storage_directory_for_context(context) / "document_agent/outline.txt"): + outline = (storage_directory_for_context(context) / "document_agent/outline.txt").read_text() # create chat completion messages chat_completion_messages: list[ChatCompletionMessageParam] = [] @@ -710,7 +738,7 @@ async def _draft_outline( _on_error_metadata_update(metadata, method_metadata_key, config, chat_completion_messages, e) # store only latest version for now (will keep all versions later as need arises) - (storage_directory_for_context(context) / "outline.txt").write_text(content) + (storage_directory_for_context(context) / "document_agent/outline.txt").write_text(content) # send the response to the conversation only if from a command. Otherwise return info to caller. message_type = MessageType.chat @@ -727,6 +755,80 @@ async def _draft_outline( return Status.USER_COMPLETED + async def _gc_outline_feedback( + self, + config: AssistantConfigModel, + gc_config: GuidedConversationAgentConfigModel, + context: ConversationContext, + message: ConversationMessage, + metadata: dict[str, Any] = {}, + ) -> Status: + method_metadata_key = "document_agent_gc_response" + + # get attachment filenames for context + filenames = await self._attachments_extension.get_attachment_filenames( + context, config=config.agents_config.attachment_agent + ) + + filenames_str = ", ".join(filenames) + filenames_str = "Filenames already attached: " + filenames_str + gc_config.context = gc_config.context + "\n\n" + filenames_str + + # get current outline related info + current_outline: str | None = None + if path.exists(storage_directory_for_context(context) / "document_agent/outline.txt"): + current_outline = (storage_directory_for_context(context) / "document_agent/outline.txt").read_text() + + if current_outline is not None: + outline_str = "Current outline under review: " + current_outline + gc_config.context = gc_config.context + "\n\n" + outline_str + + try: + response_message, conversation_status = await GuidedConversationAgent.step_conversation( + config=config, + openai_client=openai_client.create_client(config.service_config), + agent_config=gc_config, + conversation_context=context, + last_user_message=None, # fix this. Should be None on the first call, but keep the user message after. + ) + + # add the completion to the metadata for debugging + deepmerge.always_merger.merge( + metadata, + { + "debug": { + f"{method_metadata_key}": {"response": response_message}, + } + }, + ) + + except Exception as e: + logger.exception(f"exception occurred processing guided conversation: {e}") + response_message = "An error occurred while processing the guided conversation." + deepmerge.always_merger.merge( + metadata, + { + "debug": { + f"{method_metadata_key}": { + "error": str(e), + }, + } + }, + ) + + await context.send_messages( + NewConversationMessage( + content=response_message, + message_type=MessageType.chat, + metadata=metadata, + ) + ) + + # Need to add a good way to stop mode if an exception occurs. + # Also need to update the gc state turn count to 0 (and any thing else that needs to be reset) once conversation is over... or exception occurs?) + + return conversation_status + # endregion @@ -851,8 +953,8 @@ def _format_message(message: ConversationMessage, participants: list[Conversatio # region GC agent config temp # # pull in GC config with its defaults, and then make changes locally here for now. -gc_config = GuidedConversationAgentConfigModel() - +gc_config = GCAttachmentCheckConfigModel() +gc_do_feedback_config = GCDraftOutlineFeedbackConfigModel() # endregion diff --git a/libraries/python/assistant-extensions/assistant_extensions/attachments/_attachments.py b/libraries/python/assistant-extensions/assistant_extensions/attachments/_attachments.py index 1d33cbeb..f5ef7904 100644 --- a/libraries/python/assistant-extensions/assistant_extensions/attachments/_attachments.py +++ b/libraries/python/assistant-extensions/assistant_extensions/attachments/_attachments.py @@ -197,6 +197,33 @@ async def get_completion_messages_for_attachments( return messages + async def get_attachment_filenames( + self, + context: ConversationContext, + config: AttachmentsConfigModel, + include_filenames: list[str] | None = None, + exclude_filenames: list[str] = [], + ) -> list[str]: + if not config.include_in_response_generation: + return [] + + # get attachments, filtered by include_filenames and exclude_filenames + attachments = await _get_attachments( + context, + error_handler=self._error_handler, + include_filenames=include_filenames, + exclude_filenames=exclude_filenames, + ) + + if not attachments: + return [] + + filenames: list[str] = [] + for attachment in attachments: + filenames.append(attachment.filename) + + return filenames + async def _get_attachments( context: ConversationContext, From 1c92411e2b56a5fc604afde33a009ff3f295e2c1 Mon Sep 17 00:00:00 2001 From: Mollie Munoz Date: Fri, 1 Nov 2024 21:59:00 +0000 Subject: [PATCH 3/6] Adding initiated status to give control for when a last user message should be used at the start of a gc or not. --- .../assistant/agents/document/status.py | 1 + .../assistant/agents/document_agent.py | 119 ++++++++++-------- 2 files changed, 71 insertions(+), 49 deletions(-) diff --git a/assistants/prospector-assistant/assistant/agents/document/status.py b/assistants/prospector-assistant/assistant/agents/document/status.py index 31a0b66e..1e5f7ddd 100644 --- a/assistants/prospector-assistant/assistant/agents/document/status.py +++ b/assistants/prospector-assistant/assistant/agents/document/status.py @@ -3,6 +3,7 @@ class Status(StrEnum): UNDEFINED = "undefined" + INITIATED = "initiated" NOT_COMPLETED = "not_completed" UPDATE_OUTLINE = "update_outline" USER_COMPLETED = "user_completed" diff --git a/assistants/prospector-assistant/assistant/agents/document_agent.py b/assistants/prospector-assistant/assistant/agents/document_agent.py index 8dc971ae..bd8d2489 100644 --- a/assistants/prospector-assistant/assistant/agents/document_agent.py +++ b/assistants/prospector-assistant/assistant/agents/document_agent.py @@ -94,7 +94,7 @@ def get_status(self) -> Status: class Mode(BaseModel): name: ModeName = ModeName.UNDEFINED status: Status = Status.UNDEFINED - current_step: Step = Step() + step: Step = Step() step_order: list[StepName] = [] def _error_check(self) -> None: @@ -137,13 +137,15 @@ def get_status(self) -> Status: def is_running(self) -> bool: if self.status is Status.NOT_COMPLETED: return True + if self.status is Status.INITIATED: + return True return False # UNDEFINED, USER_EXIT_EARLY, USER_COMPLETED def set_step(self, step: Step) -> None: - self.current_step = step + self.step = step def get_step(self) -> Step: - return self.current_step + return self.step def set_step_order(self, steps: list[StepName]) -> None: self.step_order = steps @@ -156,17 +158,17 @@ def get_next_step(self) -> Step | None: if len(steps) == 0: return None - current_step = self.get_step() - current_step_name = current_step.get_name() - if current_step_name is steps[-1]: + step = self.get_step() + step_name = step.get_name() + if step_name is steps[-1]: return None # on final step for index, step in enumerate(steps[:-1]): - if step is current_step_name: + if step is step_name: next_step_name = steps[index + 1] break - return Step(name=next_step_name, status=Status.NOT_COMPLETED) + return Step(name=next_step_name, status=Status.INITIATED) class State(BaseModel): @@ -305,7 +307,7 @@ def _set_mode_draft_outline( return # Run - self._state.mode = Mode(name=ModeName.DRAFT_OUTLINE, status=Status.NOT_COMPLETED) + self._state.mode = Mode(name=ModeName.DRAFT_OUTLINE, status=Status.INITIATED) self._write_state(context) async def respond_to_conversation( @@ -379,17 +381,18 @@ async def _run_mode( step_name = step.get_name() step_status = step.get_status() - if step_name is StepName.UNDEFINED: + # This Status.INITIATED will occur when the mode is setting up the first step on its first run + if step_status is Status.INITIATED: logger.info("Document Agent mode (%s) at beginning.", mode_name) - first_step_name = mode.get_step_order()[0] - self._state.mode.set_step(Step(name=first_step_name, status=Status.NOT_COMPLETED)) + self._state.mode.get_step().set_status(Status.NOT_COMPLETED) self._write_state(context) step = self._state.mode.get_step() step_name = step.get_name() step_status = step.get_status() - while step_status is Status.NOT_COMPLETED: + # This Status.INITIATED will occur when a new step is setup upon a prior step's Status.USER_COMPLETED. + while step_status is Status.INITIATED or step_status is Status.NOT_COMPLETED: step_method = self._get_step_method(step) if step_method: logger.info("Document Agent in step: %s", step_name) @@ -456,7 +459,9 @@ async def _mode_draft_outline( mode_name = mode.get_name() mode_status = mode.get_status() - if mode_name is not ModeName.DRAFT_OUTLINE or mode_status is not Status.NOT_COMPLETED: + if mode_name is not ModeName.DRAFT_OUTLINE or ( + mode_status is not Status.NOT_COMPLETED and mode_status is not Status.INITIATED + ): logger.error( "Document Agent state mode: %s, mode called: %s, state mode completion status: %s. Resetting Mode.", mode_name, @@ -467,16 +472,20 @@ async def _mode_draft_outline( self._write_state(context) return self._state.mode.get_status() - # Setup - self._state.mode.set_step_order( - [ - StepName.DO_GC_ATTACHMENT_CHECK, - StepName.DO_DRAFT_OUTLINE, - StepName.DO_GC_GET_OUTLINE_FEEDBACK, - StepName.DO_FINAL_OUTLINE, - ], - ) - self._write_state(context) + # Setup on first run. + if mode_status is Status.INITIATED: + self._state.mode.set_step_order( + [ + StepName.DO_GC_ATTACHMENT_CHECK, + StepName.DO_DRAFT_OUTLINE, + StepName.DO_GC_GET_OUTLINE_FEEDBACK, + StepName.DO_FINAL_OUTLINE, + ], + ) + first_step_name = self._state.mode.get_step_order()[0] + self._state.mode.set_step(Step(name=first_step_name, status=Status.INITIATED)) + self._state.mode.set_status(Status.NOT_COMPLETED) + self._write_state(context) self._step_name_to_method: dict[StepName, Callable] = { StepName.DO_GC_ATTACHMENT_CHECK: self._step_gc_attachment_check, @@ -506,7 +515,9 @@ async def _step_gc_attachment_check( # Pre-requisites step_called = StepName.DO_GC_ATTACHMENT_CHECK - if step_name is not step_called or step_status is not Status.NOT_COMPLETED: + if step_name is not step_called or ( + step_status is not Status.NOT_COMPLETED and step_status is not Status.INITIATED + ): logger.error( "Document Agent state step: %s, step called: %s, state step completion status: %s. Resetting Mode.", step_name, @@ -519,7 +530,7 @@ async def _step_gc_attachment_check( # Run logger.info("Document Agent running step: %s", step_name) - status = await self._gc_respond_to_conversation(config, gc_config, context, message, metadata) + status = await self._gc_attachment_check(config, context, message, metadata) step.set_status(status) self._state.mode.set_step(step) self._write_state(context) @@ -542,7 +553,9 @@ async def _step_draft_outline( step_status = step.get_status() step_called = StepName.DO_DRAFT_OUTLINE - if step_name is not step_called or step_status is not Status.NOT_COMPLETED: + if step_name is not step_called or ( + step_status is not Status.NOT_COMPLETED and step_status is not Status.INITIATED + ): logger.error( "Document Agent state step: %s, step called: %s, state step completion status: %s. Resetting Mode.", step_name, @@ -579,7 +592,9 @@ async def _step_gc_get_outline_feedback( # Pre-requisites step_called = StepName.DO_GC_GET_OUTLINE_FEEDBACK - if step_name is not step_called or step_status is not Status.NOT_COMPLETED: + if step_name is not step_called or ( + step_status is not Status.NOT_COMPLETED and step_status is not Status.INITIATED + ): logger.error( "Document Agent state step: %s, step called: %s, state step completion status: %s. Resetting Mode.", step_name, @@ -591,9 +606,20 @@ async def _step_gc_get_outline_feedback( return self._state.mode.get_status() # Run + # Because the last user message will be ending a prior step, and not be related to this step. + user_message: ConversationMessage | None + if step_status is Status.INITIATED: + user_message = None + else: + user_message = message + logger.info("Document Agent running step: %s", step_name) - status = await self._gc_outline_feedback(config, gc_do_feedback_config, context, message, metadata) + status = await self._gc_outline_feedback(config, context, user_message, metadata) + # should this be status or a different return as part of a tuple... like next step request? + # this way the control would still be higher (and non recursive) as the branching logic will return instead of call internally the next step. + # but controlling of what gets called next will be determined by each step at its completion. it seems like there should still be + # some level of oversight by the mode layer in case we end up in an endless loop of steps saying the should each be called next...(still a problem.) if status is Status.UPDATE_OUTLINE: status = await self._draft_outline(config, context, message, metadata) @@ -618,16 +644,16 @@ async def _step_final_outline( # region language model methods # - async def _gc_respond_to_conversation( + async def _gc_attachment_check( self, config: AssistantConfigModel, - gc_config: GuidedConversationAgentConfigModel, context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, ) -> Status: method_metadata_key = "document_agent_gc_response" + gc_convo_config: GuidedConversationAgentConfigModel = GCAttachmentCheckConfigModel() # get attachment filenames for context filenames = await self._attachments_extension.get_attachment_filenames( context, config=config.agents_config.attachment_agent @@ -635,13 +661,13 @@ async def _gc_respond_to_conversation( filenames_str = ", ".join(filenames) filenames_str = "Filenames already attached: " + filenames_str - gc_config.context = gc_config.context + "\n\n" + filenames_str + gc_convo_config.context = gc_convo_config.context + "\n\n" + filenames_str try: response_message, conversation_status = await GuidedConversationAgent.step_conversation( config=config, openai_client=openai_client.create_client(config.service_config), - agent_config=gc_config, + agent_config=gc_convo_config, conversation_context=context, last_user_message=message.content, ) @@ -760,13 +786,13 @@ async def _draft_outline( async def _gc_outline_feedback( self, config: AssistantConfigModel, - gc_config: GuidedConversationAgentConfigModel, context: ConversationContext, - message: ConversationMessage, + message: ConversationMessage | None, metadata: dict[str, Any] = {}, ) -> Status: method_metadata_key = "document_agent_gc_response" + gc_do_feedback_config: GuidedConversationAgentConfigModel = GCDraftOutlineFeedbackConfigModel() # get attachment filenames for context filenames = await self._attachments_extension.get_attachment_filenames( context, config=config.agents_config.attachment_agent @@ -774,7 +800,7 @@ async def _gc_outline_feedback( filenames_str = ", ".join(filenames) filenames_str = "Filenames already attached: " + filenames_str - gc_config.context = gc_config.context + "\n\n" + filenames_str + gc_do_feedback_config.context = gc_do_feedback_config.context + "\n\n" + filenames_str # get current outline related info current_outline: str | None = None @@ -783,15 +809,20 @@ async def _gc_outline_feedback( if current_outline is not None: outline_str = "Current outline under review: " + current_outline - gc_config.context = gc_config.context + "\n\n" + outline_str + gc_do_feedback_config.context = gc_do_feedback_config.context + "\n\n" + outline_str + + if message is not None: + user_message = message.content + else: + user_message = None try: response_message, conversation_status = await GuidedConversationAgent.step_conversation( config=config, openai_client=openai_client.create_client(config.service_config), - agent_config=gc_config, + agent_config=gc_do_feedback_config, conversation_context=context, - last_user_message=None, # fix this. Should be None on the first call, but keep the user message after. + last_user_message=user_message, ) # add the completion to the metadata for debugging @@ -951,16 +982,6 @@ def _format_message(message: ConversationMessage, participants: list[Conversatio # endregion -# -# region GC agent config temp -# -# pull in GC config with its defaults, and then make changes locally here for now. -gc_config = GCAttachmentCheckConfigModel() -gc_do_feedback_config = GCDraftOutlineFeedbackConfigModel() - -# endregion - - ##### FROM NOTEBOOK # await document_skill.draft_outline(context=unused, openai_client=async_client, model=model) # From 1d269ae5d5fb829646966d59e36e255a04d5cab7 Mon Sep 17 00:00:00 2001 From: Mollie Munoz Date: Tue, 5 Nov 2024 21:03:10 +0000 Subject: [PATCH 4/6] check status initiated --- .../assistant/agents/document_agent.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/assistants/prospector-assistant/assistant/agents/document_agent.py b/assistants/prospector-assistant/assistant/agents/document_agent.py index bd8d2489..149e603a 100644 --- a/assistants/prospector-assistant/assistant/agents/document_agent.py +++ b/assistants/prospector-assistant/assistant/agents/document_agent.py @@ -529,6 +529,9 @@ async def _step_gc_attachment_check( return self._state.mode.get_status() # Run + if step_status is Status.INITIATED: + self._state.mode.get_step().set_status(Status.NOT_COMPLETED) + logger.info("Document Agent running step: %s", step_name) status = await self._gc_attachment_check(config, context, message, metadata) step.set_status(status) @@ -567,6 +570,9 @@ async def _step_draft_outline( return self._state.mode.get_status() # Run + if step_status is Status.INITIATED: + self._state.mode.get_step().set_status(Status.NOT_COMPLETED) + logger.info("Document Agent running step: %s", step_name) status = await self._draft_outline(config, context, message, metadata) step.set_status(status) @@ -606,6 +612,9 @@ async def _step_gc_get_outline_feedback( return self._state.mode.get_status() # Run + if step_status is Status.INITIATED: + self._state.mode.get_step().set_status(Status.NOT_COMPLETED) + # Because the last user message will be ending a prior step, and not be related to this step. user_message: ConversationMessage | None if step_status is Status.INITIATED: From 273bc9258b56d804bb6e275a749cfc4252208b9b Mon Sep 17 00:00:00 2001 From: Mollie Munoz Date: Wed, 6 Nov 2024 01:03:50 +0000 Subject: [PATCH 5/6] Changes to support correct output from gc on outline feedback to new draft. Fixes for correct state updates. Does not address outline feedback loop issue --- .../gc_draft_outline_feedback_config.py | 11 ++--- .../agents/document/guided_conversation.py | 31 +++++++----- .../assistant/agents/document_agent.py | 49 ++++++------------- 3 files changed, 39 insertions(+), 52 deletions(-) diff --git a/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py b/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py index 6b68d8c0..cc75ac86 100644 --- a/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py +++ b/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py @@ -19,22 +19,21 @@ class ArtifactModel(BaseModel): final_response: str = Field(description="The final response from the agent to the user.") conversation_status: str = Field(description="The status of the conversation.") + user_decision: str = Field(description="The decision of the user on what should happen next.") # Rules - These are the do's and don'ts that the agent should follow during the conversation. rules = [ "Terminate the conversation immediately if the user asks for harmful or inappropriate content.", - "If the conversation is complete due to the user wanting the outline updated, set the conversation_status to update_outline.", - "If the conversation is complete due to the user ready to move on to drafting the paper, set the conversation_state to user_completed.", ] # Conversation Flow (optional) - This defines in natural language the steps of the conversation. conversation_flow = """1. Start by asking the user to review the drafted outline. 2. Answer any questions about the outline or the drafting process the user might want to explore. -3. At any time, if the user asks for a change to the outline or updates the attachment file list, consider the conversation complete. -In this scenario, your final message should inform the user that a new outline is being generated based off the new info or request. -4. At any time, if the user is good with the outline in its current form and ready to move on to drafting a paper from it, consider the -conversation complete. In this scenario, your final message should inform the user that you will start drafting the beginning of the +3. At any time, if the user asks for a change to the outline or updates the attachment file list, the conversation_status must be +marked as user_completed. The user_decision must be marked as update_outline. The final_response should inform the user that a new outline is being generated based off the new info or request. +4. At any time, if the user is good with the outline in its current form and ready to move on to drafting a paper from it, the conversation_status must be +marked as user_completed. The user_decision must be marked as draft_paper. The final_response should inform the user that you will start drafting the beginning of the document based on this outline. """ diff --git a/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py b/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py index dd5ad040..51f1bee9 100644 --- a/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py +++ b/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py @@ -105,6 +105,7 @@ async def step_conversation( final_response: str = "" conversation_status: str | None = None + user_decision: str = "" response: str = "" # to_json is actually to dict, not to json. @@ -115,27 +116,33 @@ async def step_conversation( if artifact_item is not None: final_response = artifact_item.get("final_response") conversation_status = artifact_item.get("conversation_status") + user_decision = artifact_item.get("user_decision") # should be returning str and Status for Document Agent to consume. Update doc agent logic accordingly. status: Status = Status.UNDEFINED if conversation_status is not None: - if result.is_conversation_over is True: - _delete_guided_conversation_state(conversation_context) - if conversation_status == "user_completed": - status = Status.USER_COMPLETED - response = final_response - elif conversation_status == "update_outline": - status = Status.UPDATE_OUTLINE - response = final_response - else: - status = Status.USER_EXIT_EARLY - response = final_response - else: + if conversation_status == "Unanswered": if result.ai_message is not None: response = result.ai_message else: response = "" status = Status.NOT_COMPLETED + elif conversation_status == "user_completed": + _delete_guided_conversation_state(conversation_context) + response = final_response + if user_decision is None: + status = Status.USER_COMPLETED + else: + if user_decision == "update_outline": # this code is becoming highly coupled fyi to the gc configs + status = Status.UPDATE_OUTLINE + elif user_decision == "draft_paper": + status = Status.USER_COMPLETED + else: + logger.error("unknown user decision") + else: + _delete_guided_conversation_state(conversation_context) + status = Status.USER_EXIT_EARLY + response = final_response return response, status diff --git a/assistants/prospector-assistant/assistant/agents/document_agent.py b/assistants/prospector-assistant/assistant/agents/document_agent.py index 149e603a..039eda2f 100644 --- a/assistants/prospector-assistant/assistant/agents/document_agent.py +++ b/assistants/prospector-assistant/assistant/agents/document_agent.py @@ -381,36 +381,16 @@ async def _run_mode( step_name = step.get_name() step_status = step.get_status() - # This Status.INITIATED will occur when the mode is setting up the first step on its first run - if step_status is Status.INITIATED: - logger.info("Document Agent mode (%s) at beginning.", mode_name) - self._state.mode.get_step().set_status(Status.NOT_COMPLETED) - self._write_state(context) - - step = self._state.mode.get_step() - step_name = step.get_name() - step_status = step.get_status() - - # This Status.INITIATED will occur when a new step is setup upon a prior step's Status.USER_COMPLETED. while step_status is Status.INITIATED or step_status is Status.NOT_COMPLETED: step_method = self._get_step_method(step) if step_method: logger.info("Document Agent in step: %s", step_name) step_status = await step_method(config, context, message, metadata) - match step_status: - case Status.UNDEFINED: - logger.error( - "Calling corresponding step method for %s resulted in status %s. Resetting mode %s.", - step_name, - step_status, - mode_name, - ) - self._state.mode.reset() - break # problem - + match step_status: # resulting status of step_method() case Status.NOT_COMPLETED: self._state.mode.get_step().set_status(step_status) + self._state.mode.set_status(step_status) break # ok - get more user input case Status.USER_COMPLETED: @@ -418,8 +398,9 @@ async def _run_mode( if next_step is not None: step = next_step step_name = next_step.get_name() - step_status = next_step.get_status() + step_status = next_step.get_status() # new step is Status.INITIATED self._state.mode.set_step(next_step) + self._write_state(context) continue # ok - don't need user input yet else: self._state.mode.get_step().set_status(step_status) @@ -430,6 +411,16 @@ async def _run_mode( self._state.mode.get_step().set_status(step_status) self._state.mode.set_status(step_status) break # ok - done early :) + + case _: # UNDEFINED, INITIATED + logger.error( + "Document Agent: Calling corresponding step method for %s resulted in status %s. Resetting mode %s.", + step_name, + step_status, + mode_name, + ) + self._state.mode.reset() + break # problem else: logger.error( "Document Agent failed to find a corresponding step method for %s. Resetting mode %s.", @@ -482,9 +473,9 @@ async def _mode_draft_outline( StepName.DO_FINAL_OUTLINE, ], ) + logger.info("Document Agent mode (%s) at beginning.", mode_name) first_step_name = self._state.mode.get_step_order()[0] self._state.mode.set_step(Step(name=first_step_name, status=Status.INITIATED)) - self._state.mode.set_status(Status.NOT_COMPLETED) self._write_state(context) self._step_name_to_method: dict[StepName, Callable] = { @@ -529,9 +520,6 @@ async def _step_gc_attachment_check( return self._state.mode.get_status() # Run - if step_status is Status.INITIATED: - self._state.mode.get_step().set_status(Status.NOT_COMPLETED) - logger.info("Document Agent running step: %s", step_name) status = await self._gc_attachment_check(config, context, message, metadata) step.set_status(status) @@ -570,9 +558,6 @@ async def _step_draft_outline( return self._state.mode.get_status() # Run - if step_status is Status.INITIATED: - self._state.mode.get_step().set_status(Status.NOT_COMPLETED) - logger.info("Document Agent running step: %s", step_name) status = await self._draft_outline(config, context, message, metadata) step.set_status(status) @@ -612,10 +597,6 @@ async def _step_gc_get_outline_feedback( return self._state.mode.get_status() # Run - if step_status is Status.INITIATED: - self._state.mode.get_step().set_status(Status.NOT_COMPLETED) - - # Because the last user message will be ending a prior step, and not be related to this step. user_message: ConversationMessage | None if step_status is Status.INITIATED: user_message = None From 49207446251bb9a9a32c77d3ff713a1acfc79d1d Mon Sep 17 00:00:00 2001 From: Mollie Munoz Date: Thu, 7 Nov 2024 00:26:00 +0000 Subject: [PATCH 6/6] GC config changes for improvements. Update step flow to allow for different step calls. --- .../gc_draft_outline_feedback_config.py | 11 +- .../agents/document/guided_conversation.py | 11 +- .../assistant/agents/document/status.py | 9 +- .../assistant/agents/document_agent.py | 109 +++++++++--------- 4 files changed, 77 insertions(+), 63 deletions(-) diff --git a/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py b/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py index cc75ac86..b8c079ac 100644 --- a/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py +++ b/assistants/prospector-assistant/assistant/agents/document/gc_draft_outline_feedback_config.py @@ -24,16 +24,19 @@ class ArtifactModel(BaseModel): # Rules - These are the do's and don'ts that the agent should follow during the conversation. rules = [ + "Do NOT rewrite or update the outline, even if the user asks you to." + "You are ONLY allowed to help the user decide on any changes to the outline or answer questions about writing an outline." "Terminate the conversation immediately if the user asks for harmful or inappropriate content.", + "If the conversation_status is marked as user_complete, the final_response cannot be left as 'Unanswered'. The final_response must be set based on the conversation flow instructions.", ] # Conversation Flow (optional) - This defines in natural language the steps of the conversation. conversation_flow = """1. Start by asking the user to review the drafted outline. 2. Answer any questions about the outline or the drafting process the user might want to explore. 3. At any time, if the user asks for a change to the outline or updates the attachment file list, the conversation_status must be -marked as user_completed. The user_decision must be marked as update_outline. The final_response should inform the user that a new outline is being generated based off the new info or request. +marked as user_completed. The user_decision must be marked as update_outline. The final_response must inform the user that a new outline is being generated based off the new info or request. 4. At any time, if the user is good with the outline in its current form and ready to move on to drafting a paper from it, the conversation_status must be -marked as user_completed. The user_decision must be marked as draft_paper. The final_response should inform the user that you will start drafting the beginning of the +marked as user_completed. The user_decision must be marked as draft_paper. The final_response must inform the user that you will start drafting the beginning of the document based on this outline. """ @@ -41,7 +44,9 @@ class ArtifactModel(BaseModel): # It can also include the high level goal of the conversation if needed. context = """You are working with a user on drafting an outline. The current drafted outline is provided, along with any filenames that were used to help draft the outline. You do not have access to the content within the filenames that were used to help draft the outline. - Your purpose here is to help the user decide on any changes to the outline they might want or answer questions about it.""" + Your purpose here is to help the user decide on any changes to the outline they might want or answer questions about it. This may be the first time + the user is asking for you help, or the nth time. Please use the conversation history provided to determine if you should be give an initial greeting + to the user or continuing the draft outline process.""" # Resource Constraints (optional) - This defines the constraints on the conversation such as time or turns. diff --git a/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py b/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py index 51f1bee9..d6ae3250 100644 --- a/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py +++ b/assistants/prospector-assistant/assistant/agents/document/guided_conversation.py @@ -13,7 +13,7 @@ from ...config import AssistantConfigModel from .config import GuidedConversationAgentConfigModel -from .status import Status +from .status import Status, StepName logger = logging.getLogger(__name__) @@ -44,10 +44,11 @@ async def step_conversation( agent_config: GuidedConversationAgentConfigModel, conversation_context: ConversationContext, last_user_message: str | None, - ) -> tuple[str, Status]: + ) -> tuple[str, Status, StepName | None]: """ Step the conversation to the next turn. """ + next_step_name = None rules = agent_config.rules conversation_flow = agent_config.conversation_flow @@ -134,9 +135,11 @@ async def step_conversation( status = Status.USER_COMPLETED else: if user_decision == "update_outline": # this code is becoming highly coupled fyi to the gc configs - status = Status.UPDATE_OUTLINE + status = Status.USER_COMPLETED + next_step_name = StepName.DO_DRAFT_OUTLINE elif user_decision == "draft_paper": status = Status.USER_COMPLETED + next_step_name = StepName.DO_FINISH else: logger.error("unknown user decision") else: @@ -144,7 +147,7 @@ async def step_conversation( status = Status.USER_EXIT_EARLY response = final_response - return response, status + return response, status, next_step_name # endregion diff --git a/assistants/prospector-assistant/assistant/agents/document/status.py b/assistants/prospector-assistant/assistant/agents/document/status.py index 1e5f7ddd..a5c5e77d 100644 --- a/assistants/prospector-assistant/assistant/agents/document/status.py +++ b/assistants/prospector-assistant/assistant/agents/document/status.py @@ -5,6 +5,13 @@ class Status(StrEnum): UNDEFINED = "undefined" INITIATED = "initiated" NOT_COMPLETED = "not_completed" - UPDATE_OUTLINE = "update_outline" USER_COMPLETED = "user_completed" USER_EXIT_EARLY = "user_exit_early" + + +class StepName(StrEnum): + UNDEFINED = "undefined" + DO_GC_ATTACHMENT_CHECK = "step_gc_attachment_check" + DO_DRAFT_OUTLINE = "step_draft_outline" + DO_GC_GET_OUTLINE_FEEDBACK = "step_gc_get_outline_feedback" + DO_FINISH = "step_finish" diff --git a/assistants/prospector-assistant/assistant/agents/document_agent.py b/assistants/prospector-assistant/assistant/agents/document_agent.py index 039eda2f..bf7b7beb 100644 --- a/assistants/prospector-assistant/assistant/agents/document_agent.py +++ b/assistants/prospector-assistant/assistant/agents/document_agent.py @@ -26,7 +26,7 @@ from .document.gc_attachment_check_config import GCAttachmentCheckConfigModel from .document.gc_draft_outline_feedback_config import GCDraftOutlineFeedbackConfigModel from .document.guided_conversation import GuidedConversationAgent -from .document.status import Status +from .document.status import Status, StepName logger = logging.getLogger(__name__) @@ -36,14 +36,6 @@ # -class StepName(StrEnum): - UNDEFINED = "undefined" - DO_GC_ATTACHMENT_CHECK = "step_gc_attachment_check" - DO_DRAFT_OUTLINE = "step_draft_outline" - DO_GC_GET_OUTLINE_FEEDBACK = "step_gc_get_outline_feedback" - DO_FINAL_OUTLINE = "step_final_outline" - - class ModeName(StrEnum): UNDEFINED = "undefined" DRAFT_OUTLINE = "mode_draft_outline" @@ -385,7 +377,7 @@ async def _run_mode( step_method = self._get_step_method(step) if step_method: logger.info("Document Agent in step: %s", step_name) - step_status = await step_method(config, context, message, metadata) + step_status, next_step_name = await step_method(config, context, message, metadata) match step_status: # resulting status of step_method() case Status.NOT_COMPLETED: @@ -394,18 +386,26 @@ async def _run_mode( break # ok - get more user input case Status.USER_COMPLETED: - next_step = self._state.mode.get_next_step() - if next_step is not None: - step = next_step - step_name = next_step.get_name() - step_status = next_step.get_status() # new step is Status.INITIATED - self._state.mode.set_step(next_step) + if next_step_name is not None: + step = Step(name=next_step_name, status=Status.INITIATED) + step_name = step.get_name() + step_status = step.get_status() + self._state.mode.set_step(step) self._write_state(context) continue # ok - don't need user input yet - else: - self._state.mode.get_step().set_status(step_status) - self._state.mode.set_status(step_status) - break # ok - all done :) + else: # go with prescribed order? Not sure if we want this long term, or just go with above + next_step = self._state.mode.get_next_step() + if next_step is not None: + step = next_step + step_name = next_step.get_name() + step_status = next_step.get_status() # new step is Status.INITIATED + self._state.mode.set_step(next_step) + self._write_state(context) + continue # ok - don't need user input yet + else: + self._state.mode.get_step().set_status(step_status) + self._state.mode.set_status(step_status) + break # ok - all done :) case Status.USER_EXIT_EARLY: self._state.mode.get_step().set_status(step_status) @@ -470,7 +470,7 @@ async def _mode_draft_outline( StepName.DO_GC_ATTACHMENT_CHECK, StepName.DO_DRAFT_OUTLINE, StepName.DO_GC_GET_OUTLINE_FEEDBACK, - StepName.DO_FINAL_OUTLINE, + StepName.DO_FINISH, ], ) logger.info("Document Agent mode (%s) at beginning.", mode_name) @@ -482,7 +482,7 @@ async def _mode_draft_outline( StepName.DO_GC_ATTACHMENT_CHECK: self._step_gc_attachment_check, StepName.DO_DRAFT_OUTLINE: self._step_draft_outline, StepName.DO_GC_GET_OUTLINE_FEEDBACK: self._step_gc_get_outline_feedback, - StepName.DO_FINAL_OUTLINE: self._step_final_outline, + StepName.DO_FINISH: self._step_finish, } # Run @@ -494,11 +494,13 @@ async def _step_gc_attachment_check( context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, - ) -> Status: + ) -> tuple[Status, StepName | None]: + next_step = None + # Pre-requisites if self._state is None: logger.error("Document Agent state is None. Returning.") - return Status.UNDEFINED + return Status.UNDEFINED, next_step step = self._state.mode.get_step() step_name = step.get_name() @@ -517,15 +519,15 @@ async def _step_gc_attachment_check( ) self._state.mode.reset() self._write_state(context) - return self._state.mode.get_status() + return self._state.mode.get_status(), next_step # Run logger.info("Document Agent running step: %s", step_name) - status = await self._gc_attachment_check(config, context, message, metadata) + status, next_step_name = await self._gc_attachment_check(config, context, message, metadata) step.set_status(status) self._state.mode.set_step(step) self._write_state(context) - return step.get_status() + return step.get_status(), next_step_name async def _step_draft_outline( self, @@ -533,11 +535,13 @@ async def _step_draft_outline( context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, - ) -> Status: + ) -> tuple[Status, StepName | None]: + next_step = None + # Pre-requisites if self._state is None: logger.error("Document Agent state is None. Returning.") - return Status.UNDEFINED + return Status.UNDEFINED, next_step step = self._state.mode.get_step() step_name = step.get_name() @@ -555,15 +559,15 @@ async def _step_draft_outline( ) self._state.mode.reset() self._write_state(context) - return self._state.mode.get_status() + return self._state.mode.get_status(), next_step # Run logger.info("Document Agent running step: %s", step_name) - status = await self._draft_outline(config, context, message, metadata) + status, next_step_name = await self._draft_outline(config, context, message, metadata) step.set_status(status) self._state.mode.set_step(step) self._write_state(context) - return step.get_status() + return step.get_status(), next_step_name async def _step_gc_get_outline_feedback( self, @@ -571,11 +575,13 @@ async def _step_gc_get_outline_feedback( context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, - ) -> Status: + ) -> tuple[Status, StepName | None]: + next_step_name = None + # Pre-requisites if self._state is None: logger.error("Document Agent state is None. Returning.") - return Status.UNDEFINED + return Status.UNDEFINED, next_step_name step = self._state.mode.get_step() step_name = step.get_name() @@ -594,7 +600,7 @@ async def _step_gc_get_outline_feedback( ) self._state.mode.reset() self._write_state(context) - return self._state.mode.get_status() + return self._state.mode.get_status(), next_step_name # Run user_message: ConversationMessage | None @@ -604,29 +610,22 @@ async def _step_gc_get_outline_feedback( user_message = message logger.info("Document Agent running step: %s", step_name) - status = await self._gc_outline_feedback(config, context, user_message, metadata) - - # should this be status or a different return as part of a tuple... like next step request? - # this way the control would still be higher (and non recursive) as the branching logic will return instead of call internally the next step. - # but controlling of what gets called next will be determined by each step at its completion. it seems like there should still be - # some level of oversight by the mode layer in case we end up in an endless loop of steps saying the should each be called next...(still a problem.) - if status is Status.UPDATE_OUTLINE: - status = await self._draft_outline(config, context, message, metadata) + status, next_step_name = await self._gc_outline_feedback(config, context, user_message, metadata) step.set_status(status) self._state.mode.set_step(step) self._write_state(context) - return step.get_status() + return step.get_status(), next_step_name - async def _step_final_outline( + async def _step_finish( self, config: AssistantConfigModel, context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, - ) -> Status: + ) -> tuple[Status, StepName | None]: # pretend completed - return Status.USER_COMPLETED + return Status.USER_COMPLETED, None # endregion @@ -640,7 +639,7 @@ async def _gc_attachment_check( context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, - ) -> Status: + ) -> tuple[Status, StepName | None]: method_metadata_key = "document_agent_gc_response" gc_convo_config: GuidedConversationAgentConfigModel = GCAttachmentCheckConfigModel() @@ -654,7 +653,7 @@ async def _gc_attachment_check( gc_convo_config.context = gc_convo_config.context + "\n\n" + filenames_str try: - response_message, conversation_status = await GuidedConversationAgent.step_conversation( + response_message, conversation_status, next_step_name = await GuidedConversationAgent.step_conversation( config=config, openai_client=openai_client.create_client(config.service_config), agent_config=gc_convo_config, @@ -697,7 +696,7 @@ async def _gc_attachment_check( # Need to add a good way to stop mode if an exception occurs. # Also need to update the gc state turn count to 0 (and any thing else that needs to be reset) once conversation is over... or exception occurs?) - return conversation_status + return conversation_status, next_step_name async def _draft_outline( self, @@ -705,7 +704,7 @@ async def _draft_outline( context: ConversationContext, message: ConversationMessage, metadata: dict[str, Any] = {}, - ) -> Status: + ) -> tuple[Status, StepName | None]: method_metadata_key = "draft_outline" # get conversation related info @@ -771,7 +770,7 @@ async def _draft_outline( ) ) - return Status.USER_COMPLETED + return Status.USER_COMPLETED, None async def _gc_outline_feedback( self, @@ -779,7 +778,7 @@ async def _gc_outline_feedback( context: ConversationContext, message: ConversationMessage | None, metadata: dict[str, Any] = {}, - ) -> Status: + ) -> tuple[Status, StepName | None]: method_metadata_key = "document_agent_gc_response" gc_do_feedback_config: GuidedConversationAgentConfigModel = GCDraftOutlineFeedbackConfigModel() @@ -807,7 +806,7 @@ async def _gc_outline_feedback( user_message = None try: - response_message, conversation_status = await GuidedConversationAgent.step_conversation( + response_message, conversation_status, next_step_name = await GuidedConversationAgent.step_conversation( config=config, openai_client=openai_client.create_client(config.service_config), agent_config=gc_do_feedback_config, @@ -850,7 +849,7 @@ async def _gc_outline_feedback( # Need to add a good way to stop mode if an exception occurs. # Also need to update the gc state turn count to 0 (and any thing else that needs to be reset) once conversation is over... or exception occurs?) - return conversation_status + return conversation_status, next_step_name # endregion