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