Skip to content

Repository files navigation

Gut

Hex · Docs

Gut is a simple DSL for LLM decisions in Elixir. It picks an Elixir value for your subject and question. No agent framework required, just a few lines of code and your LLM of choice.

ticket = %{
  subject: "Charged twice for the same order",
  body: "Please refund the second charge."
}

Gut.feel(ticket, "Which team should handle this?",
  billing: "Payments, invoices, and refunds",
  technical: "Product defects and access problems",
  other: "Anything else"
)
#=> {:ok, :billing}

Installation

Add gut and req_llm to mix.exs:

def deps do
  [
    {:gut, "~> 0.1.0"},
    {:req_llm, "~> 1.24"}
  ]
end

Choose one of these options to configure the adapter:

Option 1: Jev evaluation

Configure Gut.ReqLLM.Jev to use a Jev evaluation model:

config :gut,
  adapter: {Gut.ReqLLM.Jev,
    model: "typesafe:jev-latest",
    api_key: System.fetch_env!("TYPESAFE_API_KEY")}

Option 2: Other LLM models

Use Gut.ReqLLM with any ReqLLM-supported model:

config :gut,
  adapter: {Gut.ReqLLM,
    model: "anthropic:claude-haiku-4-5",
    api_key: System.fetch_env!("ANTHROPIC_API_KEY")}

With Gut.ReqLLM, options other than :model pass to ReqLLM.generate_text/3.

Option 3: Local models with Ollama

Start Ollama and pull a model:

ollama pull llama3.2

Then use the same Gut.ReqLLM adapter. No API key is needed:

config :gut, adapter: {Gut.ReqLLM, model: "ollama:llama3.2"}

Ollama must be running at its default address, http://localhost:11434. See the ReqLLM Ollama guide to use a different address.

Option 4: Custom adapter

An adapter connects Gut to a decision system. Gut passes it the subject, question, and choices. The adapter returns the ID of the selected choice. Implement Gut.Adapter to use your own model or service:

defmodule MyApp.BumblebeeAdapter do
  @behaviour Gut.Adapter

  def init([]), do: nil

  def choose(subject, _question, choices, _state) do
    %{predictions: predictions} = Nx.Serving.batched_run(MyApp.Classifier, subject)
    %{label: label} = Enum.max_by(predictions, & &1.score)

    case Enum.find(choices, fn {_id, description} -> description == label end) do
      {id, _} -> {:ok, id}
      nil -> {:error, %Gut.Error{reason: :invalid_answer, message: "Unknown label"}}
    end
  end
end

config :gut, adapter: MyApp.BumblebeeAdapter

Usage

Use a list when the choices need no descriptions. For example, sort incoming mail:

case Gut.feel(%{from: email.from, subject: email.subject, body: email.body},
       "Is this spam?", [true, false], allow_unsure: true) do
  {:ok, true} -> Mailbox.move(email, :spam)
  {:ok, false} -> Mailbox.move(email, :inbox)
  {:ok, :unsure} -> Mailbox.flag_for_review(email)
  {:error, error} -> {:error, error}
end

Use keyword choices when the model needs descriptions. For example, route a ticket after loading it from the database:

with %Ticket{} = ticket <- Repo.get(Ticket, id),
     {:ok, team} <- Gut.feel(%{subject: ticket.subject, body: ticket.body},
       "Which team should handle this?",
       billing: "Payments, invoices, and refunds",
       technical: "Product defects and access problems",
       other: "Anything else"
     ),
     {:ok, ticket} <- Repo.update(Ecto.Changeset.change(ticket, team: Atom.to_string(team))) do
  Support.notify_team(ticket)
else
  nil -> {:error, :not_found}
  error -> error
end

An integer range works for scores. For example, flag a conversation when the customer is frustrated:

case Gut.feel(messages, "How frustrated is the customer?", 1..5) do
  {:ok, score} when score >= 4 -> Support.flag_for_review(conversation_id)
  {:ok, _score} -> :ok
  {:error, error} -> {:error, error}
end

Gut.feel/4 returns {:error, %Gut.Error{}} for provider and adapter failures. See Gut.Error for error details. Use the reason field for control flow:

  • :timeout
  • :rate_limited
  • :unauthorized
  • :invalid_answer
  • :adapter_error

cause contains the original error for logging.

Subjects

Gut sends strings as plain text and encodes other values as JSON.

For structs, derive Gut.Subject to limit the fields sent to the model:

defmodule Ticket do
  @derive {Gut.Subject, only: [:subject, :body]}
  defstruct [:subject, :body, :internal_notes]
end

Testing

Use Gut.Test in config/test.exs to run tests without LLM requests:

config :gut, adapter: Gut.Test

It selects the first choice by default. To select another choice, register a stub in each test:

# in an async test
Gut.Test.stub(fn _subject, question, _choices ->
  if question == "Which team?", do: :technical, else: :billing
end)

assert MyApp.route(ticket) == :technical

Stubs must return an offered choice. They belong to the test process. Tasks started by that process can use them. Other processes need their own stub. Tests can use async: true.

Telemetry

Gut emits these :telemetry events:

Event Measurements Additional metadata
[:gut, :feel, :start] system_time —
[:gut, :feel, :stop] duration outcome (:ok or a Gut.Error reason)
[:gut, :feel, :exception] duration kind (:error, :exit, or :throw)

All events include adapter and model metadata. duration uses native time units. Gut does not emit subjects, questions, choices, errors, or stack traces. Validation and adapter initialization do not emit events.

Development

Gut requires Elixir 1.18 or later.

mix deps.get
pre-commit install

Run the checks:

mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix test

About

Use LLM judgment in regular Elixir control flow.

Topics

Resources

Stars

17 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages