From 6a44fd9174563f31a582cca7bab0dac2e3b1ac83 Mon Sep 17 00:00:00 2001 From: Sean Sube Date: Sun, 26 May 2024 15:59:12 -0500 Subject: [PATCH] support multi-step conversations, improve prompts, summarize room more often --- adventure/actions/base.py | 79 ++++++++++++---- adventure/actions/optional.py | 1 + adventure/actions/planning.py | 45 ++------- adventure/main.py | 5 +- adventure/models/config.py | 11 ++- adventure/simulate.py | 65 +++++++------ adventure/systems/logic.py | 11 ++- adventure/systems/quest.py | 8 +- adventure/utils/conversation.py | 159 ++++++++++++++++++++++++++++++++ adventure/utils/planning.py | 37 ++++++++ adventure/utils/systems.py | 6 +- 11 files changed, 331 insertions(+), 96 deletions(-) create mode 100644 adventure/utils/conversation.py create mode 100644 adventure/utils/planning.py diff --git a/adventure/actions/base.py b/adventure/actions/base.py index f313d5c..86d174d 100644 --- a/adventure/actions/base.py +++ b/adventure/actions/base.py @@ -1,14 +1,13 @@ -from json import loads from logging import getLogger -from packit.utils import could_be_json - from adventure.context import ( action_context, broadcast, get_actor_agent_for_name, + get_agent_for_actor, world_context, ) +from adventure.utils.conversation import loop_conversation from adventure.utils.search import ( find_actor_in_room, find_item_in_actor, @@ -20,6 +19,8 @@ from adventure.utils.world import describe_entity logger = getLogger(__name__) +MAX_CONVERSATION_STEPS = 3 + def action_look(target: str) -> str: """ @@ -90,7 +91,9 @@ def action_move(direction: str) -> str: action_room.actors.remove(action_actor) destination_room.actors.append(action_actor) - return f"You move {direction} and arrive at {destination_room.name}." + return ( + f"You move through the {direction} and arrive at {destination_room.name}." + ) def action_take(item: str) -> str: @@ -116,11 +119,11 @@ def action_ask(character: str, question: str) -> str: Ask another character a question. Args: - character: The name of the character to ask. + character: The name of the character to ask. You cannot ask yourself questions. question: The question to ask them. """ # capture references to the current actor and room, because they will be overwritten - with action_context() as (_, action_actor): + with action_context() as (action_room, action_actor): # sanity checks question_actor, question_agent = get_actor_agent_for_name(character) if question_actor == action_actor: @@ -133,15 +136,33 @@ def action_ask(character: str, question: str) -> str: return f"The {character} character does not exist." broadcast(f"{action_actor.name} asks {character}: {question}") - answer = question_agent( - f"{action_actor.name} asks you: {question}. Reply with your response to them. " - f"Do not include the question or any JSON. Only include your answer for {action_actor.name}." + first_prompt = ( + "{last_actor.name} asks you: {response}\n" + "Reply with your response to them. Reply with 'END' to end the conversation. " + "Do not include the question or any JSON. Only include your answer for {last_actor.name}." + ) + reply_prompt = ( + "{last_actor.name} continues the conversation with you. They reply: {response}\n" + "Reply with your response to them. Reply with 'END' to end the conversation. " + "Do not include the question or any JSON. Only include your answer for {last_actor.name}." ) - if could_be_json(answer) and action_tell.__name__ in answer: - answer = loads(answer).get("parameters", {}).get("message", "") + action_agent = get_agent_for_actor(action_actor) + answer = loop_conversation( + action_room, + [question_actor, action_actor], + [question_agent, action_agent], + action_actor, + first_prompt, + reply_prompt, + question, + "Goodbye", + echo_function=action_tell.__name__, + echo_parameter="message", + max_length=MAX_CONVERSATION_STEPS, + ) - if len(answer.strip()) > 0: + if answer: broadcast(f"{character} responds to {action_actor.name}: {answer}") return f"{character} responds: {answer}" @@ -153,12 +174,12 @@ def action_tell(character: str, message: str) -> str: Tell another character a message. Args: - character: The name of the character to tell. + character: The name of the character to tell. You cannot talk to yourself. message: The message to tell them. """ # capture references to the current actor and room, because they will be overwritten - with action_context() as (_, action_actor): + with action_context() as (action_room, action_actor): # sanity checks question_actor, question_agent = get_actor_agent_for_name(character) if question_actor == action_actor: @@ -171,15 +192,33 @@ def action_tell(character: str, message: str) -> str: return f"The {character} character does not exist." broadcast(f"{action_actor.name} tells {character}: {message}") - answer = question_agent( - f"{action_actor.name} tells you: {message}. Reply with your response to them. " - f"Do not include the message or any JSON. Only include your reply to {action_actor.name}." + first_prompt = ( + "{last_actor.name} starts a conversation with you. They say: {response}\n" + "Reply with your response to them. " + "Do not include the message or any JSON. Only include your reply to {last_actor.name}." + ) + reply_prompt = ( + "{last_actor.name} continues the conversation with you. They reply: {response}\n" + "Reply with your response to them. " + "Do not include the message or any JSON. Only include your reply to {last_actor.name}." ) - if could_be_json(answer) and action_tell.__name__ in answer: - answer = loads(answer).get("parameters", {}).get("message", "") + action_agent = get_agent_for_actor(action_actor) + answer = loop_conversation( + action_room, + [question_actor, action_actor], + [question_agent, action_agent], + action_actor, + first_prompt, + reply_prompt, + message, + "Goodbye", + echo_function=action_tell.__name__, + echo_parameter="message", + max_length=MAX_CONVERSATION_STEPS, + ) - if len(answer.strip()) > 0: + if answer: broadcast(f"{character} responds to {action_actor.name}: {answer}") return f"{character} responds: {answer}" diff --git a/adventure/actions/optional.py b/adventure/actions/optional.py index dcb9019..af6e754 100644 --- a/adventure/actions/optional.py +++ b/adventure/actions/optional.py @@ -124,6 +124,7 @@ def action_use(item: str, target: str) -> str: target_actor = action_actor target = action_actor.name else: + # TODO: allow targeting the room itself and items in the room target_actor = find_actor_in_room(action_room, target) if not target_actor: return f"The {target} character is not in the room." diff --git a/adventure/actions/planning.py b/adventure/actions/planning.py index 355f251..9f94b78 100644 --- a/adventure/actions/planning.py +++ b/adventure/actions/planning.py @@ -1,5 +1,6 @@ from adventure.context import action_context, get_current_step from adventure.models.planning import CalendarEvent +from adventure.utils.planning import get_recent_notes def take_note(fact: str): @@ -13,10 +14,10 @@ def take_note(fact: str): with action_context() as (_, action_actor): if fact in action_actor.planner.notes: - return "You already know that." + return "You already have a note about that fact." action_actor.planner.notes.append(fact) - return "You make a note of that." + return "You make a note of that fact." def read_notes(unused: bool, count: int = 10): @@ -27,8 +28,9 @@ def read_notes(unused: bool, count: int = 10): count: The number of recent notes to read. 10 is usually a good number. """ - facts = get_recent_notes(count=count) - return "\n".join(facts) + with action_context() as (_, action_actor): + facts = get_recent_notes(action_actor, count=count) + return "\n".join(facts) def erase_notes(prefix: str) -> str: @@ -74,7 +76,8 @@ def replace_note(old: str, new: str) -> str: def schedule_event(name: str, turns: int): """ Schedule an event to happen at a specific turn. Events are important occurrences that can affect the world in - significant ways. You will be notified about upcoming events so you can plan accordingly. + significant ways. You will be notified about upcoming events so you can plan accordingly. Make sure you inform + other characters about events that involve them, and give them enough time to prepare. Args: name: The name of the event. @@ -88,7 +91,7 @@ def schedule_event(name: str, turns: int): return f"{name} is scheduled to happen in {turns} turns." -def read_calendar(unused: bool, count: int = 10): +def check_calendar(unused: bool, count: int = 10): """ Read your calendar to see upcoming events that you have scheduled. """ @@ -103,33 +106,3 @@ def read_calendar(unused: bool, count: int = 10): for event in events ] ) - - -def get_upcoming_events(turns: int = 3): - """ - Get a list of upcoming events within a certain number of turns. - - Args: - turns: The number of turns to look ahead for events. - """ - - current_turn = get_current_step() - - with action_context() as (_, action_actor): - calendar = action_actor.planner.calendar - # TODO: sort events by turn - return [ - event for event in calendar.events if event.turn - current_turn <= turns - ] - - -def get_recent_notes(count: int = 3): - """ - Get the most recent facts from your notes. - - Args: - history: The number of recent facts to retrieve. - """ - - with action_context() as (_, action_actor): - return action_actor.planner.notes[-count:] diff --git a/adventure/main.py b/adventure/main.py index d633cae..93f5837 100644 --- a/adventure/main.py +++ b/adventure/main.py @@ -176,16 +176,15 @@ def load_or_initialize_system_data(args, systems: List[GameSystem], world: World if system.data: system_data_file = f"{args.world}.{system.name}.json" - data = None if path.exists(system_data_file): logger.info(f"loading system data from {system_data_file}") data = system.data.load(system_data_file) + set_system_data(system.name, data) else: logger.info(f"no system data found at {system_data_file}") if system.initialize: data = system.initialize(world) - - set_system_data(system.name, data) + set_system_data(system.name, data) def save_system_data(args, systems: List[GameSystem]): diff --git a/adventure/models/config.py b/adventure/models/config.py index 40f5174..5f05914 100644 --- a/adventure/models/config.py +++ b/adventure/models/config.py @@ -40,6 +40,11 @@ class ServerConfig: websocket: WebsocketServerConfig +@dataclass +class WorldActorConfig: + conversation_limit: int + + @dataclass class WorldSizeConfig: actor_items: IntRange @@ -52,6 +57,7 @@ class WorldSizeConfig: @dataclass class WorldConfig: + actor: WorldActorConfig size: WorldSizeConfig @@ -80,6 +86,9 @@ DEFAULT_CONFIG = Config( ), server=ServerConfig(websocket=WebsocketServerConfig(host="localhost", port=8001)), world=WorldConfig( + actor=WorldActorConfig( + conversation_limit=3, + ), size=WorldSizeConfig( actor_items=IntRange(min=0, max=2), item_effects=IntRange(min=1, max=2), @@ -87,6 +96,6 @@ DEFAULT_CONFIG = Config( rooms=IntRange(min=3, max=6), room_actors=IntRange(min=1, max=3), room_items=IntRange(min=1, max=3), - ) + ), ), ) diff --git a/adventure/simulate.py b/adventure/simulate.py index 336bd7b..d5aea04 100644 --- a/adventure/simulate.py +++ b/adventure/simulate.py @@ -5,7 +5,7 @@ from math import inf from typing import Callable, Sequence from packit.agent import Agent -from packit.conditions import condition_or, condition_threshold, make_flag_condition +from packit.conditions import condition_or, condition_threshold from packit.loops import loop_reduce, loop_retry from packit.results import multi_function_or_str_result from packit.toolbox import Toolbox @@ -20,10 +20,9 @@ from adventure.actions.base import ( action_tell, ) from adventure.actions.planning import ( + check_calendar, erase_notes, get_recent_notes, - get_upcoming_events, - read_calendar, read_notes, replace_note, schedule_event, @@ -44,9 +43,10 @@ from adventure.context import ( from adventure.game_system import GameSystem from adventure.models.entity import Actor, Room, World from adventure.models.event import ActionEvent, ReplyEvent, ResultEvent +from adventure.utils.conversation import make_keyword_condition, summarize_room from adventure.utils.effect import expire_effects +from adventure.utils.planning import expire_events, get_upcoming_events from adventure.utils.search import find_room_with_actor -from adventure.utils.string import normalize_name from adventure.utils.world import describe_entity, format_attributes logger = getLogger(__name__) @@ -72,8 +72,12 @@ def world_result_parser(value, agent, **kwargs): return multi_function_or_str_result(value, agent=agent, **kwargs) -def prompt_actor_action(room, actor, agent, action_names, action_toolbox) -> str: +def prompt_actor_action( + room, actor, agent, action_names, action_toolbox, current_turn +) -> str: # collect data for the prompt + notes_prompt, events_prompt = get_notes_events(actor, current_turn) + room_actors = [actor.name for actor in room.actors] room_items = [item.name for item in room.items] room_directions = [portal.name for portal in room.portals] @@ -101,12 +105,13 @@ def prompt_actor_action(room, actor, agent, action_names, action_toolbox) -> str result = loop_retry( agent, ( - "You are currently in {room_name}. {room_description}. {attributes}. " + "You are currently in the {room_name} room. {room_description}. {attributes}. " "The room contains the following characters: {visible_actors}. " "The room contains the following items: {visible_items}. " "Your inventory contains the following items: {actor_items}." "You can take the following actions: {actions}. " "You can move in the following directions: {directions}. " + "{notes_prompt} {events_prompt}" "What will you do next? Reply with a JSON function call, calling one of the actions." "You can only perform one action per turn. What is your next action?" ), @@ -119,6 +124,8 @@ def prompt_actor_action(room, actor, agent, action_names, action_toolbox) -> str "room_description": describe_entity(room), "visible_actors": room_actors, "visible_items": room_items, + "notes_prompt": notes_prompt, + "events_prompt": events_prompt, }, result_parser=result_parser, toolbox=action_toolbox, @@ -132,11 +139,9 @@ def prompt_actor_action(room, actor, agent, action_names, action_toolbox) -> str return result -def prompt_actor_think( - room: Room, actor: Actor, agent: Agent, planner_toolbox: Toolbox -) -> str: - recent_notes = get_recent_notes() - upcoming_events = get_upcoming_events() +def get_notes_events(actor: Actor, current_turn: int): + recent_notes = get_recent_notes(actor) + upcoming_events = get_upcoming_events(actor, current_turn) if len(recent_notes) > 0: notes = "\n".join(recent_notes) @@ -155,27 +160,30 @@ def prompt_actor_think( else: events_prompt = "You have no upcoming events.\n" + return notes_prompt, events_prompt + + +def prompt_actor_think( + room: Room, actor: Actor, agent: Agent, planner_toolbox: Toolbox, current_turn: int +) -> str: + notes_prompt, events_prompt = get_notes_events(actor, current_turn) + event_count = len(actor.planner.calendar.events) note_count = len(actor.planner.notes) logger.info("starting planning for actor: %s", actor.name) - set_end, condition_end = make_flag_condition() - - def result_parser(value, **kwargs): - if normalize_name(value) == "end": - set_end() - - return multi_function_or_str_result(value, **kwargs) - + _, condition_end, result_parser = make_keyword_condition("You are done planning.") stop_condition = condition_or(condition_end, partial(condition_threshold, max=3)) result = loop_reduce( agent, - "You are about to take your turn. Plan your next action carefully. " + "You are about to start your turn. Plan your next action carefully. Take notes and schedule events to help keep track of your goals. " "You can check your notes for important facts or check your calendar for upcoming events. You have {note_count} notes. " - "Try to keep your notes accurate. Replace or erase old notes if they are no longer accurate or useful. " - "If you have upcoming events with other characters, schedule them on your calendar. You have {event_count} calendar events. " + "If you have plans with other characters, schedule them on your calendar. You have {event_count} events on your calendar. " + "{room_summary}" "Think about your goals and any quests that you are working on, and plan your next action accordingly. " + "Try to keep your notes accurate and up-to-date. Replace or erase old notes when they are no longer accurate or useful. " + "Do not keeps notes about upcoming events, use your calendar for that. " "You can perform up to 3 planning actions in a single turn. When you are done planning, reply with 'END'." "{notes_prompt} {events_prompt}", context={ @@ -183,6 +191,7 @@ def prompt_actor_think( "events_prompt": events_prompt, "note_count": note_count, "notes_prompt": notes_prompt, + "room_summary": summarize_room(room, actor), }, result_parser=result_parser, stop_condition=stop_condition, @@ -227,7 +236,7 @@ def simulate_world( replace_note, erase_notes, schedule_event, - read_calendar, + check_calendar, ] ) @@ -253,17 +262,21 @@ def simulate_world( # decrement effects on the actor and remove any that have expired expire_effects(actor) - # TODO: expire calendar events + expire_events(actor, current_step) # give the actor a chance to think and check their planner if agent.memory and len(agent.memory) > 0: try: - thoughts = prompt_actor_think(room, actor, agent, planner_toolbox) + thoughts = prompt_actor_think( + room, actor, agent, planner_toolbox, current_step + ) logger.debug(f"{actor.name} thinks: {thoughts}") except Exception: logger.exception(f"error during planning for actor {actor.name}") - result = prompt_actor_action(room, actor, agent, action_names, action_tools) + result = prompt_actor_action( + room, actor, agent, action_names, action_tools, current_step + ) result_event = ResultEvent(result=result, room=room, actor=actor) broadcast(result_event) diff --git a/adventure/systems/logic.py b/adventure/systems/logic.py index 39f7110..9755048 100644 --- a/adventure/systems/logic.py +++ b/adventure/systems/logic.py @@ -17,7 +17,7 @@ logger = getLogger(__name__) @dataclass class LogicLabel: - backstory: str + backstory: str | None = None description: str | None = None match: Optional[Attributes] = None rule: Optional[str] = None @@ -134,7 +134,7 @@ def update_logic( data: Any | None = None, *, rules: LogicTable, - triggers: TriggerTable + triggers: TriggerTable, ) -> None: for room in world.rooms: update_attributes(room, rules=rules, triggers=triggers) @@ -157,7 +157,7 @@ def format_logic( for label in rules.labels: if match_logic(entity, label): - if perspective == FormatPerspective.SECOND_PERSON: + if perspective == FormatPerspective.SECOND_PERSON and label.backstory: labels.append(label.backstory) elif perspective == FormatPerspective.THIRD_PERSON and label.description: labels.append(label.description) @@ -171,7 +171,8 @@ def format_logic( def load_logic(filename: str): - system_name = "logic-" + path.splitext(path.basename(filename))[0] + basename = path.splitext(path.basename(filename))[0] + system_name = f"logic_{basename}" logger.info("loading logic from file %s as system %s", filename, system_name) with open(filename) as file: @@ -188,7 +189,7 @@ def load_logic(filename: str): logger.info("initialized logic system") system_format = wraps(format_logic)(partial(format_logic, rules=logic_rules)) - system_initialize = wraps(load_logic)( + system_initialize = wraps(update_logic)( partial(update_logic, step=0, rules=logic_rules, triggers=logic_triggers) ) system_simulate = wraps(update_logic)( diff --git a/adventure/systems/quest.py b/adventure/systems/quest.py index 93d9e04..776bb37 100644 --- a/adventure/systems/quest.py +++ b/adventure/systems/quest.py @@ -194,18 +194,20 @@ def simulate_quests(world: World, step: int, data: QuestData | None = None) -> N 3. Generate any new quests. """ - if not data: + # TODO: switch to using data parameter + quests: QuestData | None = get_system_data(QUEST_SYSTEM) + if not quests: # TODO: initialize quest data for worlds that don't have it raise ValueError("Quest data is required for simulation") for room in world.rooms: for actor in room.actors: - active_quest = get_active_quest(data, actor) + active_quest = get_active_quest(quests, actor) if active_quest: logger.info(f"simulating quest for {actor.name}: {active_quest.name}") if is_quest_complete(world, active_quest): logger.info(f"quest complete for {actor.name}: {active_quest.name}") - complete_quest(data, actor, active_quest) + complete_quest(quests, actor, active_quest) def load_quest_data(file: str) -> QuestData: diff --git a/adventure/utils/conversation.py b/adventure/utils/conversation.py new file mode 100644 index 0000000..8d787ab --- /dev/null +++ b/adventure/utils/conversation.py @@ -0,0 +1,159 @@ +from functools import partial +from json import loads +from logging import getLogger +from typing import List + +from packit.agent import Agent +from packit.conditions import condition_and, condition_threshold, make_flag_condition +from packit.results import multi_function_or_str_result +from packit.utils import could_be_json + +from adventure.context import broadcast +from adventure.models.config import DEFAULT_CONFIG +from adventure.models.entity import Actor, Room + +from .string import normalize_name + +logger = getLogger(__name__) + + +actor_config = DEFAULT_CONFIG.world.actor + + +def make_keyword_condition(end_message: str, keywords=["end", "stop"]): + set_end, condition_end = make_flag_condition() + + def result_parser(value, **kwargs): + normalized_value = normalize_name(value) + if normalized_value in keywords: + logger.debug(f"found keyword, setting stop condition: {normalized_value}") + set_end() + return end_message + + # sometimes the models will make up a tool named after the keyword + keyword_functions = [f'"function": "{kw}"' for kw in keywords] + if could_be_json(normalized_value) and any( + kw in normalized_value for kw in keyword_functions + ): + logger.debug( + f"found keyword function, setting stop condition: {normalized_value}" + ) + set_end() + return end_message + + return multi_function_or_str_result(value, **kwargs) + + return set_end, condition_end, result_parser + + +def and_list(items: List[str]) -> str: + """ + Convert a list of items into a human-readable list. + """ + if not items: + return "nothing" + + if len(items) == 1: + return items[0] + + return f"{', '.join(items[:-1])}, and {items[-1]}" + + +def or_list(items: List[str]) -> str: + """ + Convert a list of items into a human-readable list. + """ + if not items: + return "nothing" + + if len(items) == 1: + return items[0] + + return f"{', '.join(items[:-1])}, or {items[-1]}" + + +def summarize_room(room: Room, player: Actor) -> str: + """ + Summarize a room for the player. + """ + + actor_names = and_list( + [actor.name for actor in room.actors if actor.name != player.name] + ) + item_names = and_list([item.name for item in room.items]) + inventory_names = and_list([item.name for item in player.items]) + + return ( + f"You are in the {room.name} room with {actor_names}. " + f"You see the {item_names} around the room. " + f"You are carrying the {inventory_names}." + ) + + +def loop_conversation( + room: Room, + actors: List[Actor], + agents: List[Agent], + first_actor: Actor, + first_prompt: str, + reply_prompt: str, + first_message: str, + end_message: str, + echo_function: str | None = None, + echo_parameter: str | None = None, + max_length: int | None = None, +) -> str | None: + """ + Loop through a conversation between a series of agents, using metadata from their actors. + """ + + if max_length is None: + max_length = actor_config.conversation_limit + + if len(actors) != len(agents): + raise ValueError("The number of actors and agents must match.") + + _, condition_end, parse_end = make_keyword_condition(end_message) + stop_length = partial(condition_threshold, max=max_length) + stop_condition = condition_and(condition_end, stop_length) + + def result_parser(value: str, **kwargs) -> str: + value = parse_end(value, **kwargs) + + if condition_end(): + return value + + if echo_function and could_be_json(value) and echo_function in value: + value = loads(value).get("parameters", {}).get(echo_parameter, "") + + return value.strip() + + i = 0 + last_actor = first_actor + response = first_message + + while not stop_condition(current=i): + if i == 0: + logger.debug(f"starting conversation with {first_actor.name}") + prompt = first_prompt + else: + logger.debug(f"continuing conversation with {last_actor.name} on step {i}") + prompt = reply_prompt + + # loop through the actors and agents + actor = actors[i % len(actors)] + agent = agents[i % len(agents)] + + # summarize the room and present the last response + summary = summarize_room(room, actor) + response = agent( + prompt, response=response, summary=summary, last_actor=last_actor + ) + response = result_parser(response) + broadcast(f"{actor.name} responds: {response}") + + # increment the step counter + i += 1 + last_actor = actor + + return response diff --git a/adventure/utils/planning.py b/adventure/utils/planning.py new file mode 100644 index 0000000..f336a9e --- /dev/null +++ b/adventure/utils/planning.py @@ -0,0 +1,37 @@ +from adventure.models.entity import Actor + + +def expire_events(actor: Actor, current_turn: int): + """ + Expire events that have already happened. + """ + + events = actor.planner.calendar.events + expired_events = [event for event in events if event.turn < current_turn] + actor.planner.calendar.events[:] = [ + event for event in events if event not in expired_events + ] + + return expired_events + + +def get_recent_notes(actor: Actor, count: int = 3): + """ + Get the most recent facts from your notes. + """ + + return actor.planner.notes[-count:] + + +def get_upcoming_events(actor: Actor, current_turn: int, upcoming_turns: int = 3): + """ + Get a list of upcoming events within a certain number of turns. + """ + + calendar = actor.planner.calendar + # TODO: sort events by turn + return [ + event + for event in calendar.events + if event.turn - current_turn <= upcoming_turns + ] diff --git a/adventure/utils/systems.py b/adventure/utils/systems.py index 71e9d73..7274e02 100644 --- a/adventure/utils/systems.py +++ b/adventure/utils/systems.py @@ -4,8 +4,10 @@ from adventure.utils.file import load_yaml, save_yaml def load_system_data(cls, file): - with load_yaml(file) as data: - return cls(**data) + with open(file, "r") as f: + data = load_yaml(f) + + return cls(**data) def save_system_data(cls, file, model):