Persist chat history

This commit is contained in:
Claudio Ortolina
2026-03-17 13:50:01 +00:00
parent bcef30eb52
commit 8fd8dde73d
12 changed files with 717 additions and 121 deletions
+106
View File
@@ -0,0 +1,106 @@
defmodule MusicLibrary.Chats do
@moduledoc """
Persistent storage for AI chat conversations about records and artists.
"""
import Ecto.Query, warn: false
alias MusicLibrary.Chats.{Chat, Message}
alias MusicLibrary.Repo
@spec list_chats(atom(), String.t()) :: [Chat.t()]
def list_chats(entity, musicbrainz_id) do
message_count_query =
from(m in Message,
where: m.chat_id == parent_as(:chat).id,
select: count(m.id)
)
from(c in Chat,
as: :chat,
where: c.entity == ^entity and c.musicbrainz_id == ^musicbrainz_id,
order_by: [desc: c.updated_at],
select_merge: %{message_count: subquery(message_count_query)}
)
|> Repo.all()
end
@spec has_any_chats?(atom(), String.t()) :: boolean()
def has_any_chats?(entity, musicbrainz_id) do
from(c in Chat,
where: c.entity == ^entity and c.musicbrainz_id == ^musicbrainz_id
)
|> Repo.exists?()
end
@spec get_chat!(String.t()) :: Chat.t()
def get_chat!(id) do
Chat
|> Repo.get!(id)
|> Repo.preload(:messages)
end
@spec create_chat_with_message(map(), map()) ::
{:ok, Chat.t()} | {:error, Ecto.Changeset.t()}
def create_chat_with_message(chat_attrs, message_attrs) do
topic =
message_attrs
|> Map.get(:content, "")
|> String.trim()
|> String.slice(0, 80)
Repo.transaction(fn ->
chat_attrs = Map.put(chat_attrs, :topic, topic)
with {:ok, chat} <- %Chat{} |> Chat.changeset(chat_attrs) |> Repo.insert(),
message_attrs = Map.merge(message_attrs, %{position: 0}),
{:ok, _message} <-
%Message{chat_id: chat.id}
|> Message.changeset(message_attrs)
|> Repo.insert() do
Repo.preload(chat, :messages)
else
{:error, changeset} -> Repo.rollback(changeset)
end
end)
end
@spec add_message(Chat.t(), map()) :: {:ok, Message.t()} | {:error, Ecto.Changeset.t()}
def add_message(%Chat{} = chat, attrs) do
next_position =
from(m in Message,
where: m.chat_id == ^chat.id,
select: coalesce(max(m.position), -1)
)
|> Repo.one!()
|> Kernel.+(1)
attrs = Map.put(attrs, :position, next_position)
result =
%Message{chat_id: chat.id}
|> Message.changeset(attrs)
|> Repo.insert()
case result do
{:ok, message} ->
touch_chat(chat)
{:ok, message}
error ->
error
end
end
@spec delete_chat(Chat.t()) :: {:ok, Chat.t()} | {:error, Ecto.Changeset.t()}
def delete_chat(%Chat{} = chat) do
Repo.delete(chat)
end
defp touch_chat(chat) do
now = DateTime.truncate(DateTime.utc_now(), :second)
from(c in Chat, where: c.id == ^chat.id)
|> Repo.update_all(set: [updated_at: now])
end
end
+31
View File
@@ -0,0 +1,31 @@
defmodule MusicLibrary.Chats.Chat do
use Ecto.Schema
import Ecto.Changeset
alias MusicLibrary.Chats.Message
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "chats" do
field :entity, Ecto.Enum, values: [:record, :artist]
field :musicbrainz_id, Ecto.UUID
field :topic, :string
field :message_count, :integer, virtual: true, default: 0
has_many :messages, Message, preload_order: [asc: :position]
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(chat, attrs) do
chat
|> cast(attrs, [:entity, :musicbrainz_id, :topic])
|> validate_required([:entity, :musicbrainz_id])
|> validate_length(:topic, max: 200)
end
end
+29
View File
@@ -0,0 +1,29 @@
defmodule MusicLibrary.Chats.Message do
use Ecto.Schema
import Ecto.Changeset
alias MusicLibrary.Chats.Chat
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "chat_messages" do
field :role, :string
field :content, :string
field :position, :integer
belongs_to :chat, Chat
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(message, attrs) do
message
|> cast(attrs, [:role, :content, :position])
|> validate_required([:role, :content, :position])
|> validate_inclusion(:role, ["user", "assistant"])
end
end