defmodule GenAI.VNext.Graph do @vsn 1.0 @moduledoc """ A graph data structure for representing AI graphs, threads, conversations, uml, etc. Utility Class """ use GenAI.Graph.NodeBehaviour alias GenAI.Graph.Link alias GenAI.Graph.NodeProtocol alias GenAI.Records, as: R alias GenAI.Types, as: T # alias VNextGenAI.Session.NodeProtocol.Records, as: Node import GenAI.Records.Link import GenAI.Records.Node require GenAI.Records.Link require GenAI.Records.Node require GenAI.Types.Graph # require VNextGenAI.Session.NodeProtocol.Records @derive GenAI.Graph.NodeProtocol # @derive VNextGenAI.Session.NodeProtocol defnodetype( nodes: %{T.Graph.graph_node_id() => T.Graph.graph_node()}, node_handles: %{T.handle() => T.Graph.graph_node_id()}, links: %{T.Graph.graph_link_id() => T.Graph.graph_link()}, link_handles: %{T.handle() => T.Graph.graph_link_id()}, head: T.Graph.graph_node_id() | nil, last_node: T.Graph.graph_node_id() | nil, last_link: T.Graph.graph_link_id() | nil ) defnodestruct( nodes: %{}, node_handles: %{}, links: %{}, link_handles: %{}, head: nil, last_node: nil, last_link: nil, settings: nil ) def inspect_custom_details(subject, opts) do [ "nodes:", Inspect.Algebra.to_doc(subject.nodes, opts), ", ", "links:", Inspect.Algebra.to_doc(subject.links, opts), ", ", "head:", Inspect.Algebra.to_doc(subject.head, opts), ", ", "last_node:", Inspect.Algebra.to_doc(subject.last_node, opts), ", ", "last_link:", Inspect.Algebra.to_doc(subject.last_link, opts), ", ", "settings:", Inspect.Algebra.to_doc(subject.settings, opts), ", ", ] end def process_node(%__MODULE__{} = subject, link, _, session, context, options) do with {:ok, head} <- GenAI.VNext.Graph.head(subject) do do_process_node(head, link, subject, session, context, options) end end def do_process_node(subject, link, container, session, context, options) do case GenAI.Graph.NodeProtocol.process_node( subject, link, container, session, context, options ) do GenAI.Records.Node.process_next( element: element_context(element: target, link: link, container: container), session: session ) -> do_process_node(target, link, container, session, context, options) x = process_end() -> x x = process_error() -> x x = process_yield() -> x end end @spec do_new() :: __MODULE__.t() @spec do_new(keyword) :: __MODULE__.t() def do_new(options \\ nil) def do_new(options) do settings = Keyword.merge( [ auto_head: false, update_last: true, update_last_link: false, auto_link: false ], options[:settings] || [] ) %__MODULE__{ id: options[:id] || UUID.uuid4(), handle: options[:handle] || nil, name: options[:name] || nil, description: options[:description] || nil, nodes: %{}, node_handles: %{}, links: %{}, link_handles: %{}, head: nil, last_node: nil, last_link: nil, settings: settings } end @spec setting(__MODULE__.t(), atom, keyword, any) :: any def setting(%__MODULE__{settings: settings}, setting, options, default \\ nil) do x = options[setting] cond do is_nil(x) or x == :default -> x = settings[setting] cond do x == nil -> default :else -> x end :else -> x end end # ============================================================================= # Graph Protocol # ============================================================================= # ------------------------- # node/2 # ------------------------- @doc """ Obtain node by id. ## Examples ### When Found iex> graph = GenAI.VNext.Graph.new() ...> node = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> graph = GenAI.VNext.Graph.add_node(graph, node) ...> GenAI.VNext.Graph.node(graph, node.id) {:ok, node} ### When Not Found iex> graph = GenAI.VNext.Graph.new() ...> GenAI.VNext.Graph.node(graph, UUID.uuid4()) {:error, {:node, :not_found}} """ @spec node(graph :: T.Graph.graph(), id :: T.Graph.graph_node_id()) :: T.result(T.Graph.graph_node(), T.details()) def node(graph, graph_node) def node(graph, R.Link.connector(node: id)) do node(graph, id) end def node(graph, graph_node) when T.Graph.is_node_id(graph_node) do if x = graph.nodes[graph_node] do {:ok, x} else {:error, {:node, :not_found}} end end def node(graph, graph_node) when is_struct(graph_node) do with {:ok, id} <- NodeProtocol.id(graph_node) do node(graph, id) end end def do_node(graph, graph_node), do: node(graph, graph_node) @spec nodes(T.Graph.graph()) :: {:ok, list(T.Graph.graph_node())} @spec nodes(T.Graph.graph(), keyword) :: {:ok, list(T.Graph.graph_node())} def nodes(graph, options \\ nil) def nodes(graph, _) do nodes = Map.values(graph.nodes) {:ok, nodes} end # ------------------------- # node!/2 # ------------------------- @spec nodes!(T.Graph.graph()) :: list(T.Graph.graph_node()) @spec nodes!(T.Graph.graph(), keyword) :: list(T.Graph.graph_node()) def nodes!(graph, options \\ nil) def nodes!(graph, _) do Map.values(graph.nodes) end # ------------------------- # link/2 # ------------------------- @doc """ Obtain link by id. ## Examples ### When Found iex> graph = GenAI.VNext.Graph.new() ...> node1 = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> node2 = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> graph = GenAI.VNext.Graph.add_node(graph, node1) ...> graph = GenAI.VNext.Graph.add_node(graph, node2) ...> link = GenAI.Graph.Link.new(node1.id, node2.id) ...> graph = GenAI.VNext.Graph.add_link(graph, link) ...> GenAI.VNext.Graph.link(graph, link.id) {:ok, link} ### When Not Found iex> graph = GenAI.VNext.Graph.new() ...> GenAI.VNext.Graph.link(graph, UUID.uuid4()) {:error, {:link, :not_found}} """ @spec link(graph :: T.Graph.graph(), id :: T.Graph.graph_link_id()) :: T.result(T.Graph.graph_link(), T.details()) def link(graph, graph_link) def link(graph, graph_link) when T.Graph.is_link_id(graph_link) do if x = graph.links[graph_link] do {:ok, x} else {:error, {:link, :not_found}} end end def link(graph, graph_link) when is_struct(graph_link) do with {:ok, id} <- Link.id(graph_link) do node(graph, id) end end # ------------------------- # member?/2 # ------------------------- @doc """ Check if a node is a member of the graph. ## Examples iex> graph = GenAI.VNext.Graph.new() ...> node = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> graph = GenAI.VNext.Graph.add_node(graph, node) ...> GenAI.VNext.Graph.member?(graph, node.id) true iex> graph = GenAI.VNext.Graph.new() ...> GenAI.VNext.Graph.member?(graph, UUID.uuid4()) false """ @spec member?(graph :: T.Graph.graph(), id :: T.Graph.graph_node_id()) :: boolean def member?(graph, graph_node) def member?(graph, graph_node) when T.Graph.is_node_id(graph_node) do (graph.nodes[graph_node] && true) || false end def member?(graph, graph_node) when is_struct(graph_node) do with {:ok, id} <- NodeProtocol.id(graph_node) do member?(graph, id) end end # ------------------------- # by_handle/2 # ------------------------- @doc """ Obtain node by handle. ## Examples ### When Found iex> graph = GenAI.VNext.Graph.new() ...> node = GenAI.Graph.Node.new(id: UUID.uuid4(), handle: :foo) ...> graph = GenAI.VNext.Graph.add_node(graph, node) ...> GenAI.VNext.Graph.by_handle(graph, :foo) {:ok, node} ### When Not Found iex> graph = GenAI.VNext.Graph.new() ...> GenAI.VNext.Graph.by_handle(graph, :foo) {:error, {:handle, :not_found}} """ @spec by_handle(graph :: T.Graph.graph(), handle :: T.handle()) :: T.result(T.Graph.graph_node(), T.details()) def by_handle(graph, handle) def by_handle(graph, handle) do if x = graph.node_handles[handle] do node(graph, x) else {:error, {:handle, :not_found}} end end # ------------------------- # link_by_handle/2 # ------------------------- @doc """ Obtain link by handle. ## Examples ### When Found iex> graph = GenAI.VNext.Graph.new() ...> node1 = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> node2 = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> graph = GenAI.VNext.Graph.add_node(graph, node1) ...> graph = GenAI.VNext.Graph.add_node(graph, node2) ...> link = GenAI.Graph.Link.new(node1.id, node2.id, handle: :bar) ...> graph = GenAI.VNext.Graph.add_link(graph, link) ...> GenAI.VNext.Graph.link_by_handle(graph, :bar) {:ok, link} ### When Not Found iex> graph = GenAI.VNext.Graph.new() ...> GenAI.VNext.Graph.link_by_handle(graph, :bar) {:error, {:handle, :not_found}} """ @spec link_by_handle(graph :: T.Graph.graph(), handle :: T.handle()) :: T.result(T.Graph.graph_link(), T.details()) def link_by_handle(graph, handle) def link_by_handle(graph, handle) do if x = graph.link_handles[handle] do link(graph, x) else {:error, {:handle, :not_found}} end end # ------------------------- # head/1 # ------------------------- @spec head(T.Graph.graph()) :: T.result(T.Graph.graph_node(), T.details()) def head(graph) def head(%__MODULE__{head: nil}), do: {:error, {:head, :is_nil}} def head(graph = %__MODULE__{head: x}), do: node(graph, x) # ------------------------- # last_node/1 # ------------------------- @spec last_node(T.Graph.graph()) :: T.result(T.Graph.graph_node(), T.details()) def last_node(graph) def last_node(%__MODULE__{last_node: nil}), do: {:error, {:last_node, :is_nil}} def last_node(graph = %__MODULE__{last_node: x}), do: node(graph, x) # ------------------------- # last_link/1 # ------------------------- @spec last_link(T.Graph.graph()) :: T.result(T.Graph.graph_link(), T.details()) def last_link(graph) def last_link(%__MODULE__{last_link: nil}), do: {:error, {:last_link, :is_nil}} def last_link(graph = %__MODULE__{last_link: x}), do: link(graph, x) @spec attempt_set_handle(T.Graph.graph(), T.Graph.graph_node_id(), T.Graph.graph_node()) :: T.Graph.graph() defp attempt_set_handle(graph, id, node) do with {:ok, handle} <- NodeProtocol.handle(node) do if graph.node_handles[handle] do raise GenAI.Graph.Exception, message: "Node with handle #{handle} already defined in graph", details: {:handle_exists, handle} end put_in(graph, [Access.key(:node_handles), handle], id) else _ -> graph end end @spec attempt_set_head(T.Graph.graph(), T.Graph.graph_node_id(), T.Graph.graph_node(), keyword) :: T.Graph.graph() defp attempt_set_head(graph, id, node, options) defp attempt_set_head(graph, id, _, options) do if setting(graph, :auto_head, options, false) || options[:head] == true do update_in(graph, [Access.key(:head)], &(&1 || id)) else graph end end @spec attempt_set_last_node( T.Graph.graph(), T.Graph.graph_node_id(), T.Graph.graph_node(), keyword ) :: T.Graph.graph() defp attempt_set_last_node(graph, id, node, options) defp attempt_set_last_node(graph, id, _, options) do if setting(graph, :update_last, options, false) do put_in(graph, [Access.key(:last_node)], id) else graph end end @spec auto_link_setting(T.Graph.graph(), keyword) :: any defp auto_link_setting(graph, options) do case options[:link] do true -> graph.settings[:auto_link] || true false -> false default when default in [nil, :default] -> graph.settings[:auto_link] || false {:template, template} -> if x = graph.settings[:auto_link_templates][template] do x else raise GenAI.Graph.Exception, message: "Auto Link Template #{inspect(template)} Not Found", details: {:template_not_found, template} end x -> x end end @spec attempt_auto_link( T.Graph.graph(), T.Graph.graph_node_id(), T.Graph.graph_node_id(), T.Graph.graph_node(), keyword ) :: T.Graph.graph() defp attempt_auto_link(graph, from_node, node_id, node, options) defp attempt_auto_link(graph, from_node, node_id, _, options) do auto_link = auto_link_setting(graph, options) cond do auto_link == false -> graph auto_link == true -> link = Link.new(from_node, node_id) GenAI.VNext.Graph.add_link(graph, link, options) is_struct(auto_link, Link) -> link = auto_link with {:ok, link} <- Link.putnew_source(link, from_node), {:ok, link} <- Link.putnew_target(link, node_id), {:ok, link} <- Link.with_id(link) do GenAI.VNext.Graph.add_link(graph, link, options) else {:error, details} -> raise GenAI.Graph.Exception, message: "Auto Link Failed", details: details end not is_struct(auto_link) and (is_map(auto_link) or is_list(auto_link)) -> auto_link_options = auto_link link = Link.new(from_node, node_id, auto_link_options) GenAI.VNext.Graph.add_link(graph, link, options) end end @spec attempt_set_node(T.Graph.graph(), T.Graph.graph_node_id(), T.Graph.graph_node(), keyword) :: T.Graph.graph() def attempt_set_node(graph, node_id, graph_node, options) def attempt_set_node(graph, node_id, graph_node, _) do if member?(graph, node_id) do raise GenAI.Graph.Exception, message: "Node with #{node_id} already defined in graph", details: {:node_exists, node_id} end put_in(graph, [Access.key(:nodes), node_id], graph_node) end # ------------------------- # attach_node/3 # ------------------------- @doc """ Attach a node to the graph linked to last inserted item. ## Examples iex> graph = GenAI.VNext.Graph.new() ...> node = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> graph = GenAI.VNext.Graph.attach_node(graph, node) ...> GenAI.VNext.Graph.member?(graph, node.id) true """ @spec attach_node(graph :: T.Graph.graph(), node :: T.Graph.graph_node(), options :: map) :: T.result(T.Graph.graph(), T.details()) def attach_node(graph, graph_node, options \\ nil) def attach_node(graph, graph_node, options) do options = Keyword.merge( [auto_head: true, update_last: true, update_last_link: true, link: true], options || [] ) add_node(graph, graph_node, options) end # ------------------------- # add_node/3 # ------------------------- @doc """ Add a node to the graph. ## Examples iex> graph = GenAI.VNext.Graph.new() ...> node = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> graph = GenAI.VNext.Graph.add_node(graph, node) ...> GenAI.VNext.Graph.member?(graph, node.id) true """ @spec add_node(graph :: T.Graph.graph(), node :: T.Graph.graph_node(), options :: map) :: T.result(T.Graph.graph(), T.details()) def add_node(graph, graph_node, options \\ nil) def add_node(graph, graph_node, options) do with {:ok, node_id} <- NodeProtocol.id(graph_node) do graph |> attempt_set_node(node_id, graph_node, options) |> attempt_set_handle(node_id, graph_node) |> attempt_set_head(node_id, graph_node, options) |> attempt_set_last_node(node_id, graph_node, options) |> attempt_auto_link(graph.last_node, node_id, graph_node, options) else x -> x end end @spec local_reference?(T.Graph.graph_link(), T.Graph.graph_link()) :: boolean defp local_reference?(source, target) do if R.Link.connector(source, :external) && R.Link.connector(target, :external) do false else true end end # ------------------------- # add_node/3 # ------------------------- @doc """ Add a link to the graph. ## Examples iex> graph = GenAI.VNext.Graph.new() ...> node1 = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> node2 = GenAI.Graph.Node.new(id: UUID.uuid4()) ...> graph = GenAI.VNext.Graph.add_node(graph, node1) ...> graph = GenAI.VNext.Graph.add_node(graph, node2) ...> link = GenAI.Graph.Link.new(node1.id, node2.id) ...> graph = GenAI.VNext.Graph.add_link(graph, link) ...> GenAI.VNext.Graph.link(graph, link.id) {:ok, link} """ @spec add_link(graph :: T.Graph.graph(), link :: T.Graph.graph_link(), options :: map) :: T.result(T.Graph.graph(), T.details()) def add_link(graph, graph_link, options \\ nil) def add_link(graph, graph_link, options) do with {:ok, link_id} <- Link.id(graph_link), {:ok, source} <- Link.source_connector(graph_link), {:ok, target} <- Link.target_connector(graph_link), true <- local_reference?(source, target) || {:error, {:link, :local_reference_required}} do graph |> attempt_set_link(link_id, graph_link, options) |> attempt_set_last_link(link_id, graph_link, options) |> attempt_register_link(source, graph_link, options) |> attempt_register_link(target, graph_link, options) else {:error, details} -> raise GenAI.Graph.Exception, message: "Link Failure - #{inspect(details)}", details: details end end @spec attempt_set_link(T.Graph.graph(), T.Graph.graph_link_id(), T.Graph.graph_link(), keyword) :: T.Graph.graph() defp attempt_set_link(graph, link_id, graph_link, options) defp attempt_set_link(graph, link_id, graph_link, _) do if Map.has_key?(graph.links, link_id) do raise GenAI.Graph.Exception, message: "Link with #{link_id} already defined in graph", details: {:link_exists, link_id} end with {:ok, handle} <- Link.handle(graph_link) do graph |> put_in([Access.key(:link_handles), handle], link_id) |> put_in([Access.key(:links), link_id], graph_link) else _ -> put_in(graph, [Access.key(:links), link_id], graph_link) end end @spec attempt_set_last_link( T.Graph.graph(), T.Graph.graph_link_id(), T.Graph.graph_link(), keyword ) :: T.Graph.graph() defp attempt_set_last_link(graph, link_id, graph_link, options) defp attempt_set_last_link(graph, link_id, _, options) do if setting(graph, :update_last_link, options, false) do put_in(graph, [Access.key(:last_link)], link_id) else graph end end @spec attempt_register_link( T.Graph.graph(), T.Graph.graph_link(), T.Graph.graph_link(), keyword ) :: T.Graph.graph() defp attempt_register_link(graph, connector, link, options) do connector_node_id = R.Link.connector(connector, :node) cond do R.Link.connector(connector, :external) -> graph member?(graph, connector_node_id) -> n = graph.nodes[connector_node_id] {:ok, n} = NodeProtocol.register_link(n, graph, link, options) put_in(graph, [Access.key(:nodes), connector_node_id], n) :else -> raise GenAI.Graph.Exception, message: "Node Not Found", details: {:source_not_found, connector} end end end defimpl GenAI.Graph.MermaidProtocol, for: GenAI.VNext.Graph do @spec mermaid_id(any) :: any def mermaid_id(subject) do GenAI.Graph.MermaidProtocol.Helpers.mermaid_id(subject.id) end @spec encode(any) :: {:ok, String.t()} | {:error, any} def encode(graph_element), do: encode(graph_element, []) @spec encode(any, any) :: {:ok, String.t()} | {:error, any} def encode(graph_element, options), do: encode(graph_element, options, %{}) @spec encode(any, any, any) :: {:ok, String.t()} | {:error, any} def encode(graph_element, options, state) do case GenAI.Graph.MermaidProtocol.Helpers.diagram_type(options) do :state_diagram_v2 -> state_diagram_v2(graph_element, options, state) x -> {:error, {:unsupported_diagram, x}} end end @spec state_diagram_v2(any, keyword, map) :: {:ok, String.t()} | {:error, any} def state_diagram_v2(graph_element, options, state) do identifier = mermaid_id(graph_element) headline = """ stateDiagram-v2 """ if graph_element.nodes == %{} do body = GenAI.Graph.MermaidProtocol.Helpers.indent(""" [*] --> #{identifier} state "Empty Graph" as #{identifier} """) graph = headline <> body {:ok, graph} else entry_point = if head = graph_element.head do """ [*] --> #{GenAI.Graph.MermaidProtocol.Helpers.mermaid_id(head)} """ else "" end # We need expanded nodes with link details state = update_in(state, [:container], &[graph_element | &1 || []]) contents = graph_element.nodes |> Enum.map(fn {_, n} -> GenAI.Graph.MermaidProtocol.encode(n, options, state) end) |> Enum.map_join("\n", fn {:ok, x} -> x end) body = GenAI.Graph.MermaidProtocol.Helpers.indent(entry_point <> contents) graph = headline <> body {:ok, graph} end end end