README.md

# LangchainPrompt

A structured approach to building prompt-driven LLM pipelines in Elixir.

Define each AI task as a **prompt module** — a plain Elixir module that
implements a four-callback behaviour. `LangchainPrompt.execute/4` wires the
pieces together: builds the message list, calls the adapter, and runs
post-processing.

```elixir
defmodule MyApp.Prompts.Summarise do
  @behaviour LangchainPrompt.Prompt

  @impl true
  def set_profile(_assigns) do
    %LangchainPrompt.Profile{
      adapter: LangchainPrompt.Adapters.Langchain,
      opts: %{
        chat_module: LangChain.ChatModels.ChatOpenAI,
        model: "gpt-4o-mini"
      }
    }
  end

  @impl true
  def generate_system_prompt(_assigns), do: "You are a concise summariser."

  @impl true
  def generate_user_prompt(%{text: text}), do: "Summarise: #{text}"

  @impl true
  def post_process(_assigns, %LangchainPrompt.Message{content: content}),
    do: {:ok, content}
end

{:ok, summary} = LangchainPrompt.execute(MyApp.Prompts.Summarise, %{text: "..."})
```

## Installation

```elixir
def deps do
  [
    {:langchain_prompt, "~> 0.1"}
  ]
end
```

## Core concepts

### The `Prompt` behaviour

Each prompt module implements four callbacks:

| Callback | Returns | Purpose |
|---|---|---|
| `set_profile/1` | `Profile.t()` | Which adapter + model to use |
| `generate_system_prompt/1` | `String.t() \| nil` | The system message (nil to omit) |
| `generate_user_prompt/1` | `String.t() \| nil` | The user message (nil for conversation-tail) |
| `post_process/2` | `{:ok, any} \| {:error, any}` | Parse / validate the raw response |

`assigns` is whatever map or struct your application passes to `execute/4`. It
flows through every callback, so model selection, prompt content, and
post-processing can all be data-driven.

### Profiles

A `Profile` pairs an adapter module with its opts:

```elixir
%LangchainPrompt.Profile{
  adapter: LangchainPrompt.Adapters.Langchain,
  opts: %{
    chat_module: LangChain.ChatModels.ChatGoogleAI,
    model: "gemini-2.0-flash",
    temperature: 0.1
  }
}
```

For named profiles shared across many prompt modules, configure a profiles
module (see `LangchainPrompt.Profiles`).

### Message history

Pass prior turns as the third argument:

```elixir
history = [
  %LangchainPrompt.Message{role: :user, content: "Hello"},
  %LangchainPrompt.Message{role: :assistant, content: "Hi there!"}
]
LangchainPrompt.execute(MyPrompt, assigns, history)
```

Messages are assembled as: `[system] ++ history ++ [user]`.

### Attachments (multimodal)

```elixir
attachments = [LangchainPrompt.Attachment.from_file!("/tmp/menu.jpg")]
LangchainPrompt.execute(MyPrompt, assigns, [], attachments)
```

Supported file types: `.jpg`/`.jpeg`, `.png`, `.gif`, `.webp`, `.pdf`.

### Error handling

`execute/4` returns tagged error tuples:

- `{:error, {:adapter_failure, reason}}` — adapter returned an error
- `{:error, {:post_processing_failure, reason}}` — `post_process/2` returned an error

## Adapters

### `LangchainPrompt.Adapters.Langchain`

Delegates to any [elixir-langchain](https://hex.pm/packages/langchain) chat
model. Pass `:chat_module` in the profile opts:

```elixir
# Google AI
opts: %{chat_module: LangChain.ChatModels.ChatGoogleAI, model: "gemini-2.0-flash"}

# Anthropic
opts: %{chat_module: LangChain.ChatModels.ChatAnthropic, model: "claude-sonnet-4-6"}

# OpenAI-compatible (Deepseek, Mistral, Ollama, …)
opts: %{
  chat_module: LangChain.ChatModels.ChatOpenAI,
  model: "deepseek-chat",
  endpoint: "https://api.deepseek.com/chat/completions",
  api_key: System.get_env("DEEPSEEK_API_KEY")
}
```

### `LangchainPrompt.Adapters.Test`

Zero-dependency adapter for ExUnit. Records calls as process messages; use
`LangchainPrompt.TestAssertions` to assert on them.

**Trigger a failure:** include a message with content `"FAIL_NOW"`.

**Custom response:** pass `mock_content: "..."` in profile opts.

## Testing

```elixir
defmodule MyApp.Prompts.SummariseTest do
  use ExUnit.Case, async: true
  import LangchainPrompt.TestAssertions

  alias LangchainPrompt.Adapters.Test, as: TestAdapter
  alias LangchainPrompt.Profile

  # Override the profile to use the test adapter
  defmodule TestablePrompt do
    @behaviour LangchainPrompt.Prompt

    @impl true
    def set_profile(_), do: %Profile{adapter: TestAdapter, opts: %{}}

    defdelegate generate_system_prompt(a), to: MyApp.Prompts.Summarise
    defdelegate generate_user_prompt(a), to: MyApp.Prompts.Summarise
    defdelegate post_process(a, r), to: MyApp.Prompts.Summarise
  end

  test "builds the right user prompt" do
    LangchainPrompt.execute(TestablePrompt, %{text: "hello world"})

    assert_adapter_called(fn payload ->
      user_msg = List.last(payload.messages)
      assert user_msg.content =~ "hello world"
    end)
  end
end
```

Or configure the test adapter globally via `LangchainPrompt.Profiles.TestImpl`:

```elixir
# config/test.exs
config :langchain_prompt, :profiles_impl, LangchainPrompt.Profiles.TestImpl
```

## Named profiles

```elixir
# lib/my_app/ai_profiles.ex
defmodule MyApp.AIProfiles do
  alias LangchainPrompt.{Profile, Adapters.Langchain}

  def get(:fast) do
    %Profile{
      adapter: Langchain,
      opts: %{chat_module: LangChain.ChatModels.ChatGoogleAI, model: "gemini-2.0-flash-lite"}
    }
  end

  def get(:smart) do
    %Profile{
      adapter: Langchain,
      opts: %{chat_module: LangChain.ChatModels.ChatAnthropic, model: "claude-opus-4-6"}
    }
  end
end

# config/config.exs
config :langchain_prompt, :profiles_impl, MyApp.AIProfiles

# config/test.exs
config :langchain_prompt, :profiles_impl, LangchainPrompt.Profiles.TestImpl
```

Then in a prompt module:

```elixir
def set_profile(_assigns), do: LangchainPrompt.Profiles.get(:fast)
```

## License

MIT