Packages

Handwave your problems away with LLMs.

Current section

Files

Jump to
handwave lib handwave.ex
Raw

lib/handwave.ex

defmodule Handwave do
@handwave_options [
log_level: [
type: {:in, Logger.levels()},
default: :debug,
doc: "Which Log level to use for debug logs."
],
default: [
type: :any,
default: nil,
doc:
"Determines which branch will be executed if LLM call failed. This option only affects non-raising `llm_if`, `llm_rewrite`, and `llm_route`"
]
]
@handwave_options_schema NimbleOptions.new!(@handwave_options)
@known_handwave_options Keyword.keys(@handwave_options_schema.schema)
@moduledoc """
Defines behaviour that provider-specific modules like `Handwave.OpenAI` implement
## Usage
Pick your poison and import it:
import Handwave.OpenAI
Enjoy state-of-the-art AI engineering:
llm_if user_email, "looks legit?" do
create_user(user_email)
else
{:error, :bad_vibes}
end
Need to rewrite some input? Use `llm_rewrite/3`:
llm_rewrite("angel_of_death_420", "make username family-friendly")
# => "angel_of_light_42"
Need to route/classify input into one of several options? Use `llm_route/4`:
llm_route(job_context, [:cancel, :snooze, :discard], "what should the job do now?")
# => :cancel
By default, Handwave emits debug logs to provide some visibility into LLM
decisions. You can customize level of debug logs using `log_level` option:
iex> llm_if Req.get!("https://www.githubstatus.com/api/v2/summary.json"), "is github down?", log_level: :info do
{:snooze, 300}
else
:ok
end
[info] LLM_IF resolved to false: The response status is 200 and all GitHub components have a status of 'operational'. There are no incidents or scheduled maintenances reported. The overall status description is 'All Systems Operational', indicating GitHub is not down.
Prompt: is github down?
:ok
Here are all options that `Handwave` accepts:
#{NimbleOptions.docs(@handwave_options_schema)}
## Creating Provider
To create your own provider, just `use Handwave` in a module and implement `build_params/4` and `build_options/2` callbacks:
defmodule MyApp.Grok do
use Handwave
@impl Handwave
def build_params(operation, term, prompt, opts) do
%{
model: "grok-3-latest",
messages: [
%{role: :system, content: Handwave.Prompt.system(operation)},
%{role: :user, content: Handwave.Prompt.user(operation, term, prompt)}
]
}
end
@impl Handwave
def build_options(pre_options, _opts) do
pre_options
|> Keyword.put(:adapter, InstructorLite.Adapters.ChatCompletionsCompatible)
|> Keyword.put(:adapter_context,
url: "https://api.x.ai/v1/chat/completions",
api_key: Application.get_env(:handwave, :grok_key)
)
end
end
Then `import` or `require` your provider module in your code:
require MyApp.Grok
iex> MyApp.Grok.llm_if "0.(9) == 1", "Grok is it true?", do: true, else: false
[debug] LLM_IF resolved to false: The input '0.(9) == 1' represents the comparison between the repeating decimal 0.999... and 1. While 0.999... is mathematically equal to 1, the notation '0.(9)' explicitly denotes a repeating decimal. In a strict syntactical evaluation, without additional context or instructions to interpret it as a mathematical equality, this expression is treated as a string or symbolic comparison. Therefore, '0.(9)' is not identically the same as '1' in this context, leading to the result being false.
Prompt: Grok is it true?
false
"""
require Logger
@typedoc """
Options passed to Handwave functions.
#{NimbleOptions.docs(@handwave_options_schema)}
Additional options may be passed through to adapters.
"""
@type opts :: keyword()
@type operation :: :llm_if | :llm_rewrite | :llm_route
@doc """
Callback to build parameters specific for LLM Provider endpoint.
"""
@callback build_params(operation(), term :: term(), prompt :: String.t(), opts :: opts()) ::
InstructorLite.Adapter.params()
@doc """
Callback to build InstructorLite options for the request.
Receives `pre_options` (a keyword list containing `:response_model`) and `opts` (user options).
Should return the final options to pass to `InstructorLite.instruct/2`.
"""
@callback build_options(pre_options :: keyword(), opts :: opts()) :: InstructorLite.opts()
@doc """
LLM-powered `if` statement
It works like a regular `if` macro
import Handwave
llm_if user_input, "has bad vibes" do
# block
else
# continue
end
Unlike regular `if`, this `llm_if` has a side-effect under the hood an can fail.
In this case, an error will be logged and default branch will be executed:
iex> llm_if(%{}, "is it a map?", model: "gpt-42-micro", do: "yes", else: "no")
[error] LLM_IF call failed
"no"
This is configurable using `default` option:
iex> llm_if(%{}, "is it a map?", model: "gpt-42-micro", default: true, do: "yes", else: "no")
[error] LLM_IF call failed
"yes"
Shorthand syntax also supported:
llm_if user_input, "has bad vibes", do: raise "Crash!"
"""
@macrocallback llm_if(
condition :: any(),
prompt :: String.t(),
opts :: Keyword.t(),
clauses :: any()
) :: Macro.t()
@doc """
Raising version of `llm_if`
## Examples
iex(4)> llm_if!(%{}, "is it a map?", model: "gpt-42-micro", do: "yes", else: "no")
** (Handwave.Error) LLM_IF call failed
{:error,
%Req.Response{
status: 400,
body: %{
"error" => %{
"code" => "model_not_found",
"message" => "The requested model 'gpt-42-micro' does not exist.",
"param" => "model",
"type" => "invalid_request_error"
}
},
trailers: %{},
private: %{}
}}
(handwave 0.1.0) lib/handwave.ex:179: Handwave.evaluate/5
iex:4: (file)
"""
@macrocallback llm_if!(
condition :: any(),
prompt :: String.t(),
opts :: Keyword.t(),
clauses :: any()
) :: Macro.t()
@doc """
LLM-powered term rewriting.
Takes an input term and rewrites it according to the given prompt instructions.
## Examples
iex> llm_rewrite("hello world", "capitalize each word")
"Hello World"
iex> llm_rewrite("angel_of_death_420", "Make sure user name is appropriate for our family-friendly website")
"angel_of_light_42"
On error, returns the `default` option value (or `nil` if not specified):
iex> llm_rewrite("hello", "do something", model: "gpt-42-micro")
[error] LLM_REWRITE call failed
nil
Use `llm_rewrite!/3` for a raising version.
"""
@callback llm_rewrite(input :: term(), prompt :: String.t(), opts :: Keyword.t()) :: term()
@doc """
Raising version of `llm_rewrite/3`.
Same as `llm_rewrite/3` but raises `Handwave.Error` on failure instead of returning the default.
## Examples
iex> llm_rewrite!("hello", "uppercase it", model: "gpt-42-micro")
** (Handwave.Error) LLM_REWRITE call failed
"""
@callback llm_rewrite!(input :: term(), prompt :: String.t(), opts :: Keyword.t()) :: term()
@doc """
LLM-powered routing/classification.
Takes an input term and routes it to one of the given options based on the prompt.
The options should be a list of atoms representing possible choices.
## Examples
iex> llm_route(%{action: "user wants to stop"}, [:cancel, :snooze, :discard], "what should the job do now?")
:cancel
iex> llm_route("remind me later", [:now, :later, :never], "when does the user want this?")
:later
On error, returns the `default` option value (or `nil` if not specified):
iex> llm_route("hello", [:a, :b], "pick one", model: "gpt-42-micro")
[error] LLM_ROUTE call failed
nil
Use `llm_route!/4` for a raising version.
"""
@callback llm_route(
input :: term(),
options :: [atom()],
prompt :: String.t(),
opts :: Keyword.t()
) ::
atom() | nil
@doc """
Raising version of `llm_route/4`.
Same as `llm_route/4` but raises `Handwave.Error` on failure instead of returning the default.
## Examples
iex> llm_route!("hello", [:a, :b], "pick one", model: "gpt-42-micro")
** (Handwave.Error) LLM_ROUTE call failed
"""
@callback llm_route!(
input :: term(),
options :: [atom()],
prompt :: String.t(),
opts :: Keyword.t()
) ::
atom()
defmacro __using__(_opts) do
quote do
@behaviour Handwave
@handwave_module __MODULE__
@handwave_instructor_lite Application.compile_env(
:handwave,
:instructor_lite_module,
InstructorLite
)
@impl Handwave
defmacro llm_if(condition, prompt, opts \\ [], clauses) do
Handwave.build_llm_if(@handwave_module, condition, prompt, opts, clauses)
end
@impl Handwave
defmacro llm_if!(condition, prompt, opts \\ [], clauses) do
opts = Keyword.put(opts, :raise?, true)
Handwave.build_llm_if(@handwave_module, condition, prompt, opts, clauses)
end
@impl Handwave
def llm_rewrite(input, prompt, opts \\ []) do
Handwave.llm_rewrite(@handwave_module, @handwave_instructor_lite, input, prompt, opts)
end
@impl Handwave
def llm_rewrite!(input, prompt, opts \\ []) do
llm_rewrite(input, prompt, Keyword.put(opts, :raise?, true))
end
@impl Handwave
def llm_route(input, options, prompt, opts \\ []) do
Handwave.llm_route(
@handwave_module,
@handwave_instructor_lite,
input,
options,
prompt,
opts
)
end
@impl Handwave
def llm_route!(input, options, prompt, opts \\ []) do
llm_route(input, options, prompt, Keyword.put(opts, :raise?, true))
end
@doc false
def evaluate(term, prompt, opts),
do: Handwave.evaluate(@handwave_module, @handwave_instructor_lite, term, prompt, opts)
end
end
@doc false
def llm_rewrite(module, instructor_lite, input, prompt, raw_opts) do
with {:ok, opts} <- prepare_options(prompt, raw_opts) do
log_level = opts[:log_level]
default = opts[:default]
raise? = opts[:raise?]
response_model =
if Logger.compare_levels(log_level, Logger.level()) == :lt do
%{output: :string}
else
%{output: :string, explanation: :string}
end
params = module.build_params(:llm_rewrite, input, prompt, opts)
options = module.build_options([response_model: response_model], opts)
case instructor_lite.instruct(params, options) do
{:ok, %{output: output, explanation: explanation}} ->
Logger.log(
log_level,
"LLM_REWRITE resolved with explanation: #{explanation}\nBEFORE:\n#{inspect(input)}\nAFTER:\n#{output}\nPrompt: #{prompt}",
result: output,
explanation: explanation,
prompt: prompt
)
output
{:ok, %{output: output}} ->
output
error ->
error = %Handwave.Error{
message: "LLM_REWRITE call failed",
error: error
}
if raise? do
raise error
else
Logger.error(error.message, error: error.error)
default
end
end
end
end
@doc false
def llm_route(module, instructor_lite, input, options, prompt, raw_opts) do
with {:ok, opts} <- prepare_options(prompt, raw_opts) do
log_level = opts[:log_level]
default = opts[:default]
raise? = opts[:raise?]
response_model =
if Logger.compare_levels(log_level, Logger.level()) == :lt do
%{choice: Ecto.ParameterizedType.init(Ecto.Enum, values: options)}
else
%{choice: Ecto.ParameterizedType.init(Ecto.Enum, values: options), explanation: :string}
end
params = module.build_params(:llm_route, input, prompt, opts)
options_list = module.build_options([response_model: response_model], opts)
case instructor_lite.instruct(params, options_list) do
{:ok, %{choice: choice, explanation: explanation}} ->
Logger.log(
log_level,
"LLM_ROUTE resolved to #{inspect(choice)}: #{explanation}\nPrompt: #{prompt}",
result: choice,
explanation: explanation,
prompt: prompt
)
choice
{:ok, %{choice: choice}} ->
choice
error ->
error = %Handwave.Error{
message: "LLM_ROUTE call failed",
error: error
}
if raise? do
raise error
else
Logger.error(error.message, error: error.error)
default
end
end
end
end
@doc false
def evaluate(module, instructor_lite, term, prompt, raw_opts) do
with {:ok, opts} <- prepare_options(prompt, raw_opts) do
log_level = opts[:log_level]
default = opts[:default]
raise? = opts[:raise?]
response_model =
if Logger.compare_levels(log_level, Logger.level()) == :lt do
%{result: :boolean}
else
%{result: :boolean, explanation: :string}
end
params = module.build_params(:llm_if, term, prompt, opts)
options = module.build_options([response_model: response_model], opts)
case instructor_lite.instruct(params, options) do
{:ok, %{result: bool, explanation: explanation}} when is_boolean(bool) ->
Logger.log(log_level, "LLM_IF resolved to #{bool}: #{explanation}\nPrompt: #{prompt}",
result: bool,
explanation: explanation,
prompt: prompt
)
bool
{:ok, %{result: bool}} when is_boolean(bool) ->
bool
error ->
error = %Handwave.Error{
message: "LLM_IF call failed",
error: error
}
if raise? do
raise error
else
Logger.error(error.message, error: error.error)
default
end
end
end
end
@doc false
def build_llm_if(module, condition, prompt, opts, clauses) do
case Keyword.split(clauses, [:do, :else]) do
{[do: do_clause], more_opts} ->
do_build_llm_if(module, condition, prompt, opts ++ more_opts, do: do_clause, else: nil)
{clauses, more_opts} ->
do_build_llm_if(module, condition, prompt, opts ++ more_opts, clauses)
end
end
defp do_build_llm_if(module, condition, prompt, opts, do: do_clause, else: else_clause) do
quote do
case unquote(condition) |> unquote(module).evaluate(unquote(prompt), unquote(opts)) do
x when unquote(x_is_false_or_nil()) -> unquote(else_clause)
_ -> unquote(do_clause)
end
end
end
defp x_is_false_or_nil do
quote generated: true do
:erlang.orelse(:erlang."=:="(x, false), :erlang."=:="(x, nil))
end
end
defp prepare_options(prompt, raw_opts) do
{raise?, raw_opts} = Keyword.pop(raw_opts, :raise?, false)
{known_opts, leftover_opts} = Keyword.split(raw_opts, @known_handwave_options)
case NimbleOptions.validate(known_opts, @handwave_options_schema) do
{:ok, known_opts} ->
{:ok, known_opts ++ leftover_opts ++ [raise?: raise?]}
{:error, error} ->
if raise? do
raise error
else
Exception.format(:error, error)
|> Logger.error(prompt: prompt, opt: raw_opts)
raw_opts[:default]
end
end
end
end