Building an AI Chat Log with Spector
View SourceThis 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
end2. 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
endThe 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
endUsage
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
Insert: Creates the first event with the initial message. The event
idandparent_idare the same (this is the conversation root).Append: Creates a new event linked to the conversation (
parent_id) with ancestry tracking to the specified message (to).Branching: When you append with a
:topointing to an earlier message,prepare_event/3builds the ancestry chain from that point. This creates a new branch in the conversation tree.Reconstruction: The
ancestorsassociation lets you traverse back through any branch to reconstruct the full conversation path.
Tips
- The
roleenum (:user,:assistant,:system) matches common AI API conventions - Consider adding timestamps to message payloads for display purposes
- The
parent_idgroups all events for a conversation; use it to list all branches - Use
get_branch/1for reconstructing a single path through the conversation tree