Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Flint schemas and validations #76

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Used by "mix format"
[
import_deps: [:ecto, :phoenix, :phoenix_live_view],
import_deps: [:ecto, :flint, :phoenix, :phoenix_live_view],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "pages/cookbook/**/*.{ex,exs}"]
inputs: [
"{mix,.formatter}.exs",
"{config,lib,test}/**/*.{ex,exs}",
"pages/cookbook/**/*.{ex,exs}"
]
]
6 changes: 3 additions & 3 deletions lib/instructor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -589,12 +589,12 @@ defmodule Instructor do
not is_ecto_schema(response_model) ->
changeset

function_exported?(response_model, :validate_changeset, 1) ->
response_model.validate_changeset(changeset)

function_exported?(response_model, :validate_changeset, 2) ->
response_model.validate_changeset(changeset, context)

function_exported?(response_model, :validate_changeset, 1) ->
response_model.validate_changeset(changeset)

true ->
changeset
end
Expand Down
17 changes: 8 additions & 9 deletions lib/instructor/adapters/anthropic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ defmodule Instructor.Adapters.Anthropic do
Anthropic adapter for Instructor.
"""
@behaviour Instructor.Adapter
@default_config [
api_url: "https://api.anthropic.com/",
http_options: [receive_timeout: 60_000]
]

alias Instructor.SSEStreamParser

Expand Down Expand Up @@ -131,14 +135,9 @@ defmodule Instructor.Adapters.Anthropic do
defp api_key(config), do: Keyword.fetch!(config, :api_key)
defp http_options(config), do: Keyword.fetch!(config, :http_options)

defp config(nil), do: config(Application.get_env(:instructor, :anthropic, []))

defp config(base_config) do
default_config = [
api_url: "https://api.anthropic.com/",
http_options: [receive_timeout: 60_000]
]

Keyword.merge(default_config, base_config)
defp config(base_config \\ nil) do
@default_config
|> Keyword.merge(Application.get_env(:anthropic, :openai, []))
|> Keyword.merge(base_config)
end
end
22 changes: 11 additions & 11 deletions lib/instructor/adapters/openai.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ defmodule Instructor.Adapters.OpenAI do
alias Instructor.JSONSchema
alias Instructor.SSEStreamParser

@default_config [
api_url: "https://api.openai.com",
api_path: "/v1/chat/completions",
auth_mode: :bearer,
http_options: [receive_timeout: 60_000]
]

@impl true
def chat_completion(params, user_config \\ nil) do
config = config(user_config)
Expand Down Expand Up @@ -213,16 +220,9 @@ defmodule Instructor.Adapters.OpenAI do

defp http_options(config), do: Keyword.fetch!(config, :http_options)

defp config(nil), do: config(Application.get_env(:instructor, :openai, []))

defp config(base_config) do
default_config = [
api_url: "https://api.openai.com",
api_path: "/v1/chat/completions",
auth_mode: :bearer,
http_options: [receive_timeout: 60_000]
]

Keyword.merge(default_config, base_config)
defp config(base_config \\ nil) do
@default_config
|> Keyword.merge(Application.get_env(:instructor, :openai, []))
|> Keyword.merge(base_config)
end
end
169 changes: 168 additions & 1 deletion lib/instructor/ecto_type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Instructor.EctoType do
that works natively with Instructor.

## Example

```elixir
defmodule MyCustomType do
use Ecto.Type
Expand All @@ -22,10 +22,177 @@ defmodule Instructor.EctoType do
```
"""
@callback to_json_schema() :: map()
@callback to_json_schema(tuple()) :: map()

@optional_callbacks to_json_schema: 0, to_json_schema: 1

defguard is_ecto_schema(mod) when is_atom(mod)
defguard is_ecto_types(types) when is_map(types)

def title_for(ecto_schema) when is_ecto_schema(ecto_schema) do
to_string(ecto_schema) |> String.trim_leading("Elixir.")
end

