From 8e1ad29c1be70338818f4c9b37e5e511a273d09c Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Fri, 10 Nov 2023 23:19:40 -0700 Subject: [PATCH 01/10] created FunctionParam struct - helps define a function's parameters more easily - more tests needed --- lib/function.ex | 41 ++++- lib/function_param.ex | 216 +++++++++++++++++++++++++ test/function_param_test.exs | 306 +++++++++++++++++++++++++++++++++++ test/function_test.exs | 50 ++++++ 4 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 lib/function_param.ex create mode 100644 test/function_param_test.exs diff --git a/lib/function.ex b/lib/function.ex index a552cc97..6d90c915 100644 --- a/lib/function.ex +++ b/lib/function.ex @@ -56,11 +56,20 @@ defmodule LangChain.Function do Context examples may be user_id, account_id, account struct, billing level, etc. + ## Function Parameters + + The `parameters` field is a list of `LangChain.FunctionParam` structs. This is + a convenience for defining the parameters to the function. If it does not work + for more complex use-cases, then use the `parameters_schema` to declare it as + needed. + The `parameters_schema` is an Elixir map that follows a [JSONSchema](https://json-schema.org/learn/getting-started-step-by-step.html) structure. It is used to define the required data structure format for receiving data to the function from the LLM. + NOTE: Only `parameters` or `parameters_schema` can be used. Not both. + """ use Ecto.Schema import Ecto.Changeset @@ -76,13 +85,16 @@ defmodule LangChain.Function do # requiring an explicit step to perform the evaluation. # field :auto_evaluate, :boolean, default: false field :function, :any, virtual: true - # parameters is a map used to express a JSONSchema structure of inputs and what's required + + # parameters_schema is a map used to express a JSONSchema structure of inputs and what's required field :parameters_schema, :map + # parameters is a list of `LangChain.FunctionParam` structs. + field :parameters, {:array, :any}, default: [] end @type t :: %Function{} - @create_fields [:name, :description, :parameters_schema, :function] + @create_fields [:name, :description, :parameters_schema, :parameters, :function] @required_fields [:name] @doc """ @@ -114,6 +126,7 @@ defmodule LangChain.Function do changeset |> validate_required(@required_fields) |> validate_length(:name, max: 64) + |> ensure_single_parameter_option() end @doc """ @@ -125,10 +138,25 @@ defmodule LangChain.Function do Logger.debug("Executing function #{inspect(function.name)}") fun.(arguments, context) end + + defp ensure_single_parameter_option(changeset) do + params_list = get_field(changeset, :parameters) + schema_map = get_field(changeset, :parameters_schema) + + cond do + # can't have both + is_map(schema_map) and !Enum.empty?(params_list) -> + add_error(changeset, :parameters, "Cannot use both parameters and parameters_schema") + + true -> + changeset + end + end end defimpl LangChain.ForOpenAIApi, for: LangChain.Function do alias LangChain.Function + alias LangChain.FunctionParam def for_api(%Function{} = fun) do %{ @@ -138,14 +166,19 @@ defimpl LangChain.ForOpenAIApi, for: LangChain.Function do } end - defp get_parameters(%Function{parameters_schema: nil} = _fun) do + defp get_parameters(%Function{parameters: [], parameters_schema: nil} = _fun) do %{ "type" => "object", "properties" => %{} } end - defp get_parameters(%Function{parameters_schema: schema} = _fun) do + defp get_parameters(%Function{parameters: [], parameters_schema: schema} = _fun) + when is_map(schema) do schema end + + defp get_parameters(%Function{parameters: params} = _fun) do + FunctionParam.to_parameters_schema(params) + end end diff --git a/lib/function_param.ex b/lib/function_param.ex new file mode 100644 index 00000000..f7bb8199 --- /dev/null +++ b/lib/function_param.ex @@ -0,0 +1,216 @@ +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. + + """ + 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) + type = get_field(changeset, :type) + + cond do + type == :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") + + # not an :object field but gave object_properties, error + !Enum.empty?(props) -> + 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: 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 diff --git a/test/function_param_test.exs b/test/function_param_test.exs new file mode 100644 index 00000000..d8f00bce --- /dev/null +++ b/test/function_param_test.exs @@ -0,0 +1,306 @@ +defmodule LangChain.FunctionParamTest do + use ExUnit.Case + + doctest LangChain.FunctionParam + + alias LangChain.FunctionParam + + describe "new!/1" do + test "creates the function parameter" do + %FunctionParam{} = + param = + FunctionParam.new!(%{ + name: "code", + type: :string, + description: "A unique code used to identify the object.", + required: true + }) + + assert param.name == "code" + assert param.type == :string + assert param.description == "A unique code used to identify the object." + assert param.required == true + end + + test "description and required are optional" do + param = FunctionParam.new!(%{name: "code", type: :string}) + + assert param.name == "code" + assert param.type == :string + assert param.description == nil + assert param.required == false + end + + test "supports enum values" do + param = FunctionParam.new!(%{name: "color", type: :string, enum: ["red", "green"]}) + assert param.name == "color" + assert param.type == :string + assert param.enum == ["red", "green"] + end + + test "supports array type" do + param = FunctionParam.new!(%{name: "colors", type: :array}) + assert param.name == "colors" + assert param.type == :array + assert param.item_type == nil + + param = FunctionParam.new!(%{name: "colors", type: :array, item_type: "string"}) + assert param.name == "colors" + assert param.type == :array + assert param.item_type == "string" + end + + test "supports object type" do + person_properties = [ + FunctionParam.new!(%{name: "name", type: :string, required: true}), + FunctionParam.new!(%{name: "age", type: :integer}), + FunctionParam.new!(%{name: "employee", type: :boolean}) + ] + + param = + FunctionParam.new!(%{name: "person", type: :object, object_properties: person_properties}) + + assert param.name == "person" + assert param.type == :object + assert param.object_properties == person_properties + end + + test "supports nested objects type" + + test "does not allow field data for non-matching types" do + {:error, changeset} = + FunctionParam.new(%{name: "thing", type: :string, item_type: "number"}) + + assert {"not allowed for type :string", _} = changeset.errors[:item_type] + + {:error, changeset} = + FunctionParam.new(%{ + name: "thing", + type: :string, + object_properties: [FunctionParam.new!(%{name: "name", type: :string})] + }) + + assert {"not allowed for type :string", _} = changeset.errors[:object_properties] + end + end + + describe "to_json_schema/2" do + test "basic types - integer, string, number, boolean" do + param = FunctionParam.new!(%{name: "name", type: :string}) + expected = %{"name" => %{"type" => "string"}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + param = FunctionParam.new!(%{name: "age", type: :integer}) + expected = %{"age" => %{"type" => "integer"}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + param = FunctionParam.new!(%{name: "height", type: :number}) + expected = %{"height" => %{"type" => "number"}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + # includes description + param = FunctionParam.new!(%{name: "name", type: :string, description: "Applicant's name"}) + expected = %{"name" => %{"type" => "string", "description" => "Applicant's name"}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + param = FunctionParam.new!(%{name: "enabled", type: :boolean}) + expected = %{"enabled" => %{"type" => "boolean"}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + param = + FunctionParam.new!(%{name: "enabled", type: :boolean, description: "If option is enabled"}) + + expected = %{"enabled" => %{"type" => "boolean", "description" => "If option is enabled"}} + assert expected == FunctionParam.to_json_schema(%{}, param) + end + + test "basic types support enum values" do + param = FunctionParam.new!(%{name: "name", type: :string, enum: ["John", "Mary"]}) + expected = %{"name" => %{"type" => "string", "enum" => ["John", "Mary"]}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + param = FunctionParam.new!(%{name: "age", type: :integer, enum: [1, 2, 10]}) + expected = %{"age" => %{"type" => "integer", "enum" => [1, 2, 10]}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + param = FunctionParam.new!(%{name: "height", type: :number, enum: [5.0, 5.5, 6, 6.5]}) + expected = %{"height" => %{"type" => "number", "enum" => [5.0, 5.5, 6, 6.5]}} + assert expected == FunctionParam.to_json_schema(%{}, param) + end + + test "array of basic types" do + # no defined item_type + param = + FunctionParam.new!(%{name: "list_data", type: :array, description: "A list of things"}) + + expected = %{"list_data" => %{"type" => "array", "description" => "A list of things"}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + # with a specified item type + param = FunctionParam.new!(%{name: "tags", type: :array, item_type: "string"}) + expected = %{"tags" => %{"type" => "array", "items" => %{"type" => "string"}}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + # includes description + param = + FunctionParam.new!(%{ + name: "tags", + type: :array, + item_type: "string", + description: "tag values" + }) + + expected = %{ + "tags" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "tag values" + } + } + + assert expected == FunctionParam.to_json_schema(%{}, param) + end + + test "string type with enum values" do + param = FunctionParam.new!(%{name: "color", type: :string, enum: ["red", "green"]}) + expected = %{"color" => %{"type" => "string", "enum" => ["red", "green"]}} + assert expected == FunctionParam.to_json_schema(%{}, param) + + # includes description + param = + FunctionParam.new!(%{ + name: "color", + type: :string, + enum: ["red", "green"], + description: "Allowed colors" + }) + + expected = %{ + "color" => %{ + "type" => "string", + "enum" => ["red", "green"], + "description" => "Allowed colors" + } + } + + assert expected == FunctionParam.to_json_schema(%{}, param) + end + + test "object type" do + param = + FunctionParam.new!(%{ + name: "attributes", + type: :object, + description: "Set of attributes for a new thing", + object_properties: [ + FunctionParam.new!(%{ + name: "name", + type: :string, + description: "The name of the thing" + }), + FunctionParam.new!(%{ + name: "code", + type: :string, + description: "Unique code", + required: true + }) + ] + }) + + expected = + %{ + "attributes" => %{ + "type" => "object", + "description" => "Set of attributes for a new thing", + "properties" => %{ + "name" => %{ + "type" => "string", + "description" => "The name of the thing" + }, + "code" => %{ + "type" => "string", + "description" => "Unique code" + } + }, + "required" => ["code"] + } + } + + assert expected == FunctionParam.to_json_schema(%{}, param) + end + + test "array of objects" + end + + describe "to_parameters_schema/1" do + test "basic example" do + expected = %{ + "type" => "object", + "properties" => %{ + "code" => %{ + "type" => "string", + "description" => "Unique code" + }, + "other" => %{ + "type" => "string" + } + }, + "required" => ["code"] + } + + params = [ + FunctionParam.new!(%{ + name: "code", + type: "string", + description: "Unique code", + required: true + }), + FunctionParam.new!(%{ + name: "other", + type: "string" + }) + ] + + assert expected == FunctionParam.to_parameters_schema(params) + end + + test "generates the full JSONSchema structured map for the list of parameters" + test "supports nested objects" + test "supports listing required parameters" + end + + describe "required_properties/1" do + test "return empty when nothing required" do + params = [ + FunctionParam.new!(%{ + name: "optional_thing", + type: "string" + }) + ] + + assert [] == FunctionParam.required_properties(params) + end + + test "return a list of the property names flagged as required" do + params = [ + FunctionParam.new!(%{ + name: "code", + type: "string", + description: "Unique code", + required: true + }), + FunctionParam.new!(%{ + name: "other", + type: "string" + }), + FunctionParam.new!(%{ + name: "important", + type: "integer", + required: true + }) + ] + + assert ["code", "important"] == FunctionParam.required_properties(params) + end + end +end diff --git a/test/function_test.exs b/test/function_test.exs index d9cfa313..c16a76d1 100644 --- a/test/function_test.exs +++ b/test/function_test.exs @@ -4,6 +4,7 @@ defmodule LangChain.FunctionTest do doctest LangChain.Function alias LangChain.Function + alias LangChain.FunctionParam alias LangChain.ForOpenAIApi defp hello_world(_args, _context) do @@ -71,6 +72,41 @@ defmodule LangChain.FunctionTest do end test "supports parameters" do + params_def = %{ + "type" => "object", + "properties" => %{ + "p1" => %{"type" => "string"}, + "p2" => %{"description" => "Param 2", "type" => "number"}, + "p3" => %{ + "enum" => ["yellow", "red", "green"], + "type" => "string" + } + }, + "required" => ["p1"] + } + + {:ok, fun} = + Function.new(%{ + name: "say_hi", + description: "Provide a friendly greeting.", + parameters: [ + FunctionParam.new!(%{name: "p1", type: :string, required: true}), + FunctionParam.new!(%{name: "p2", type: :number, description: "Param 2"}), + FunctionParam.new!(%{name: "p3", type: :string, enum: ["yellow", "red", "green"]}) + ] + }) + + # result = Function.for_api(fun) + result = ForOpenAIApi.for_api(fun) + + assert result == %{ + "name" => "say_hi", + "description" => "Provide a friendly greeting.", + "parameters" => params_def + } + end + + test "supports parameters_schema" do params_def = %{ "type" => "object", "properties" => %{ @@ -102,6 +138,20 @@ defmodule LangChain.FunctionTest do } end + test "does not allow both parameters and parameters_schema" do + {:error, changeset} = + Function.new(%{ + name: "problem", + parameters: [ + FunctionParam.new!(%{name: "p1", type: :string, required: true}) + ], + parameters_schema: %{stuff: true} + }) + + assert {"Cannot use both parameters and parameters_schema", _} = + changeset.errors[:parameters] + end + test "does not include the function to execute" do # don't try and send an Elixir function ref through to the API {:ok, fun} = Function.new(%{"name" => "hello_world", "function" => &hello_world/2}) From 46b5c6c1ef93d8ef9451c24e5ebaa0fb711244c0 Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Sat, 11 Nov 2023 10:04:59 -0700 Subject: [PATCH 02/10] working on documentation --- lib/function.ex | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/function.ex b/lib/function.ex index 6d90c915..7c2320a0 100644 --- a/lib/function.ex +++ b/lib/function.ex @@ -68,7 +68,37 @@ defmodule LangChain.Function do structure. It is used to define the required data structure format for receiving data to the function from the LLM. - NOTE: Only `parameters` or `parameters_schema` can be used. Not both. + NOTE: Only use `parameters` or `parameters_schema`, not both. + + ## Examples: + + Define a function with no arguments: + + alias LangChain.Function + Function.new!(%{name: "get_current_user_info"}) + + Define a function that takes a simple argument: + + alias LangChain.FunctionParam + Function.new!(%{name: "set_user_name", parameters: [ + FunctionParam.new!(%{name: "user_name", type: :string, required: true}) + ]}) + + Define a function that takes a single complex argument: + + alias LangChain.FunctionParam + Function.new!(%{name: "update_user_data", parameters: [ + FunctionParam.new!(%{name: "data", type: :object, + description: "Known data about the user"}, + object_properties: [ + FunctionParam.new!(%{name: "first_name", type: :string, description: "The user's first name"}), + FunctionParam.new!(%{name: "age", type: :integer}), + FunctionParam.new!(%{name: "favorite_color", type: :string, + enum: ["red", "blue", "yellow", "gray"]}), + FunctionParam.new!(%{name: "has_pet", type: :boolean, + description: "Does the user have a pet?"}), + ]), + ]}) """ use Ecto.Schema From ec1b89caca6d26388ab589eb6a6d22e2e6d71a82 Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Sat, 11 Nov 2023 22:35:46 -0700 Subject: [PATCH 03/10] function docs update --- lib/function.ex | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/function.ex b/lib/function.ex index 7c2320a0..37774a57 100644 --- a/lib/function.ex +++ b/lib/function.ex @@ -84,22 +84,6 @@ defmodule LangChain.Function do FunctionParam.new!(%{name: "user_name", type: :string, required: true}) ]}) - Define a function that takes a single complex argument: - - alias LangChain.FunctionParam - Function.new!(%{name: "update_user_data", parameters: [ - FunctionParam.new!(%{name: "data", type: :object, - description: "Known data about the user"}, - object_properties: [ - FunctionParam.new!(%{name: "first_name", type: :string, description: "The user's first name"}), - FunctionParam.new!(%{name: "age", type: :integer}), - FunctionParam.new!(%{name: "favorite_color", type: :string, - enum: ["red", "blue", "yellow", "gray"]}), - FunctionParam.new!(%{name: "has_pet", type: :boolean, - description: "Does the user have a pet?"}), - ]), - ]}) - """ use Ecto.Schema import Ecto.Changeset From f19d016fa1d56b274ce369650a16bb3a0f9b7bb9 Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Sat, 11 Nov 2023 22:38:47 -0700 Subject: [PATCH 04/10] updated data extraction docs and implementation - use FunctionParams for schema --- lib/chains/data_extraction_chain.ex | 18 +++++- test/chains/data_extraction_chain_test.exs | 66 +++++++++++++++++----- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/lib/chains/data_extraction_chain.ex b/lib/chains/data_extraction_chain.ex index 49aacdbe..f86f3f30 100644 --- a/lib/chains/data_extraction_chain.ex +++ b/lib/chains/data_extraction_chain.ex @@ -7,7 +7,7 @@ defmodule LangChain.Chains.DataExtractionChain do information is returned as an array. Originally based on: - - https://github.com/hwchase17/langchainjs/blob/main/langchain/src/chains/openai_functions/extraction.ts#L42 + - https://github.com/langchain-ai/langchainjs/blob/main/langchain/src/chains/openai_functions/extraction.ts#L43 ## Example @@ -51,6 +51,22 @@ defmodule LangChain.Chains.DataExtractionChain do "person_name" => "Claudia" } ] + + The `schema_parameters` in the previous example can also be expressed using a + list of `LangChain.FunctionParam` structs. An equivalent version looks like + this: + + alias LangChain.FunctionParam + + schema_parameters = [ + FunctionParam.new!(%{name: "person_name", type: :string}), + FunctionParam.new!(%{name: "person_age", type: :number}), + FunctionParam.new!(%{name: "person_hair_color", type: :string}), + FunctionParam.new!(%{name: "dog_name", type: :string}), + FunctionParam.new!(%{name: "dog_breed", type: :string}) + ] + |> FunctionParam.to_parameters_schema() + """ use Ecto.Schema require Logger diff --git a/test/chains/data_extraction_chain_test.exs b/test/chains/data_extraction_chain_test.exs index 02915920..c0323f98 100644 --- a/test/chains/data_extraction_chain_test.exs +++ b/test/chains/data_extraction_chain_test.exs @@ -2,23 +2,63 @@ defmodule LangChain.Chains.DataExtractionChainTest do use LangChain.BaseCase doctest LangChain.Chains.DataExtractionChain + + alias LangChain.Function + alias LangChain.FunctionParam + alias LangChain.Chains.DataExtractionChain alias LangChain.ChatModels.ChatOpenAI + describe "build_extract_function/1" do + test "parameters_schema is set correctly" do + property_config = + [ + FunctionParam.new!(%{name: "person_name", type: :string}), + FunctionParam.new!(%{name: "person_age", type: :number}), + FunctionParam.new!(%{name: "person_hair_color", type: :string}), + FunctionParam.new!(%{name: "dog_name", type: :string}), + FunctionParam.new!(%{name: "dog_breed", type: :string}) + ] + |> FunctionParam.to_parameters_schema() + + %Function{} = function = DataExtractionChain.build_extract_function(property_config) + + # the full combined JSONSchema structure for function arguments + assert function.parameters_schema == %{ + type: "object", + properties: %{ + info: %{ + type: "array", + items: %{ + "type" => "object", + "properties" => %{ + "dog_breed" => %{"type" => "string"}, + "dog_name" => %{"type" => "string"}, + "person_age" => %{"type" => "number"}, + "person_hair_color" => %{"type" => "string"}, + "person_name" => %{"type" => "string"} + }, + "required" => [] + } + } + }, + required: ["info"] + } + end + end + # Extraction - https://js.langchain.com/docs/modules/chains/openai_functions/extraction @tag :live_call test "data extraction chain" do # JSONSchema definition - schema_parameters = %{ - type: "object", - properties: %{ - person_name: %{type: "string"}, - person_age: %{type: "number"}, - person_hair_color: %{type: "string"}, - dog_name: %{type: "string"}, - dog_breed: %{type: "string"} - }, - required: [] - } + schema_parameters = + [ + FunctionParam.new!(%{name: "person_name", type: :string}), + FunctionParam.new!(%{name: "person_age", type: :number}), + FunctionParam.new!(%{name: "person_hair_color", type: :string}), + FunctionParam.new!(%{name: "dog_name", type: :string}), + FunctionParam.new!(%{name: "dog_breed", type: :string}) + ] + |> FunctionParam.to_parameters_schema() # Model setup - specify the model and seed {:ok, chat} = ChatOpenAI.new(%{model: "gpt-4", temperature: 0, seed: 0}) @@ -29,9 +69,7 @@ defmodule LangChain.Chains.DataExtractionChainTest do Claudia is a brunette and Alex is blonde. Alex's dog Frosty is a labrador and likes to play hide and seek." {:ok, result} = - LangChain.Chains.DataExtractionChain.run(chat, schema_parameters, data_prompt, - verbose: true - ) + DataExtractionChain.run(chat, schema_parameters, data_prompt, verbose: true) assert result == [ %{ From 23c14051320f368d46fc91263fc2dbe0b77543ad Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Sun, 12 Nov 2023 22:45:50 -0700 Subject: [PATCH 05/10] code complete? --- lib/function_param.ex | 22 +++++- test/function_param_test.exs | 131 +++++++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/lib/function_param.ex b/lib/function_param.ex index f7bb8199..c192d222 100644 --- a/lib/function_param.ex +++ b/lib/function_param.ex @@ -111,18 +111,28 @@ defmodule LangChain.FunctionParam do 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") - # not an :object field but gave object_properties, error - !Enum.empty?(props) -> + # 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 @@ -183,6 +193,14 @@ defmodule LangChain.FunctionParam do 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}} diff --git a/test/function_param_test.exs b/test/function_param_test.exs index d8f00bce..4485a6b8 100644 --- a/test/function_param_test.exs +++ b/test/function_param_test.exs @@ -65,7 +65,26 @@ defmodule LangChain.FunctionParamTest do assert param.object_properties == person_properties end - test "supports nested objects type" + test "supports nested objects type" do + education = [ + FunctionParam.new!(%{name: "institution_name", type: :string, required: true}), + FunctionParam.new!(%{name: "completed", type: :boolean}) + ] + + person_properties = [ + FunctionParam.new!(%{name: "name", type: :string, required: true}), + FunctionParam.new!(%{name: "education", type: :object, object_properties: education}) + ] + + person = + FunctionParam.new!(%{name: "person", type: :object, object_properties: person_properties}) + + # IO.inspect(person) + + assert person.name == "person" + assert person.type == :object + assert person.object_properties == person_properties + end test "does not allow field data for non-matching types" do {:error, changeset} = @@ -82,6 +101,14 @@ defmodule LangChain.FunctionParamTest do assert {"not allowed for type :string", _} = changeset.errors[:object_properties] end + + test "requires object_properties when array of object" do + {:error, changeset} = + FunctionParam.new(%{name: "thing", type: :array, item_type: "object"}) + + assert {"required when array type of object is used", _} = + changeset.errors[:object_properties] + end end describe "to_json_schema/2" do @@ -229,7 +256,34 @@ defmodule LangChain.FunctionParamTest do assert expected == FunctionParam.to_json_schema(%{}, param) end - test "array of objects" + test "array of objects" do + array = + FunctionParam.new!(%{ + name: "info", + type: :array, + item_type: "object", + object_properties: [ + FunctionParam.new!(%{name: "name", type: :string}), + FunctionParam.new!(%{name: "age", type: :number}) + ] + }) + + schema = FunctionParam.to_json_schema(%{}, array) + + assert schema == %{ + "info" => %{ + "type" => "array", + "items" => %{ + "type" => "object", + "properties" => %{ + "age" => %{"type" => "number"}, + "name" => %{"type" => "string"} + }, + "required" => [] + } + } + } + end end describe "to_parameters_schema/1" do @@ -264,9 +318,76 @@ defmodule LangChain.FunctionParamTest do assert expected == FunctionParam.to_parameters_schema(params) end - test "generates the full JSONSchema structured map for the list of parameters" - test "supports nested objects" - test "supports listing required parameters" + test "supports nested objects" do + education = [ + FunctionParam.new!(%{name: "institution_name", type: :string, required: true}), + FunctionParam.new!(%{name: "completed", type: :boolean}) + ] + + person_properties = [ + FunctionParam.new!(%{name: "name", type: :string, required: true}), + FunctionParam.new!(%{name: "age", type: :integer}), + FunctionParam.new!(%{name: "education", type: :object, object_properties: education}) + ] + + person = + FunctionParam.new!(%{name: "person", type: :object, object_properties: person_properties}) + + schema = FunctionParam.to_parameters_schema([person]) + + expected = + %{ + "type" => "object", + "properties" => %{ + "person" => %{ + "type" => "object", + "properties" => %{ + "age" => %{"type" => "integer"}, + "education" => %{ + "type" => "object", + "properties" => %{ + "completed" => %{"type" => "boolean"}, + "institution_name" => %{"type" => "string"} + }, + "required" => ["institution_name"] + }, + "name" => %{"type" => "string"} + }, + "required" => ["name"] + } + }, + "required" => [] + } + + assert schema == expected + end + + test "supports listing required parameters" do + params = [ + FunctionParam.new!(%{ + name: "code", + type: "string", + required: true + }), + FunctionParam.new!(%{ + name: "other", + type: "string" + }) + ] + + result = FunctionParam.to_parameters_schema(params) + assert result["required"] == ["code"] + + params = [ + FunctionParam.new!(%{ + name: "other", + type: "string" + }) + ] + + result = FunctionParam.to_parameters_schema(params) + assert result["required"] == [] + end end describe "required_properties/1" do From c2c1fa31826f7cce5eaaa08f9fe3cfb121467900 Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Mon, 13 Nov 2023 07:02:37 -0700 Subject: [PATCH 06/10] fixed error from missing function description - submitting a function description of nil results in cryptic error from ChatGPT. - conditionally add the description key --- lib/function.ex | 3 ++- lib/utils.ex | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/function.ex b/lib/function.ex index 37774a57..53bb570a 100644 --- a/lib/function.ex +++ b/lib/function.ex @@ -171,13 +171,14 @@ end defimpl LangChain.ForOpenAIApi, for: LangChain.Function do alias LangChain.Function alias LangChain.FunctionParam + alias LangChain.Utils def for_api(%Function{} = fun) do %{ "name" => fun.name, - "description" => fun.description, "parameters" => get_parameters(fun) } + |> Utils.conditionally_add_to_map("description", fun.description) end defp get_parameters(%Function{parameters: [], parameters_schema: nil} = _fun) do diff --git a/lib/utils.ex b/lib/utils.ex index b7caa3c9..475e38e3 100644 --- a/lib/utils.ex +++ b/lib/utils.ex @@ -8,8 +8,8 @@ defmodule LangChain.Utils do 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(%{atom() => any()}, key :: atom(), value :: nil | list()) :: %{ - atom() => any() + @spec conditionally_add_to_map(%{any() => any()}, key :: any(), value :: nil | list()) :: %{ + any() => any() } def conditionally_add_to_map(map, key, value) From 3fe5c9fdf2151ac4880b12fbf41bb546f2d1956c Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Mon, 13 Nov 2023 07:06:23 -0700 Subject: [PATCH 07/10] updated test to remove nil description --- test/function_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/function_test.exs b/test/function_test.exs index c16a76d1..798e5691 100644 --- a/test/function_test.exs +++ b/test/function_test.exs @@ -65,7 +65,6 @@ defmodule LangChain.FunctionTest do assert result == %{ "name" => "hello_world", - "description" => nil, # NOTE: Sends the required empty parameter definition when none set "parameters" => %{"properties" => %{}, "type" => "object"} } From dffac6aae36b2082529900a1b4c208caf22deeb6 Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Mon, 13 Nov 2023 08:09:08 -0700 Subject: [PATCH 08/10] improved function documentation --- lib/function.ex | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/function.ex b/lib/function.ex index 53bb570a..e17da14e 100644 --- a/lib/function.ex +++ b/lib/function.ex @@ -14,13 +14,19 @@ defmodule LangChain.Function do should describe what the function is used for or what it returns. This information is used by the LLM to decide which function to call and for what purpose. + * ` parameters` - A list of `Function.FunctionParam` structs that are + converted to a JSONSchema format. (Use in place of `parameters_schema`) * ` parameters_schema` - A [JSONSchema structure](https://json-schema.org/learn/getting-started-step-by-step.html) that describes the required data structure format for how arguments are - passed to the function. + passed to the function. (Use if greater control or unsupported features are + needed.) * `function` - An Elixir function to execute when an LLM requests to execute the function. + When passing arguments from an LLM to a function, they go through a single + `map` argument. This allows for multiple keys or named parameters. + ## Example This example defines a function that an LLM can execute for performing basic @@ -70,20 +76,40 @@ defmodule LangChain.Function do NOTE: Only use `parameters` or `parameters_schema`, not both. - ## Examples: + ## Expanded Parameter Examples - Define a function with no arguments: + Function with no arguments: alias LangChain.Function + Function.new!(%{name: "get_current_user_info"}) - Define a function that takes a simple argument: + Function that takes a simple required argument: alias LangChain.FunctionParam + Function.new!(%{name: "set_user_name", parameters: [ FunctionParam.new!(%{name: "user_name", type: :string, required: true}) ]}) + Function that takes an array of strings: + + Function.new!(%{name: "set_tags", parameters: [ + FunctionParam.new!(%{name: "tags", type: :array, item_type: "string"}) + ]}) + + Function that takes two arguments and one is an object/map: + + Function.new!(%{name: "update_preferences", parameters: [ + FunctionParam.new!(%{name: "unique_code", type: :string, required: true}) + FunctionParam.new!(%{name: "data", type: :object, object_properties: [ + FunctionParam.new!(%{name: "auto_complete_email", type: :boolean}), + FunctionParam.new!(%{name: "items_per_page", type: :integer}), + ]}) + ]}) + + The `LangChain.FunctionParam` is nestable allowing for arrays of object and + objects with nested objects. """ use Ecto.Schema import Ecto.Changeset From e79b028f53e471946ead794c3d38d8e5aaf8a4b6 Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Mon, 13 Nov 2023 08:18:34 -0700 Subject: [PATCH 09/10] updated docs on FunctionParam --- lib/function_param.ex | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/function_param.ex b/lib/function_param.ex index c192d222..156db390 100644 --- a/lib/function_param.ex +++ b/lib/function_param.ex @@ -10,6 +10,21 @@ defmodule LangChain.FunctionParam do 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 From 3a81189921589ee82df4b0ae4f604b51dcf23d8c Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Mon, 13 Nov 2023 08:23:27 -0700 Subject: [PATCH 10/10] formatted --- test/chat_models/chat_open_ai_test.exs | 3 ++- test/tools/calculator_test.exs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/test/chat_models/chat_open_ai_test.exs b/test/chat_models/chat_open_ai_test.exs index 80a4dec3..2cefb15d 100644 --- a/test/chat_models/chat_open_ai_test.exs +++ b/test/chat_models/chat_open_ai_test.exs @@ -162,7 +162,8 @@ defmodule LangChain.ChatModels.ChatOpenAITest do @tag :live_call test "handles when request is too large" do - {:ok, chat} = ChatOpenAI.new(%{model: "gpt-3.5-turbo-0301", seed: 0, stream: false, temperature: 1}) + {:ok, chat} = + ChatOpenAI.new(%{model: "gpt-3.5-turbo-0301", seed: 0, stream: false, temperature: 1}) {:error, reason} = ChatOpenAI.call(chat, [too_large_user_request()]) assert reason =~ "maximum context length" diff --git a/test/tools/calculator_test.exs b/test/tools/calculator_test.exs index 8bd5bd9a..398e456a 100644 --- a/test/tools/calculator_test.exs +++ b/test/tools/calculator_test.exs @@ -83,7 +83,9 @@ defmodule LangChain.Tools.CalculatorTest do assert_received {:callback_msg, message} assert message.role == :assistant - assert message.content == "The answer to the math question \"What is 100 + 300 - 200?\" is 200." + + assert message.content == + "The answer to the math question \"What is 100 + 300 - 200?\" is 200." assert updated_chain.last_message == message end