Current section

Files

Jump to
langchain lib utils.ex
Raw

lib/utils.ex

defmodule LangChain.Utils do
@moduledoc """
Collection of helpful utilities mostly for internal use.
"""
alias Ecto.Changeset
require Logger
@doc """
Only add the key to the map if the value is present. When the value is a list,
the key will not be added when the list is empty. If the value is `nil`, it
will not be added.
"""
@spec conditionally_add_to_map(%{any() => any()}, key :: any(), value :: nil | list()) :: %{
any() => any()
}
def conditionally_add_to_map(map, key, value)
def conditionally_add_to_map(map, _key, nil), do: map
def conditionally_add_to_map(map, _key, []), do: map
def conditionally_add_to_map(map, key, value) do
Map.put(map, key, value)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(LangChain.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(LangChain.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Return changeset errors as text with comma separated description.
"""
def changeset_error_to_string(%Ecto.Changeset{valid?: true}), do: nil
def changeset_error_to_string(%Ecto.Changeset{valid?: false} = changeset) do
fields = changeset.errors |> Keyword.keys() |> Enum.uniq()
fields
|> Enum.reduce([], fn f, acc ->
field_errors =
changeset.errors
|> translate_errors(f)
|> Enum.join(", ")
acc ++ ["#{f}: #{field_errors}"]
end)
|> Enum.join("; ")
end
@doc """
Validation helper. Validates a struct changeset that the LLM is a struct.
"""
@spec validate_llm_is_struct(Ecto.Changeset.t()) :: Ecto.Changeset.t()
def validate_llm_is_struct(changeset) do
case Changeset.get_change(changeset, :llm) do
nil -> changeset
llm when is_struct(llm) -> changeset
_other -> Changeset.add_error(changeset, :llm, "LLM must be a struct")
end
end
@type callback_data ::
{:ok, Message.t() | MessageDelta.t() | [Message.t() | MessageDelta.t()]}
| {:error, String.t()}
@doc """
Fire a streaming callback if present.
"""
@spec fire_callback(
%{optional(:stream) => boolean()},
data :: callback_data() | [callback_data()],
(callback_data() -> any())
) :: :ok
def fire_callback(%{stream: true}, _data, nil) do
Logger.warning("Streaming call requested but no callback function was given.")
:ok
end
def fire_callback(_model, _data, nil), do: :ok
def fire_callback(_model, data, callback_fn) when is_function(callback_fn) do
# OPTIONAL: Execute callback function
data
|> List.flatten()
|> Enum.each(fn item -> callback_fn.(item) end)
:ok
end
@doc """
Creates and returns an anonymous function to handle the streaming request.
"""
@spec handle_stream_fn(
%{optional(:stream) => boolean()},
process_response_fn :: function(),
callback_fn :: function()
) :: function()
def handle_stream_fn(model, process_response_fn, callback_fn) do
fn {:data, raw_data}, {req, response} ->
# cleanup data because it isn't structured well for JSON.
# Fetch any previously incomplete messages that are buffered in the
# response struct. and pass that in with the data for decode
buffered = Req.Response.get_private(response, :lang_incomplete, "")
{parsed_data, incomplete} = decode_streamed_data({raw_data, buffered}, process_response_fn)
# execute the callback function for each MessageDelta
fire_callback(model, parsed_data, callback_fn)
old_body = if response.body == "", do: [], else: response.body
# Returns %Req.Response{} where the body contains ALL the stream delta
# chunks converted to MessageDelta structs. The body is a list of lists like this...
#
# body: [
# [
# %LangChain.MessageDelta{
# content: nil,
# index: 0,
# function_name: nil,
# role: :assistant,
# arguments: nil,
# complete: false
# }
# ],
# ...
# ]
#
# The reason for the inner list is for each entry in the "n" choices. By default only 1.
updated_response = %{response | body: old_body ++ parsed_data}
# write any incomplete portion to the response's private data for when
# more data is received.
updated_response = Req.Response.put_private(updated_response, :lang_incomplete, incomplete)
{:cont, {req, updated_response}}
end
end
@doc false
def decode_streamed_data({raw_data, buffer}, process_response_fn) do
# Data comes back like this:
#
# "data: {\"id\":\"chatcmpl-7e8yp1xBhriNXiqqZ0xJkgNrmMuGS\",\"object\":\"chat.completion.chunk\",\"created\":1689801995,\"model\":\"gpt-4-0613\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"function_call\":{\"name\":\"calculator\",\"arguments\":\"\"}},\"finish_reason\":null}]}\n\n
# data: {\"id\":\"chatcmpl-7e8yp1xBhriNXiqqZ0xJkgNrmMuGS\",\"object\":\"chat.completion.chunk\",\"created\":1689801995,\"model\":\"gpt-4-0613\",\"choices\":[{\"index\":0,\"delta\":{\"function_call\":{\"arguments\":\"{\\n\"}},\"finish_reason\":null}]}\n\n"
#
# In that form, the data is not ready to be interpreted as JSON. Let's clean
# it up first.
# as we start, the initial accumulator is an empty set of parsed results and
# any left-over buffer from a previous processing.
raw_data
|> String.split("data: ")
|> Enum.reduce({[], buffer}, fn str, {done, incomplete} = acc ->
# auto filter out "" and "[DONE]" by not including the accumulator
str
|> String.trim()
|> case do
"" ->
acc
"[DONE]" ->
acc
json ->
# combine with any previous incomplete data
starting_json = incomplete <> json
starting_json
|> Jason.decode()
|> case do
{:ok, parsed} ->
{done ++ [process_response_fn.(parsed)], ""}
{:error, _reason} ->
{done, starting_json}
end
end
end)
end
end