def for_type(:any), do: %{}
def for_type(:id), do: %{type: "integer", description: "Integer, e.g. 1"}
def for_type(:binary_id), do: %{type: "string"}
def for_type(:integer), do: %{type: "integer", description: "Integer, e.g. 1"}
def for_type(:float), do: %{type: "number", description: "Float, e.g. 1.27", format: "float"}
def for_type(:boolean), do: %{type: "boolean", description: "Boolean, e.g. true"}
def for_type(:string), do: %{type: "string", description: "String, e.g. 'hello'"}
# def for_type(:binary), do: %{type: "unsupported"}
def for_type({:array, type}), do: %{type: "array", items: for_type(type)}

def for_type(:map),
do: %{
type: "object",
properties: %{},
additionalProperties: false,
description: "An object with arbitrary keys and values, e.g. { key: value }"
}

def for_type({:map, type}),
do: %{
type: "object",
properties: %{},
additionalProperties: for_type(type),
description: "An object with values of a type #{inspect(type)}, e.g. { key: value }"
}

def for_type(:decimal), do: %{type: "number", format: "float"}

def for_type(:date),
do: %{type: "string", description: "ISO8601 Date, e.g. \"2024-07-20\"", format: "date"}

def for_type(:time),
do: %{
type: "string",
description: "ISO8601 Time, e.g. \"12:00:00\"",
pattern: "^[0-9]{2}:?[0-9]{2}:?[0-9]{2}$"
}

def for_type(:time_usec),
do: %{
type: "string",
description: "ISO8601 Time with microseconds, e.g. \"12:00:00.000000\"",
pattern: "^[0-9]{2}:?[0-9]{2}:?[0-9]{2}.[0-9]{6}$"
}

def for_type(:naive_datetime),
do: %{
type: "string",
description: "ISO8601 DateTime, e.g. \"2024-07-20T12:00:00\"",
format: "date-time"
}

def for_type(:naive_datetime_usec),
do: %{
type: "string",
description: "ISO8601 DateTime with microseconds, e.g. \"2024-07-20T12:00:00.000000\"",
format: "date-time"
}

def for_type(:utc_datetime),
do: %{
type: "string",
description: "ISO8601 DateTime, e.g. \"2024-07-20T12:00:00Z\"",
format: "date-time"
}

def for_type(:utc_datetime_usec),
do: %{
type: "string",
description: "ISO8601 DateTime with microseconds, e.g. \"2024-07-20T12:00:00.000000Z\"",
format: "date-time"
}

def for_type(
{:parameterized, {Ecto.Embedded, %Ecto.Embedded{cardinality: :many, related: related}}}
)
when is_ecto_schema(related) do
title = title_for(related)

%{
items: %{"$ref": "#/$defs/#{title}"},
title: title,
type: "array"
}
end

def for_type(
{:parameterized, {Ecto.Embedded, %Ecto.Embedded{cardinality: :many, related: related}}}
)
when is_ecto_types(related) do
properties =
for {field, type} <- related, into: %{} do
{field, for_type(type)}
end

required = Map.keys(properties) |> Enum.sort()

%{
items: %{
type: "object",
required: required,
properties: properties
},
type: "array"
}
end

def for_type(
{:parameterized, {Ecto.Embedded, %Ecto.Embedded{cardinality: :one, related: related}}}
)
when is_ecto_schema(related) do
%{"$ref": "#/$defs/#{title_for(related)}"}
end

def for_type(
{:parameterized, {Ecto.Embedded, %Ecto.Embedded{cardinality: :one, related: related}}}
)
when is_ecto_types(related) do
properties =
for {field, type} <- related, into: %{} do
{field, for_type(type)}
end

required = Map.keys(properties) |> Enum.sort()

%{
type: "object",
required: required,
properties: properties,
additionalProperties: false
}
end

def for_type({:parameterized, {Ecto.Enum, %{mappings: mappings}}}) do
%{
type: "string",
enum: Keyword.keys(mappings)
}
end

