lib/function_param.ex

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