Building an AI Chat Log with Spector

View Source

This guide shows how to use Spector to build an AI chat log that supports conversation branching (like ChatGPT's "edit and regenerate" feature).

Overview

AI chat applications often need:

  • Full conversation history
  • Ability to branch from any point in the conversation
  • Reconstruct any conversation path from the event log

Spector's event sourcing with links provides exactly this. Each message becomes an event, and branching is handled by tracking ancestors.

Setup

1. Define the Chat Schema

Since chat state is reconstructed from events, use an embedded schema (no database table):

defmodule MyApp.AIChat do
  use Spector.Evented, events: MyApp.AIChatEvents, actions: [:append]
  use Ecto.Schema
  alias Ecto.Changeset

  defmodule Message do
    use Ecto.Schema

    @primary_key false
    embedded_schema do
      field :id, :binary_id
      field :content, :string
      field :role, Ecto.Enum, values: [:user, :assistant, :system]
    end

    def changeset(message \\ %__MODULE__{}, attrs) do

      message
      |> Changeset.cast(attrs, [:id, :content, :role])
      |> Spector.changeset_put_event_id(attrs)
      |> Changeset.validate_required([:id, :content, :role])
    end
  end

  @primary_key {:id, :binary_id, autogenerate: false}
  embedded_schema do
    embeds_many :messages, Message
  end

  @impl true
  def prepare_event(event_changeset, previous_events, %{to: ancestor_id}) do
    # Find the ancestor event and build the ancestry chain
    previous_events
    |> Enum.find(&(&1.id == ancestor_id))
    |> MyApp.Repo.preload(:ancestors)
    |> then(&[&1 | &1.ancestors])
    |> then(&Changeset.put_assoc(event_changeset, :ancestors, &1))
  end

  def prepare_event(event_changeset, _previous_events, _attrs) do
    # Require :to for append actions (except the first message)
    if Changeset.fetch_field!(event_changeset, :action) == :append do
      raise ArgumentError, "Missing :to attribute for append action"
    else
      event_changeset
    end
  end

  def changeset(changeset \\ %__MODULE__{}, attrs) do
    changeset = Changeset.change(changeset)

    message =
      attrs
      |> Message.changeset()
      |> Changeset.apply_action!(:insert)

    current_messages = Changeset.get_field(changeset, :messages, [])
    Changeset.put_change(changeset, :messages, [message | current_messages])
  end

  def get_branch(tail_id) do
    case get_branch_events(tail_id) do
      [%{parent_id: parent_id} | _] = events ->
        events
        |> Enum.reduce(%__MODULE__{id: parent_id}, fn event, acc ->
          changeset(acc, Map.put(event.payload, "__event_id__", event.id))
        end)
        |> Changeset.apply_action!(:get)
      [] -> nil
    end
  end

  defp get_branch_events(tail_id) do
    import Ecto.Query

    ancestor_ids =
      from(link in "ai_chat_ancestors",
        where: link.event_id == type(^tail_id, :binary_id),
        select: link.ancestor_id
      )

    MyApp.Repo.all(
      from(e in MyApp.AIChatEvents,
        where: e.id == ^tail_id or e.id in subquery(ancestor_ids),
        order_by: [desc: e.inserted_at]
      )
    )
  end
end

2. Define the Events Module

defmodule MyApp.AIChatEvents do
  use Spector.Events,
    table: "ai_chat_events",
    links: [ancestors: {"ai_chat_ancestors", :ancestor_id}],
    schemas: [MyApp.AIChat],
    repo: MyApp.Repo
end

The links option creates a many-to-many relationship for tracking message ancestry. Each event can reference its ancestor events, enabling tree-structured conversations.

3. Create the Migration

defmodule MyApp.Repo.Migrations.CreateChatEvents do
  use Ecto.Migration

  def up do
    Spector.Migration.up(
      table: "ai_chat_events",
      links: [{"ai_chat_ancestors", :ancestor_id}]
    )
  end

  def down do
    Spector.Migration.down(
      table: "ai_chat_events",
      links: [{"ai_chat_ancestors", :ancestor_id}]
    )
  end
end

Usage

Starting a Conversation

assert {:ok, %MyApp.AIChat{messages: [%{role: :user, content: "Hello, how are you?"}]} = chat} =
  Spector.insert(MyApp.AIChat, %{
    role: :user,
    content: "Hello, how are you?"
  })

Adding Messages

To add a message, use Spector.execute/3 with the :append action. The :to attribute specifies which message to append after:

# Append assistant response
assert {:ok, %{messages: [%{role: :assistant, content: "I'm doing well, thank you!"}, _]} = chat} =
  Spector.execute(chat, :append, %{
    to: chat.id,
    role: :assistant,
    content: "I'm doing well, thank you!"
  })

Branching a Conversation

To branch from an earlier point, specify the :to attribute pointing to the message you want to branch from:

# branch from the first message
{:ok, %{messages: [%{id: branch_id}, %{id: base_id}, _]}} = Spector.execute(chat, :append, %{
  to: chat.id,
  role: :user,
  content: "Actually, let me rephrase that..."
})

Reconstructing a Conversation Branch

Use get_branch/1 (defined in the Chat module above) to retrieve the chat state at a specific branch point. Note that messages are prepended in our implementation above, so the most recent message appears first:

# Get the assistant's branch (original user message + assistant response)
assert %{messages: [
  %{role: :user, content: "Hello, how are you?"}, 
  %{role: :assistant, content: "I'm doing well, thank you!"}]} = MyApp.AIChat.get_branch(base_id)

# Get the new branch (original user message + rephrased user message)
assert %{messages: [
  %{role: :user, content: "Hello, how are you?"}, 
  %{role: :user, content: "Actually, let me rephrase that..."}]} = MyApp.AIChat.get_branch(branch_id)

How It Works

  1. Insert: Creates the first event with the initial message. The event id and parent_id are the same (this is the conversation root).

  2. Append: Creates a new event linked to the conversation (parent_id) with ancestry tracking to the specified message (to).

  3. Branching: When you append with a :to pointing to an earlier message, prepare_event/3 builds the ancestry chain from that point. This creates a new branch in the conversation tree.

  4. Reconstruction: The ancestors association lets you traverse back through any branch to reconstruct the full conversation path.

Tips

  • The role enum (:user, :assistant, :system) matches common AI API conventions
  • Consider adding timestamps to message payloads for display purposes
  • The parent_id groups all events for a conversation; use it to list all branches
  • Use get_branch/1 for reconstructing a single path through the conversation tree