Packages

Use LLM judgment in regular Elixir control flow.

Current section

Files

Jump to
gut README.md
Raw

README.md

# Gut

[Hex](https://hex.pm/packages/gut) · [Docs](https://hexdocs.pm/gut)

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.

```elixir
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`:

```elixir
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`](https://hexdocs.pm/gut/Gut.ReqLLM.Jev.html) to use a Jev evaluation model:

```elixir
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`](https://hexdocs.pm/gut/Gut.ReqLLM.html) with any ReqLLM-supported model:

```elixir
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](https://hexdocs.pm/req_llm/ollama.html) and pull a model:

```sh
ollama pull llama3.2
```

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

```elixir
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](https://hexdocs.pm/req_llm/ollama.html) 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`](https://hexdocs.pm/gut/Gut.Adapter.html) to use your own model or service:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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`](https://hexdocs.pm/gut/Gut.Error.html) 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`](https://hexdocs.pm/gut/Gut.Subject.html) to limit the fields sent to the model:

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

## Testing

Use [`Gut.Test`](https://hexdocs.pm/gut/Gut.Test.html) in `config/test.exs` to run tests without LLM requests:

```elixir
config :gut, adapter: Gut.Test
```

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

```elixir
# 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.

```sh
mix deps.get
pre-commit install
```

Run the checks:

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