Add similar records
Initial stab by Claude
This commit is contained in:
@@ -28,7 +28,9 @@
|
||||
"Bash(git show:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(mise tasks:*)",
|
||||
"Bash(mix run:*)"
|
||||
"Bash(mix run:*)",
|
||||
"Bash(sqlite3:*)",
|
||||
"WebSearch"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
defmodule Mix.Tasks.MusicLibrary.GenerateEmbeddings do
|
||||
@moduledoc """
|
||||
Generates embeddings for records.
|
||||
|
||||
## Usage
|
||||
|
||||
# Generate embeddings for all records without embeddings
|
||||
mix music_library.generate_embeddings
|
||||
|
||||
# Regenerate embeddings for all records (force)
|
||||
mix music_library.generate_embeddings --force
|
||||
|
||||
# Generate embeddings for specific record IDs
|
||||
mix music_library.generate_embeddings --ids=id1,id2,id3
|
||||
|
||||
## Options
|
||||
|
||||
* `--force` - Regenerate embeddings for all records, even if they already exist
|
||||
* `--ids` - Comma-separated list of record IDs to process
|
||||
"""
|
||||
use Mix.Task
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias MusicLibrary.Repo
|
||||
alias MusicLibrary.Records.{Record, RecordEmbedding}
|
||||
alias MusicLibrary.Worker.GenerateRecordEmbedding
|
||||
|
||||
@shortdoc "Generates embeddings for records"
|
||||
|
||||
@impl Mix.Task
|
||||
def run(args) do
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
{opts, _} =
|
||||
OptionParser.parse!(args,
|
||||
strict: [force: :boolean, ids: :string],
|
||||
aliases: [f: :force, i: :ids]
|
||||
)
|
||||
|
||||
force? = Keyword.get(opts, :force, false)
|
||||
ids = parse_ids(Keyword.get(opts, :ids))
|
||||
|
||||
records = get_records(force?, ids)
|
||||
total = length(records)
|
||||
|
||||
Mix.shell().info("Found #{total} records to process...")
|
||||
|
||||
records
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.each(fn {record, index} ->
|
||||
Mix.shell().info("[#{index}/#{total}] Enqueueing job for: #{record.title}")
|
||||
|
||||
%{record_id: record.id}
|
||||
|> GenerateRecordEmbedding.new()
|
||||
|> Oban.insert!()
|
||||
end)
|
||||
|
||||
Mix.shell().info("\nSuccessfully enqueued #{total} embedding generation jobs.")
|
||||
Mix.shell().info("Monitor progress in Oban dashboard or logs.")
|
||||
end
|
||||
|
||||
defp parse_ids(nil), do: []
|
||||
|
||||
defp parse_ids(ids_string) do
|
||||
ids_string
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
|
||||
defp get_records(force?, []) when force? do
|
||||
Record
|
||||
|> order_by(asc: :title)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp get_records(false, []) do
|
||||
existing_record_ids =
|
||||
RecordEmbedding
|
||||
|> select([re], re.record_id)
|
||||
|> Repo.all()
|
||||
|> MapSet.new()
|
||||
|
||||
Record
|
||||
|> order_by(asc: :title)
|
||||
|> Repo.all()
|
||||
|> Enum.reject(fn record -> MapSet.member?(existing_record_ids, record.id) end)
|
||||
end
|
||||
|
||||
defp get_records(_force?, ids) when is_list(ids) and length(ids) > 0 do
|
||||
Record
|
||||
|> where([r], r.id in ^ids)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
@@ -279,6 +279,15 @@ defmodule MusicLibrary.Records do
|
||||
|> BackgroundRepo.insert()
|
||||
end
|
||||
|
||||
def generate_embedding_async(record) do
|
||||
meta = %{title: record.title, artists: Enum.map(record.artists, & &1.name)}
|
||||
params = %{"record_id" => record.id}
|
||||
|
||||
params
|
||||
|> Worker.GenerateRecordEmbedding.new(meta: meta)
|
||||
|> BackgroundRepo.insert()
|
||||
end
|
||||
|
||||
def resize_cover(record) do
|
||||
with {:ok, thumb_data} <- Assets.Image.resize(record.cover_data),
|
||||
{:ok, asset} <- Assets.store_image(%{content: thumb_data, format: "image/jpeg"}) do
|
||||
@@ -340,6 +349,7 @@ defmodule MusicLibrary.Records do
|
||||
def create_record(attrs \\ %{}) do
|
||||
with {:ok, record} <- do_create_record(attrs) do
|
||||
extract_colors_async(record, :fast)
|
||||
generate_embedding_async(record)
|
||||
|
||||
record
|
||||
|> Record.artist_ids()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
defmodule MusicLibrary.Records.RecordEmbedding do
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias MusicLibrary.Records.Record
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "record_embeddings" do
|
||||
belongs_to :record, Record
|
||||
|
||||
field :embedding, MusicLibrary.Records.RecordEmbedding.EmbeddingType
|
||||
field :text_representation, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(record_embedding, attrs) do
|
||||
record_embedding
|
||||
|> cast(attrs, [:record_id, :embedding, :text_representation])
|
||||
|> validate_required([:record_id, :embedding, :text_representation])
|
||||
|> validate_embedding_dimensions()
|
||||
|> unique_constraint(:record_id)
|
||||
end
|
||||
|
||||
defp validate_embedding_dimensions(changeset) do
|
||||
case get_change(changeset, :embedding) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
embedding when is_list(embedding) ->
|
||||
if length(embedding) == 1536 do
|
||||
changeset
|
||||
else
|
||||
add_error(
|
||||
changeset,
|
||||
:embedding,
|
||||
"must have exactly 1536 dimensions, got #{length(embedding)}"
|
||||
)
|
||||
end
|
||||
|
||||
_ ->
|
||||
add_error(changeset, :embedding, "must be a list of floats")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
defmodule MusicLibrary.Records.RecordEmbedding.EmbeddingType do
|
||||
@moduledoc """
|
||||
Custom Ecto type for storing embedding vectors.
|
||||
|
||||
Embeddings are stored as JSON-encoded arrays of floats in the database,
|
||||
but presented as Elixir lists in the application.
|
||||
"""
|
||||
use Ecto.Type
|
||||
|
||||
@impl true
|
||||
def type, do: :string
|
||||
|
||||
@impl true
|
||||
def cast(embedding) when is_list(embedding) do
|
||||
if Enum.all?(embedding, &is_float/1) or Enum.all?(embedding, &is_number/1) do
|
||||
# Convert all numbers to floats
|
||||
{:ok, Enum.map(embedding, &to_float/1)}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
def cast(_), do: :error
|
||||
|
||||
@impl true
|
||||
def load(json) when is_binary(json) do
|
||||
case JSON.decode(json) do
|
||||
{:ok, embedding} when is_list(embedding) ->
|
||||
{:ok, Enum.map(embedding, &to_float/1)}
|
||||
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
def load(_), do: :error
|
||||
|
||||
@impl true
|
||||
def dump(embedding) when is_list(embedding) do
|
||||
json = JSON.encode!(embedding)
|
||||
{:ok, json}
|
||||
rescue
|
||||
_ -> :error
|
||||
end
|
||||
|
||||
def dump(_), do: :error
|
||||
|
||||
defp to_float(n) when is_float(n), do: n
|
||||
defp to_float(n) when is_integer(n), do: n * 1.0
|
||||
end
|
||||
@@ -0,0 +1,179 @@
|
||||
defmodule MusicLibrary.Records.Similarity do
|
||||
@moduledoc """
|
||||
Functions for calculating and finding similar records based on embeddings.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias MusicLibrary.Records.{Record, RecordEmbedding}
|
||||
alias MusicLibrary.Repo
|
||||
|
||||
@doc """
|
||||
Generates a text representation of a record for embedding generation.
|
||||
|
||||
The representation includes:
|
||||
- Title
|
||||
- Artist names
|
||||
- Genres
|
||||
- Release year
|
||||
- Type (album, EP, etc.)
|
||||
"""
|
||||
def text_representation(%Record{} = record) do
|
||||
artist_names = Record.artist_names(record)
|
||||
genres = Enum.join(record.genres, ", ")
|
||||
year = extract_year(record.release_date)
|
||||
type = humanize_type(record.type)
|
||||
|
||||
"""
|
||||
Album: #{record.title}
|
||||
Artists: #{artist_names}
|
||||
Genres: #{genres}
|
||||
Released: #{year}
|
||||
Type: #{type}
|
||||
"""
|
||||
|> String.trim()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds similar records based on embedding similarity.
|
||||
|
||||
## Options
|
||||
|
||||
- `:limit` - Maximum number of similar records to return (default: 10)
|
||||
- `:min_similarity` - Minimum similarity score (0.0 to 1.0, default: 0.0)
|
||||
- `:scope` - Filter by :collection or :wishlist (default: no filter)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> find_similar("record-id-123", limit: 5)
|
||||
[%Record{}, ...]
|
||||
|
||||
iex> find_similar("record-id-123", min_similarity: 0.7, scope: :collection)
|
||||
[%Record{}, ...]
|
||||
"""
|
||||
def find_similar(record_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 10)
|
||||
min_similarity = Keyword.get(opts, :min_similarity, 0.0)
|
||||
scope = Keyword.get(opts, :scope)
|
||||
|
||||
with {:ok, source_embedding} <- get_embedding(record_id),
|
||||
similar_records <- calculate_similarities(source_embedding, record_id, scope) do
|
||||
similar_records
|
||||
|> Enum.filter(fn {_record, similarity} -> similarity >= min_similarity end)
|
||||
|> Enum.take(limit)
|
||||
|> Enum.map(fn {record, similarity} -> {record, Float.round(similarity, 4)} end)
|
||||
else
|
||||
{:error, :not_found} -> []
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates cosine similarity between two embedding vectors.
|
||||
|
||||
Returns a float between -1.0 and 1.0, where:
|
||||
- 1.0 = identical vectors
|
||||
- 0.0 = orthogonal vectors
|
||||
- -1.0 = opposite vectors
|
||||
"""
|
||||
def cosine_similarity(vec_a, vec_b) when is_list(vec_a) and is_list(vec_b) do
|
||||
if length(vec_a) != length(vec_b) do
|
||||
raise ArgumentError, "Vectors must have the same length"
|
||||
end
|
||||
|
||||
dot_product =
|
||||
Enum.zip(vec_a, vec_b)
|
||||
|> Enum.reduce(0.0, fn {a, b}, acc -> acc + a * b end)
|
||||
|
||||
magnitude_a = calculate_magnitude(vec_a)
|
||||
magnitude_b = calculate_magnitude(vec_b)
|
||||
|
||||
if magnitude_a == 0.0 or magnitude_b == 0.0 do
|
||||
0.0
|
||||
else
|
||||
dot_product / (magnitude_a * magnitude_b)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the embedding for a record.
|
||||
"""
|
||||
def get_embedding(record_id) do
|
||||
case Repo.get_by(RecordEmbedding, record_id: record_id) do
|
||||
nil -> {:error, :not_found}
|
||||
embedding -> {:ok, embedding.embedding}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stores an embedding for a record.
|
||||
"""
|
||||
def store_embedding(record_id, embedding, text_representation) do
|
||||
attrs = %{
|
||||
record_id: record_id,
|
||||
embedding: embedding,
|
||||
text_representation: text_representation
|
||||
}
|
||||
|
||||
%RecordEmbedding{}
|
||||
|> RecordEmbedding.changeset(attrs)
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace, [:embedding, :text_representation, :updated_at]},
|
||||
conflict_target: :record_id
|
||||
)
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp extract_year(nil), do: "Unknown"
|
||||
defp extract_year(""), do: "Unknown"
|
||||
|
||||
defp extract_year(release_date) do
|
||||
case String.split(release_date, "-", parts: 2) do
|
||||
[year | _] -> year
|
||||
_ -> "Unknown"
|
||||
end
|
||||
end
|
||||
|
||||
defp humanize_type(:album), do: "Album"
|
||||
defp humanize_type(:ep), do: "EP"
|
||||
defp humanize_type(:live), do: "Live"
|
||||
defp humanize_type(:compilation), do: "Compilation"
|
||||
defp humanize_type(:single), do: "Single"
|
||||
defp humanize_type(:other), do: "Other"
|
||||
defp humanize_type(_), do: "Unknown"
|
||||
|
||||
defp calculate_magnitude(vector) do
|
||||
vector
|
||||
|> Enum.reduce(0.0, fn x, acc -> acc + x * x end)
|
||||
|> :math.sqrt()
|
||||
end
|
||||
|
||||
defp calculate_similarities(source_embedding, source_record_id, scope) do
|
||||
query =
|
||||
from re in RecordEmbedding,
|
||||
where: re.record_id != ^source_record_id,
|
||||
join: r in Record,
|
||||
on: r.id == re.record_id,
|
||||
select: {r, re.embedding}
|
||||
|
||||
query = apply_scope_filter(query, scope)
|
||||
|
||||
query
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn {record, embedding} ->
|
||||
similarity = cosine_similarity(source_embedding, embedding)
|
||||
{record, similarity}
|
||||
end)
|
||||
|> Enum.sort_by(fn {_record, similarity} -> similarity end, :desc)
|
||||
end
|
||||
|
||||
defp apply_scope_filter(query, :collection) do
|
||||
from [re, r] in query, where: not is_nil(r.purchased_at)
|
||||
end
|
||||
|
||||
defp apply_scope_filter(query, :wishlist) do
|
||||
from [re, r] in query, where: is_nil(r.purchased_at)
|
||||
end
|
||||
|
||||
defp apply_scope_filter(query, _), do: query
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
defmodule MusicLibrary.Worker.GenerateRecordEmbedding do
|
||||
use Oban.Worker, queue: :heavy_writes, max_attempts: 3
|
||||
|
||||
require Logger
|
||||
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibrary.Records.Similarity
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"record_id" => record_id}}) do
|
||||
record = Records.get_record!(record_id)
|
||||
|
||||
with {:ok, embedding} <- generate_embedding(record),
|
||||
{:ok, _} <- store_embedding(record, embedding) do
|
||||
Logger.info("Generated embedding for record #{record_id}")
|
||||
:ok
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to generate embedding for record #{record_id}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_embedding(record) do
|
||||
text = Similarity.text_representation(record)
|
||||
|
||||
case OpenAI.embeddings(text) do
|
||||
{:ok, embedding} ->
|
||||
{:ok, {embedding, text}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp store_embedding(record, {embedding, text_representation}) do
|
||||
Similarity.store_embedding(record.id, embedding, text_representation)
|
||||
end
|
||||
end
|
||||
@@ -385,4 +385,56 @@ defmodule MusicLibraryWeb.RecordComponents do
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :similar_records, :list, required: true
|
||||
attr :record_show_path, :any, required: true
|
||||
attr :section, :atom, required: true
|
||||
|
||||
def similar_records(assigns) do
|
||||
~H"""
|
||||
<div :if={@similar_records != []} class="mt-8 px-4">
|
||||
<header class="flex items-baseline justify-start">
|
||||
<h2 class="font-semibold text-base sm:text-lg leading-5 text-zinc-700 dark:text-zinc-300">
|
||||
{gettext("Similar Records")}
|
||||
</h2>
|
||||
<span class="ml-2 text-xs font-normal text-zinc-500 dark:text-zinc-400">
|
||||
{gettext("Based on genres, artists, and release year")}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
role="list"
|
||||
class="mt-4 grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 sm:gap-x-6"
|
||||
>
|
||||
<li :for={{record, similarity} <- @similar_records} class="relative group">
|
||||
<div class="overflow-hidden rounded-lg bg-zinc-100 focus-within:ring-2 focus-within:ring-zinc-500 focus-within:ring-offset-2 focus-within:ring-offset-zinc-100">
|
||||
<.record_cover
|
||||
record={record}
|
||||
class="pointer-events-none aspect-square object-cover group-hover:opacity-75 transition-opacity"
|
||||
width={300}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 focus:outline-hidden"
|
||||
phx-click={JS.navigate(@record_show_path.(record))}
|
||||
>
|
||||
<span class="sr-only">{gettext("View details")}</span>
|
||||
</button>
|
||||
|
||||
<span class="absolute top-2 right-2 rounded-full px-2 py-0.5 text-xs font-medium bg-zinc-900/75 text-white backdrop-blur-sm">
|
||||
{Float.round(similarity * 100, 0)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="pointer-events-none mt-2 block truncate text-sm font-medium text-zinc-900 dark:text-zinc-300">
|
||||
{record.title}
|
||||
</p>
|
||||
<p class="pointer-events-none block truncate text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{Records.Record.artist_names(record)}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,10 +10,12 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
|
||||
release_summary: 1,
|
||||
artist_links: 1,
|
||||
record_colors: 1,
|
||||
record_cover: 1
|
||||
record_cover: 1,
|
||||
similar_records: 1
|
||||
]
|
||||
|
||||
alias MusicLibrary.{Records, ScrobbleActivity}
|
||||
alias MusicLibrary.Records.Similarity
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@impl true
|
||||
@@ -33,6 +35,7 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
record = Records.get_record!(id)
|
||||
last_listened_track = Records.get_last_listened_track(record)
|
||||
similar_records = Similarity.find_similar(id, limit: 6, scope: :collection)
|
||||
|
||||
socket =
|
||||
if record.selected_release_id do
|
||||
@@ -45,7 +48,8 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action, record))
|
||||
|> assign(:record, record)
|
||||
|> assign(:last_listened_track, last_listened_track)}
|
||||
|> assign(:last_listened_track, last_listened_track)
|
||||
|> assign(:similar_records, similar_records)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -326,6 +326,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.similar_records
|
||||
similar_records={@similar_records}
|
||||
record_show_path={fn record -> ~p"/collection/#{record}" end}
|
||||
section={:collection}
|
||||
/>
|
||||
|
||||
<.json_viewer title={gettext("MusicBrainz data")} data={@record.musicbrainz_data} />
|
||||
|
||||
<.live_component
|
||||
|
||||
@@ -8,11 +8,13 @@ defmodule MusicLibraryWeb.WishlistLive.Show do
|
||||
release_summary: 1,
|
||||
artist_links: 1,
|
||||
record_colors: 1,
|
||||
record_cover: 1
|
||||
record_cover: 1,
|
||||
similar_records: 1
|
||||
]
|
||||
|
||||
alias MusicLibrary.OnlineStoreTemplates
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibrary.Records.Similarity
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => record_id}, _session, socket) do
|
||||
@@ -32,12 +34,14 @@ defmodule MusicLibraryWeb.WishlistLive.Show do
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
record = Records.get_record!(id)
|
||||
online_store_templates = OnlineStoreTemplates.list_enabled_templates()
|
||||
similar_records = Similarity.find_similar(id, limit: 6, scope: :wishlist)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action, record))
|
||||
|> assign(:record, record)
|
||||
|> assign(:online_store_templates, online_store_templates)}
|
||||
|> assign(:online_store_templates, online_store_templates)
|
||||
|> assign(:similar_records, similar_records)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -302,6 +302,12 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<.similar_records
|
||||
similar_records={@similar_records}
|
||||
record_show_path={fn record -> ~p"/wishlist/#{record}" end}
|
||||
section={:wishlist}
|
||||
/>
|
||||
|
||||
<.json_viewer title={gettext("MusicBrainz data")} data={@record.musicbrainz_data} />
|
||||
|
||||
<.structured_modal
|
||||
|
||||
@@ -16,6 +16,10 @@ defmodule OpenAI do
|
||||
{:ok, result}
|
||||
end
|
||||
|
||||
def embeddings(text) do
|
||||
API.get_embeddings(text, api_key())
|
||||
end
|
||||
|
||||
defp api_key do
|
||||
Application.get_env(:music_library, __MODULE__)
|
||||
|> Keyword.fetch!(:api_key)
|
||||
|
||||
@@ -1464,3 +1464,13 @@ msgstr ""
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "External Links"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/record_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Based on genres, artists, and release year"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/record_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Similar Records"
|
||||
msgstr ""
|
||||
|
||||
@@ -1464,3 +1464,13 @@ msgstr ""
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "External Links"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/record_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Based on genres, artists, and release year"
|
||||
msgstr ""
|
||||
|
||||
#: lib/music_library_web/components/record_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Similar Records"
|
||||
msgstr ""
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
defmodule MusicLibrary.Repo.Migrations.CreateRecordEmbeddings do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:record_embeddings, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :record_id, references(:records, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
add :embedding, :text, null: false
|
||||
add :text_representation, :text, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:record_embeddings, [:record_id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,256 @@
|
||||
defmodule MusicLibrary.Records.SimilarityTest do
|
||||
use MusicLibrary.DataCase
|
||||
|
||||
import MusicLibrary.Fixtures.Records
|
||||
|
||||
alias MusicLibrary.Records.{Record, Similarity}
|
||||
|
||||
describe "text_representation/1" do
|
||||
test "generates text representation for a record" do
|
||||
record = %Record{
|
||||
title: "OK Computer",
|
||||
artists: [
|
||||
%{
|
||||
name: "Radiohead",
|
||||
sort_name: "Radiohead",
|
||||
musicbrainz_id: "a74b1b7f-71a5-4011-9441-d0b5e4122711",
|
||||
disambiguation: "",
|
||||
joinphrase: ""
|
||||
}
|
||||
],
|
||||
genres: ["alternative rock", "art rock", "experimental"],
|
||||
release_date: "1997-05-21",
|
||||
type: :album
|
||||
}
|
||||
|
||||
text = Similarity.text_representation(record)
|
||||
|
||||
assert text =~ "Album: OK Computer"
|
||||
assert text =~ "Artists: Radiohead"
|
||||
assert text =~ "Genres: alternative rock, art rock, experimental"
|
||||
assert text =~ "Released: 1997"
|
||||
assert text =~ "Type: Album"
|
||||
end
|
||||
|
||||
test "handles records with no release date" do
|
||||
record = %Record{
|
||||
title: "Unknown Album",
|
||||
artists: [
|
||||
%{
|
||||
name: "Artist",
|
||||
sort_name: "Artist",
|
||||
musicbrainz_id: "id",
|
||||
disambiguation: "",
|
||||
joinphrase: ""
|
||||
}
|
||||
],
|
||||
genres: ["rock"],
|
||||
release_date: nil,
|
||||
type: :album
|
||||
}
|
||||
|
||||
text = Similarity.text_representation(record)
|
||||
|
||||
assert text =~ "Released: Unknown"
|
||||
end
|
||||
|
||||
test "handles different record types" do
|
||||
record = %Record{
|
||||
title: "Live at Budokan",
|
||||
artists: [
|
||||
%{
|
||||
name: "Cheap Trick",
|
||||
sort_name: "Cheap Trick",
|
||||
musicbrainz_id: "id",
|
||||
disambiguation: "",
|
||||
joinphrase: ""
|
||||
}
|
||||
],
|
||||
genres: ["rock"],
|
||||
release_date: "1979",
|
||||
type: :live
|
||||
}
|
||||
|
||||
text = Similarity.text_representation(record)
|
||||
|
||||
assert text =~ "Type: Live"
|
||||
end
|
||||
|
||||
test "handles multiple artists" do
|
||||
record = %Record{
|
||||
title: "Collaboration Album",
|
||||
artists: [
|
||||
%{
|
||||
name: "Artist One",
|
||||
sort_name: "One, Artist",
|
||||
musicbrainz_id: "id1",
|
||||
disambiguation: "",
|
||||
joinphrase: " & "
|
||||
},
|
||||
%{
|
||||
name: "Artist Two",
|
||||
sort_name: "Two, Artist",
|
||||
musicbrainz_id: "id2",
|
||||
disambiguation: "",
|
||||
joinphrase: ""
|
||||
}
|
||||
],
|
||||
genres: ["electronic"],
|
||||
release_date: "2020-01-01",
|
||||
type: :album
|
||||
}
|
||||
|
||||
text = Similarity.text_representation(record)
|
||||
|
||||
# Record.artist_names joins with ", " not with joinphrase
|
||||
assert text =~ "Artists: Artist One, Artist Two"
|
||||
end
|
||||
end
|
||||
|
||||
describe "cosine_similarity/2" do
|
||||
test "calculates similarity between identical vectors" do
|
||||
vec = [1.0, 2.0, 3.0, 4.0]
|
||||
similarity = Similarity.cosine_similarity(vec, vec)
|
||||
|
||||
assert_in_delta similarity, 1.0, 0.0001
|
||||
end
|
||||
|
||||
test "calculates similarity between orthogonal vectors" do
|
||||
vec_a = [1.0, 0.0, 0.0]
|
||||
vec_b = [0.0, 1.0, 0.0]
|
||||
similarity = Similarity.cosine_similarity(vec_a, vec_b)
|
||||
|
||||
assert_in_delta similarity, 0.0, 0.0001
|
||||
end
|
||||
|
||||
test "calculates similarity between opposite vectors" do
|
||||
vec_a = [1.0, 0.0, 0.0]
|
||||
vec_b = [-1.0, 0.0, 0.0]
|
||||
similarity = Similarity.cosine_similarity(vec_a, vec_b)
|
||||
|
||||
assert_in_delta similarity, -1.0, 0.0001
|
||||
end
|
||||
|
||||
test "calculates similarity between similar vectors" do
|
||||
vec_a = [1.0, 2.0, 3.0]
|
||||
vec_b = [1.1, 2.1, 2.9]
|
||||
similarity = Similarity.cosine_similarity(vec_a, vec_b)
|
||||
|
||||
# Should be close to 1.0 since vectors are similar
|
||||
assert similarity > 0.99
|
||||
end
|
||||
|
||||
test "raises error for vectors of different lengths" do
|
||||
vec_a = [1.0, 2.0, 3.0]
|
||||
vec_b = [1.0, 2.0]
|
||||
|
||||
assert_raise ArgumentError, fn ->
|
||||
Similarity.cosine_similarity(vec_a, vec_b)
|
||||
end
|
||||
end
|
||||
|
||||
test "handles zero vectors" do
|
||||
vec_a = [0.0, 0.0, 0.0]
|
||||
vec_b = [1.0, 2.0, 3.0]
|
||||
similarity = Similarity.cosine_similarity(vec_a, vec_b)
|
||||
|
||||
assert similarity == 0.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "store_embedding/3 and get_embedding/1" do
|
||||
test "stores and retrieves an embedding" do
|
||||
record = record()
|
||||
embedding = Enum.map(1..1536, fn _ -> :rand.uniform() end)
|
||||
text_rep = "Test representation"
|
||||
|
||||
assert {:ok, _} = Similarity.store_embedding(record.id, embedding, text_rep)
|
||||
assert {:ok, retrieved_embedding} = Similarity.get_embedding(record.id)
|
||||
|
||||
assert length(retrieved_embedding) == 1536
|
||||
# Check that embeddings are the same (within floating point precision)
|
||||
Enum.zip(embedding, retrieved_embedding)
|
||||
|> Enum.each(fn {a, b} -> assert_in_delta a, b, 0.0001 end)
|
||||
end
|
||||
|
||||
test "updates existing embedding on conflict" do
|
||||
record = record()
|
||||
embedding1 = Enum.map(1..1536, fn _ -> 0.5 end)
|
||||
embedding2 = Enum.map(1..1536, fn _ -> 0.7 end)
|
||||
|
||||
assert {:ok, _} = Similarity.store_embedding(record.id, embedding1, "Text 1")
|
||||
assert {:ok, _} = Similarity.store_embedding(record.id, embedding2, "Text 2")
|
||||
|
||||
assert {:ok, retrieved_embedding} = Similarity.get_embedding(record.id)
|
||||
assert List.first(retrieved_embedding) == 0.7
|
||||
end
|
||||
|
||||
test "returns error for non-existent record" do
|
||||
assert {:error, :not_found} = Similarity.get_embedding(Ecto.UUID.generate())
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_similar/2" do
|
||||
setup do
|
||||
# Create test records with embeddings
|
||||
record1 = record(%{title: "Rock Album 1", genres: ["rock", "alternative"]})
|
||||
record2 = record(%{title: "Rock Album 2", genres: ["rock", "indie"]})
|
||||
record3 = record(%{title: "Jazz Album", genres: ["jazz", "fusion"]})
|
||||
|
||||
# Create similar embeddings for rock albums and different for jazz
|
||||
rock_embedding_base = Enum.map(1..1536, fn i -> if i <= 10, do: 1.0, else: 0.0 end)
|
||||
jazz_embedding = Enum.map(1..1536, fn i -> if i > 1526, do: 1.0, else: 0.0 end)
|
||||
|
||||
# Slight variation for record2
|
||||
rock_embedding2 = List.update_at(rock_embedding_base, 5, fn _ -> 0.9 end)
|
||||
|
||||
Similarity.store_embedding(record1.id, rock_embedding_base, "Rock 1")
|
||||
Similarity.store_embedding(record2.id, rock_embedding2, "Rock 2")
|
||||
Similarity.store_embedding(record3.id, jazz_embedding, "Jazz")
|
||||
|
||||
%{record1: record1, record2: record2, record3: record3}
|
||||
end
|
||||
|
||||
test "finds similar records", %{record1: record1, record2: record2} do
|
||||
similar = Similarity.find_similar(record1.id, limit: 5)
|
||||
|
||||
assert length(similar) >= 1
|
||||
# record2 should be most similar to record1
|
||||
{first_record, similarity} = List.first(similar)
|
||||
assert first_record.id == record2.id
|
||||
assert similarity > 0.9
|
||||
end
|
||||
|
||||
test "respects limit option", %{record1: record1} do
|
||||
similar = Similarity.find_similar(record1.id, limit: 1)
|
||||
|
||||
assert length(similar) == 1
|
||||
end
|
||||
|
||||
test "respects min_similarity option", %{record1: record1} do
|
||||
similar = Similarity.find_similar(record1.id, min_similarity: 0.99)
|
||||
|
||||
# Since we have slight variations, only very similar records pass
|
||||
assert length(similar) <= 1
|
||||
end
|
||||
|
||||
test "returns empty list for record without embedding" do
|
||||
record_without_embedding = record()
|
||||
|
||||
similar = Similarity.find_similar(record_without_embedding.id)
|
||||
|
||||
assert similar == []
|
||||
end
|
||||
|
||||
test "filters by collection scope", %{record1: record1, record2: record2} do
|
||||
# Mark record2 as purchased
|
||||
{:ok, _} = MusicLibrary.Records.update_record(record2, %{purchased_at: DateTime.utc_now()})
|
||||
|
||||
similar = Similarity.find_similar(record1.id, scope: :wishlist)
|
||||
|
||||
# Should not include record2 since it's in collection
|
||||
record_ids = Enum.map(similar, fn {record, _} -> record.id end)
|
||||
refute record2.id in record_ids
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user