Add similar records

Initial stab by Claude
This commit is contained in:
Claudio Ortolina
2025-10-11 21:39:59 +02:00
parent 07218d16ca
commit 9cae972bc5
18 changed files with 1869 additions and 5 deletions
+3 -1
View File
@@ -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
+10
View File
@@ -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
+179
View File
@@ -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
+4
View File
@@ -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)
+10
View File
@@ -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 ""
+10
View File
@@ -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