defmodule LangChain.Message do
@moduledoc """
Models a complete `Message` for a chat LLM.
## Roles
- `:system` - a system message. Typically just one and it occurs first as a
primer for how the LLM should behave.
- `:user` - The user or application responses. Typically represents the
"human" element of the exchange.
- `:assistant` - Responses coming back from the LLM.
- `:arguments` - The `arguments` can be set as a map where each key is an
"argument". If set as a String, it is expected to be a JSON formatted string
and will be parsed to a map. If there is an error parsing the arguments to
JSON, it is considered an error.
An empty map `%{}` means no arguments are passed.
- `:function` - A message for returning the result of executing a
`function_call`.
## Functions
A `function_call` comes from the `:assistant` role. The `function_name`
identifies the named function to execute.
Create a message of role `:function` to provide the function response.
"""
use Ecto.Schema
import Ecto.Changeset
require Logger
alias __MODULE__
alias LangChain.LangChainError
@primary_key false
embedded_schema do
field :content, :string
field :index, :integer
field :status, Ecto.Enum, values: [:complete, :cancelled, :length], default: :complete
field :role, Ecto.Enum,
values: [:system, :user, :assistant, :function],
default: :user
field :function_name, :string
field :arguments, :any, virtual: true
end
@type t :: %Message{}
@type status :: :complete | :cancelled | :length
@create_fields [:role, :content, :status, :function_name, :arguments, :index]
@required_fields [:role]
@doc """
Build a new message and return an `:ok`/`:error` tuple with the result.
"""
@spec new(attrs :: map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def new(attrs \\ %{}) do
%Message{}
|> cast(attrs, @create_fields)
|> parse_arguments()
|> common_validations()
|> apply_action(:insert)
end
@doc """
Build a new message and return it or raise an error if invalid.
"""
@spec new!(attrs :: map()) :: t() | no_return()
def new!(attrs \\ %{}) do
case new(attrs) do
{:ok, message} ->
message
{:error, changeset} ->
raise LangChainError, changeset
end
end
defp changeset_is_function?(changeset) do
get_field(changeset, :role) == :assistant and
is_binary(get_field(changeset, :function_name)) and
get_field(changeset, :status) == :complete
end
defp parse_arguments(changeset) do
args = get_field(changeset, :arguments)
is_function = changeset_is_function?(changeset)
# only
cond do
is_function && is_binary(args) ->
# decode the arguments
case Jason.decode(args) do
{:ok, parsed} when is_map(parsed) ->
put_change(changeset, :arguments, parsed)
{:ok, parsed} ->
Logger.warning(
"Parsed unexpected function argument format. Expected a map but received: #{inspect(parsed)}"
)
add_error(changeset, :arguments, "unexpected JSON arguments format")
{:error, error} ->
Logger.warning("Received invalid argument JSON data. Error: #{inspect(error)}")
add_error(changeset, :arguments, "invalid JSON function arguments")
end
true ->
changeset
end
end
defp common_validations(changeset) do
changeset
|> validate_required(@required_fields)
|> validate_content()
|> validate_function_name_for_role()
|> validate_function_name_when_args()
end
# validate that a "user" and "system" message has content. Allow an
# "assistant" message to be created where we don't have content yet because it
# can be streamed in through deltas from an LLM and not yet receive the
# content.
defp validate_content(changeset) do
case fetch_field!(changeset, :role) do
role when role in [:system, :user] ->
validate_required(changeset, [:content])
_other ->
changeset
end
end
# validate that "function_name" can only be set if the role is "function_call"
# for requesting execution or "function" for the returning a function result.
#
# The function_name is required for those message types.
defp validate_function_name_for_role(changeset) do
case fetch_field!(changeset, :role) do
role when role in [:function] ->
validate_required(changeset, [:function_name])
role when role in [:system, :user] ->
if get_field(changeset, :function_name) == nil do
changeset
else
add_error(changeset, :function_name, "can't be set with role #{inspect(role)}")
end
_other ->
changeset
end
end
# validate that "function_name" is required if arguments are set.
defp validate_function_name_when_args(changeset) do
function_name = get_field(changeset, :function_name)
if get_field(changeset, :arguments) != nil && is_nil(function_name) do
add_error(changeset, :function_name, "is required when arguments are given")
else
changeset
end
end
@doc """
Create a new system message which can prime the AI/Assistant for how to
respond.
"""
@spec new_system(content :: String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def new_system(content \\ "You are a helpful assistant.") do
new(%{role: :system, content: content, status: :complete})
end
@doc """
Create a new system message which can prime the AI/Assistant for how to
respond.
"""
@spec new_system!(content :: String.t()) :: t() | no_return()
def new_system!(content \\ "You are a helpful assistant.") do
case new_system(content) do
{:ok, msg} ->
msg
{:error, %Ecto.Changeset{} = changeset} ->
raise LangChainError, changeset
end
end
@doc """
Create a new user message which represents a human message or a message from
the application.
"""
@spec new_user(content :: String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def new_user(content) do
new(%{role: :user, content: content, status: :complete})
end
@doc """
Create a new user message which represents a human message or a message from
the application.
"""
@spec new_user!(content :: String.t()) :: t() | no_return()
def new_user!(content) do
case new_user(content) do
{:ok, msg} ->
msg
{:error, %Ecto.Changeset{} = changeset} ->
raise LangChainError, changeset
end
end
@doc """
Create a new assistant message which represents a response from the AI or LLM.
"""
@spec new_assistant(content :: String.t(), status()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def new_assistant(content, status \\ :complete) do
new(%{role: :assistant, content: content, status: status})
end
@doc """
Create a new assistant message which represents a response from the AI or LLM.
"""
@spec new_assistant!(content :: String.t(), status()) :: t() | no_return()
def new_assistant!(content, status \\ :complete) do
case new_assistant(content, status) do
{:ok, msg} ->
msg
{:error, %Ecto.Changeset{} = changeset} ->
raise LangChainError, changeset
end
end
@doc """
Create a new function_call message to represent the request for a function to
be executed.
"""
@spec new_function_call(name :: String.t(), raw_args :: String.t()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def new_function_call(name, raw_args) do
case Jason.decode(raw_args) do
{:ok, parsed} ->
new(%{role: :assistant, function_name: name, arguments: parsed, status: :complete})
{:error, %Jason.DecodeError{data: reason}} ->
{:error,
%Message{role: :assistant, function_name: name}
|> change()
|> add_error(:arguments, "Failed to parse arguments: #{inspect(reason)}")}
end
end
@doc """
Create a new function_call message to represent the request for a function to
be executed.
"""
@spec new_function_call!(name :: String.t(), raw_args :: String.t()) :: t() | no_return()
def new_function_call!(name, raw_args) do
case new_function_call(name, raw_args) do
{:ok, msg} ->
msg
{:error, %Ecto.Changeset{} = changeset} ->
raise LangChainError, changeset
end
end
@doc """
Create a new function message to represent the result of an executed
function.
"""
@spec new_function(name :: String.t(), result :: any()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def new_function(name, result) do
new(%{role: :function, function_name: name, content: result})
end
@doc """
Create a new function message to represent the result of an executed
function.
"""
@spec new_function!(name :: String.t(), result :: any()) :: t() | no_return()
def new_function!(name, result) do
case new_function(name, result) do
{:ok, msg} ->
msg
{:error, %Ecto.Changeset{} = changeset} ->
raise LangChainError, changeset
end
end
@doc """
Return if a Message is a function_call.
"""
def is_function_call?(%Message{role: :assistant, status: :complete, function_name: fun_name})
when is_binary(fun_name),
do: true
def is_function_call?(%Message{}), do: false
end
defimpl LangChain.ForOpenAIApi, for: LangChain.Message do
alias LangChain.Message
def for_api(%Message{role: :assistant, function_name: fun_name} = fun) when is_binary(fun_name) do
%{
"role" => :assistant,
"function_call" => %{
"arguments" => Jason.encode!(fun.arguments),
"name" => fun.function_name
},
"content" => fun.content
}
end
def for_api(%Message{role: :function} = fun) do
%{
"role" => :function,
"name" => fun.function_name,
"content" => fun.content
}
end
def for_api(%Message{} = fun) do
%{
"role" => fun.role,
"content" => fun.content
}
end
end