defmodule LangChain.FunctionParam do @moduledoc """ Define a function parameter as a struct. Used to generate the expected JSONSchema data for describing one or more arguments being passed to a `LangChain.Function`. Note: This is not intended to be a fully compliant implementation of [JSONSchema types](https://json-schema.org/understanding-json-schema/reference/type). This is intended to be a convenience for working with the most common situations when working with an LLM that understands JSONSchema. Supports: * simple values - string, integer, number, boolean * enum values - `enum: ["alpha", "beta"]`. The values can be strings, integers, etc. * array values - `type: :array` couples with `item_type: "string"` to express it is an array of. * `item_type` is optional. When omitted, it can be a mixed array. * `item_type: "object"` allows for creating an array of objects. Use `object_properties: [...]` to describe the structure of the objects. * objects - Define the object's expected values or supported structure using `object_properties`. The function `to_parameters_schema/1` is used to convert a list of `FunctionParam` structs into a JSONSchema formatted data map. """ use Ecto.Schema import Ecto.Changeset require Logger alias __MODULE__ alias LangChain.LangChainError @primary_key false embedded_schema do field :name, :string field :type, Ecto.Enum, values: [:string, :integer, :number, :boolean, :array, :object] field :item_type, :string field :enum, {:array, :any}, default: [] field :description, :string field :required, :boolean, default: false # list of object properties. Only used for objects field :object_properties, {:array, :any}, default: [] end @type t :: %FunctionParam{} @create_fields [ :name, :type, :item_type, :enum, :description, :required, :object_properties ] @required_fields [:name, :type] @doc """ Build a new FunctionParam struct. """ @spec new(attrs :: map()) :: {:ok, t} | {:error, Ecto.Changeset.t()} def new(attrs \\ %{}) do %FunctionParam{} |> cast(attrs, @create_fields) # |> Ecto.Changeset.put_embed(:object_properties, value: ) |> common_validation() |> apply_action(:insert) end @doc """ Build a new `FunctionParam` struct 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, param} -> param {:error, changeset} -> raise LangChainError, changeset end end defp common_validation(changeset) do changeset |> validate_required(@required_fields) |> validate_enum() |> validate_array_type() |> validate_object_type() end defp validate_enum(changeset) do values = get_field(changeset, :enum, []) type = get_field(changeset, :type) cond do type in [:string, :integer, :number] and !Enum.empty?(values) -> changeset # not an :enum field but gave enum, error !Enum.empty?(values) -> add_error(changeset, :enum, "not allowed for type #{inspect(type)}") # no enum given true -> changeset end end defp validate_array_type(changeset) do item = get_field(changeset, :item_type) type = get_field(changeset, :type) cond do # can only use item_type field when an array type != :array and item != nil -> add_error(changeset, :item_type, "not allowed for type #{inspect(type)}") # okay true -> changeset end end defp validate_object_type(changeset) do props = get_field(changeset, :object_properties) item = get_field(changeset, :item_type) type = get_field(changeset, :type) cond do # allowed case for object_properties type == :object and !Enum.empty?(props) -> changeset # allowed case for object_properties type == :array and item == "object" and !Enum.empty?(props) -> changeset # object type but missing the properties. Add error type == :object -> add_error(changeset, :object_properties, "is required for object type") # when an array of objects, object_properties is required type == :array and item == "object" and Enum.empty?(props) -> add_error(changeset, :object_properties, "required when array type of object is used") # has object_properties but not one of the allowed cases !Enum.empty?(props) and (!(type == :array and item == "object") and !(type == :object)) -> add_error(changeset, :object_properties, "not allowed for type #{inspect(type)}") # not an object and didn't give object_properties true -> changeset end end @doc """ Return the list of required property names. """ @spec required_properties(params :: [t()]) :: [String.t()] def required_properties(params) when is_list(params) do params |> Enum.reduce([], fn p, acc -> if p.required do [p.name | acc] else acc end end) |> Enum.reverse() end @doc """ Transform a list of `FunctionParam` structs into a map expressing the structure in a JSONSchema compatible way. """ @spec to_parameters_schema([t()]) :: %{String.t() => any()} def to_parameters_schema(params) when is_list(params) do %{ "type" => "object", "properties" => Enum.reduce(params, %{}, &to_json_schema(&2, &1)), "required" => required_properties(params) } end @doc """ Transform a `FunctionParam` to a JSONSchema compatible definition that is added to the passed in `data` map. """ @spec to_json_schema(data :: map(), t()) :: map() def to_json_schema(%{} = data, %FunctionParam{type: type} = param) when type in [:string, :integer, :number, :boolean] do settings = %{"type" => to_string(type)} |> include_enum_value(param) |> description_for_schema(param.description) Map.put(data, param.name, settings) end def to_json_schema(%{} = data, %FunctionParam{type: :array, item_type: nil} = param) do settings = %{"type" => "array"} |> description_for_schema(param.description) Map.put(data, param.name, settings) end def to_json_schema(%{} = data, %FunctionParam{type: :array, item_type: "object"} = param) do settings = %{"type" => "array", "items" => to_parameters_schema(param.object_properties)} |> description_for_schema(param.description) Map.put(data, param.name, settings) end def to_json_schema(%{} = data, %FunctionParam{type: :array, item_type: item_type} = param) do settings = %{"type" => "array", "items" => %{"type" => item_type}} |> description_for_schema(param.description) Map.put(data, param.name, settings) end def to_json_schema(%{} = data, %FunctionParam{type: :object, object_properties: props} = param) do settings = props |> to_parameters_schema() |> description_for_schema(param.description) Map.put(data, param.name, settings) end # conditionally add the description field if set defp description_for_schema(data, nil), do: data defp description_for_schema(data, description) when is_binary(description) do Map.put(data, "description", description) end defp include_enum_value(data, %FunctionParam{type: type, enum: values} = _param) when type in [:string, :integer, :number] and values != [] do Map.put(data, "enum", values) end defp include_enum_value(data, %FunctionParam{} = _param), do: data end