def for_type({:parameterized, {mod, params}}) do
if function_exported?(mod, :to_json_schema, 1) do
mod.to_json_schema(params)
else
raise "Unsupported type: #{inspect(mod)}, please implement `to_json_schema/1` via `use Instructor.EctoType`"
end
end

def for_type(mod) do
if function_exported?(mod, :to_json_schema, 0) do
mod.to_json_schema()
else
raise "Unsupported type: #{inspect(mod)}, please implement `to_json_schema/0` via `use Instructor.EctoType`"
end
end

def __using__(_) do
quote do
@behaviour Instructor.EctoType
import Instructor.EctoType
end
end
end
2 changes: 2 additions & 0 deletions lib/instructor/gbnf.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ defmodule Instructor.GBNF do
ws01 ::= ([ \\t\\n])?
"""

import Instructor.EctoType

@doc """
Convert a JSONSchema to a GBNF grammar to be used with llama.cpp

Expand Down
108 changes: 108 additions & 0 deletions lib/instructor/instruction.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
if Code.ensure_loaded?(Flint.Schema) do
defmodule Instructor.Union do
use Flint.Type, extends: Flint.Types.Union
@behaviour Instructor.EctoType

@impl true
def to_json_schema(%{types: types}) when is_list(types) do
%{
"oneOf" => Enum.map(types, &Instructor.EctoType.for_type/1)
}
end
end

defmodule Instructor.Instruction do
use Flint.Extension

attribute(:stream, default: false, validator: &is_boolean/1)
attribute(:validation_context, default: %{}, validator: &is_map/1)
attribute(:mode, default: :tools, validator: &Kernel.in(&1, [:tools, :json, :md_json]))
attribute(:max_retries, default: 0, validator: &is_integer/1)
attribute(:system_prompt, validator: &is_binary/1)
attribute(:model, validator: &is_binary/1)
attribute(:array, default: false, validator: &is_boolean/1)
attribute(:template)

option(:doc, default: "", validator: &is_binary/1, required: false)

defmacro __using__(_opts) do
quote do
use Instructor.Validator
alias Instructor.Union

def render_template(assigns) do
EEx.eval_string(__MODULE__.__schema__(:template), assigns: assigns)
end

def chat_completion(messages, opts \\ []) do
{stream, opts} = Keyword.pop(opts, :stream, __MODULE__.__schema__(:stream))

{validation_context, opts} =
Keyword.pop(opts, :validation_context, __MODULE__.__schema__(:validation_context))

{mode, opts} = Keyword.pop(opts, :mode, __MODULE__.__schema__(:mode))

{max_retries, opts} =
Keyword.pop(opts, :max_retries, __MODULE__.__schema__(:max_retries))

{model, opts} = Keyword.pop(opts, :model, __MODULE__.__schema__(:model))

{config, opts} = Keyword.split(opts, [:api_key, :api_url, :http_options])

settings =
[
stream: stream,
validation_context: validation_context,
mode: mode,
max_retries: max_retries,
model: model
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)

messages = if Keyword.keyword?(messages), do: [messages], else: messages

messages =
for message <- messages do
case message do
%{role: _role, content: _content} ->
message

_ ->
%{
role: "user",
content:
if(__MODULE__.__schema__(:template),
do: render_template(message),
else: message
)
}
end
end

messages =
if __MODULE__.__schema__(:system_prompt) do
[%{role: "system", content: __MODULE__.__schema__(:system_prompt)} | messages]
else
messages
end

response_model =
if __MODULE__.__schema__(:array), do: {:array, __MODULE__}, else: __MODULE__

opts = [messages: messages, response_model: response_model] ++ settings ++ opts

Instructor.chat_completion(opts, config)
end

@impl true
def validate_changeset(changeset, context \\ %{}) do
__MODULE__
|> struct!()
|> changeset(changeset, Enum.into(context, []))
end

defoverridable validate_changeset: 1, validate_changeset: 2
end
end
end
end
Loading