From b396dbe0370998998711bb8dbe8b7afcf0c64461 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Sat, 2 May 2026 19:14:25 +0100 Subject: [PATCH] ML-156: plan revisions --- ...tion-context-to-LLM-for-collection-chat.md | 471 ++++++++++++++---- 1 file changed, 381 insertions(+), 90 deletions(-) diff --git a/backlog/tasks/ml-156 - Explore-alternatives-to-reduce-token-usage-when-providing-collection-context-to-LLM-for-collection-chat.md b/backlog/tasks/ml-156 - Explore-alternatives-to-reduce-token-usage-when-providing-collection-context-to-LLM-for-collection-chat.md index 402cb386..861e7e17 100644 --- a/backlog/tasks/ml-156 - Explore-alternatives-to-reduce-token-usage-when-providing-collection-context-to-LLM-for-collection-chat.md +++ b/backlog/tasks/ml-156 - Explore-alternatives-to-reduce-token-usage-when-providing-collection-context-to-LLM-for-collection-chat.md @@ -6,7 +6,7 @@ title: >- status: To Do assignee: [] created_date: '2026-05-02 16:02' -updated_date: '2026-05-02 16:13' +updated_date: '2026-05-02 18:10' labels: - chat - collection @@ -54,7 +54,7 @@ The `Collection.collection_summary/0` function loads ALL records from the DB, fo ## Implementation Plan -## Approach: OpenAI `file_search` tool +## Approach: OpenAI `file_search` tool + Oban-managed file lifecycle Upload the collection catalog as a file to OpenAI, create a vector store, and use the Responses API's built-in `file_search` tool. OpenAI automatically performs semantic search over the file and includes relevant results inline in the response stream — no SSE event handling changes, no orchestration loop. @@ -62,160 +62,451 @@ Token savings: ~9,000 → ~100 tokens per chat (99% reduction). File search resu Research and alternative analysis: see [ML-156 Research document](backlog://document/doc-1). +### Architecture decisions + +1. **Chat sessions never upload or refresh files.** They read `vector_store_id` from `Secrets`. If missing, they fall back to stats-only instructions (no errors surfaced to the user). +2. **File upload and refresh happen exclusively in Oban workers.** No `Task.start`, no `start_async` — all OpenAI API calls for file management are serialized through a unique Oban worker with a 5-minute debounce window. +3. **Refresh is triggered at the lowest level.** `Records.create_record/1`, `Records.update_record/2`, and `Records.delete_record/1` enqueue the refresh worker on success. This catches all mutation paths (Index, Show, cart import, barcode scan, batch operations) without PubSub or LiveView-specific hooks. +4. **The full `collection_summary/0` (catalog + stats) is eliminated from the LiveView page load.** `CollectionLive.Index.mount/3` loads only a lightweight `stats_summary/0` — the same aggregated stats (record count, artist count, genre/format/era breakdowns) but without the per-record catalog lines. The full catalog is only loaded inside the Oban worker when a file upload/refresh is needed. Stats are ~200 tokens, always included in instructions, giving the LLM high-level collection awareness even before file search. + --- -### Phase 1: OpenAI API extensions +### Phase 1: OpenAI API extensions (7 new endpoints) Add to `OpenAI.API` (following existing `new_request/1` pattern with `Req.RateLimiter` on `:open_ai` bucket): -- `upload_file(file_content, config)` — `POST /v1/files` with multipart body, `purpose: "assistants"` -- `create_vector_store(name, config)` — `POST /v1/vector_stores` -- `add_file_to_vector_store(store_id, file_id, config)` — `POST /v1/vector_stores/{id}/files` -- `delete_file(file_id, config)` — `DELETE /v1/files/{id}` (cleanup on re-upload) +- `upload_file(file_content, filename, config)` — `POST /v1/files` with multipart body (`purpose: "assistants"`, `file` field containing the catalog text). Uses `Req` multipart support. +- `create_vector_store(name, config)` — `POST /v1/vector_stores` with `%{name: name}` body. +- `add_file_to_vector_store(store_id, file_id, config)` — `POST /v1/vector_stores/{store_id}/files` with `%{file_id: file_id}` body. +- `remove_file_from_vector_store(store_id, file_id, config)` — `DELETE /v1/vector_stores/{store_id}/files/{file_id}`. Detaches the old file from the vector store before deleting. +- `delete_file(file_id, config)` — `DELETE /v1/files/{file_id}`. Called after detaching from vector store. +- `get_file(file_id, config)` — `GET /v1/files/{file_id}`. Returns file metadata including `status` field (`uploaded`, `processed`, `error`). Used to poll indexing completion after attach. +- `list_files(config)` — `GET /v1/files`. Returns paginated list of files. Used by the periodic cleanup worker to find orphaned resources. -~60 lines. +~90 lines. -### Phase 2: File management module +--- + +### Phase 2: FileStore module Create `MusicLibrary.Chats.CollectionChat.FileStore`: -``` +```elixir defmodule MusicLibrary.Chats.CollectionChat.FileStore do @moduledoc """ Manages the collection catalog file lifecycle at OpenAI. Persists file_id and vector_store_id via Secrets. + + All OpenAI API calls happen inside Oban workers — never from chat + sessions or LiveViews. The chat session path only calls get_vector_store_id/0. """ - @spec ensure_uploaded() :: {:ok, String.t()} | {:error, term()} - def ensure_uploaded do - # Check Secrets for existing file_id + vector_store_id - # If missing, call Collection.collection_summary/0 - # Upload to OpenAI, create vector store, attach file - # Persist IDs via Secrets.store/2 - # Return {:ok, vector_store_id} - end - - @spec refresh() :: :ok | {:error, term()} - def refresh do - # Delete old file from OpenAI (if exists) - # Regenerate summary, upload new file - # Attach to existing vector store (re-indexes automatically) + @spec upload_or_refresh() :: :ok | {:error, term()} + def upload_or_refresh do + # 1. Check Secrets for existing file_id + vector_store_id + # 2. If missing → initial upload flow: + # a. Call Collection.collection_summary/0 to get catalog text + # b. POST /v1/files → file_id + # c. POST /v1/vector_stores → vector_store_id + # d. POST /v1/vector_stores/{id}/files → attach + # e. Poll GET /v1/files/{file_id} until status == "processed" + # (max 10 attempts, 2s backoff) + # f. Persist both IDs via Secrets.store/2 + # 3. If present → refresh flow: + # a. Call Collection.collection_summary/0 + # b. POST /v1/files → new_file_id + # c. POST /v1/vector_stores/{store_id}/files → attach new file + # d. Poll GET /v1/files/{new_file_id} until status == "processed" + # e. DELETE /v1/vector_stores/{store_id}/files/{old_file_id} → detach old + # f. DELETE /v1/files/{old_file_id} → delete old file + # g. Update file_id in Secrets (vector_store_id unchanged) + # 4. Log errors at each step; return :ok or {:error, reason} end @spec get_vector_store_id() :: {:ok, String.t()} | {:error, :not_uploaded} def get_vector_store_id do # Read vector_store_id from Secrets + # Return {:ok, id} or {:error, :not_uploaded} + end + + @spec cleanup_orphaned_files() :: :ok + def cleanup_orphaned_files do + # Called by periodic cleanup worker + # 1. Read known file_id + vector_store_id from Secrets + # 2. Call GET /v1/files to list all files + # 3. Delete any file whose id doesn't match the known file_id + # (with safety check: also detach from any vector store first) + # 4. Log orphan count, return :ok end end ``` -- `ensure_uploaded/0` — lazy init on first chat; idempotent -- `refresh/0` — called when records are added/edited/deleted (async, non-blocking) -- `get_vector_store_id/0` — reads from `Secrets`; returns error if never uploaded +Key design properties: +- `upload_or_refresh/0` is the single entry point called by the Oban worker — never by chat sessions +- `get_vector_store_id/0` is the single entry point called by chat sessions — read-only, no side effects +- `cleanup_orphaned_files/0` is a safety net for edge cases (e.g., Secrets write failed after OpenAI upload succeeded) -~50 lines. +~70 lines. -### Phase 3: Chat streaming changes +--- -**`OpenAI.chat_stream/2`** — add optional `vector_store_ids` option: +### Phase 3: Oban workers + +#### 3a. RefreshCollectionFile worker + +Create `MusicLibrary.Worker.RefreshCollectionFile`: ```elixir -def chat_stream(messages, opts) do +defmodule MusicLibrary.Worker.RefreshCollectionFile do + use Oban.Worker, + queue: :default, + unique: [period: 300, keys: [:worker]], + max_attempts: 3 + + @impl true + def perform(_job) do + case MusicLibrary.Chats.CollectionChat.FileStore.upload_or_refresh() do + :ok -> :ok + {:error, reason} -> {:error, reason} + end + end +end +``` + +- `unique: [period: 300, keys: [:worker]]` — 5-minute debounce window. Only one refresh can be executing or scheduled at a time. Rapid record mutations (cart import of 50 records) each call `Oban.insert` but only the first insert succeeds; subsequent inserts within the 300s unique window are silently ignored by Oban. +- `max_attempts: 3` — transient OpenAI failures get 2 retries. +- Queue `:default` (concurrency 10) — the worker is fast (a few API calls), no need for a dedicated queue. + +Enqueued from two places: +1. **Record mutations** — `Records.create_record/1`, `update_record/2`, `delete_record/1` (see Phase 4) +2. **Cron** — every hour in both dev and prod, to handle initial upload after deploy and recovery after failures: + +```elixir +# config/prod.exs (add to crontab) +{"0 * * * *", MusicLibrary.Worker.RefreshCollectionFile} + +# config/dev.exs (add to crontab) +{"*/15 * * * *", MusicLibrary.Worker.RefreshCollectionFile} +``` + +~15 lines. + +#### 3b. CleanupOrphanedOpenAIFiles worker + +Create `MusicLibrary.Worker.CleanupOrphanedOpenAIFiles`: + +- Cron: monthly (1st of month, 11 AM) +- Calls `FileStore.cleanup_orphaned_files/0` +- Queue: `:default` +- `max_attempts: 2` + +~12 lines. + +--- + +### Phase 4: Records context + Collection context + LiveView changes + +#### 4a. Records context — enqueue refresh on mutations + +In `lib/music_library/records.ex`, after each successful mutation, enqueue the refresh worker **outside** the `with` chain (non-blocking — the mutation succeeds regardless of enqueue result): + +```elixir +def create_record(attrs \\ %{}) do + with {:ok, record} <- do_create_record(attrs), + record = Enrichment.best_effort_extract_colors(record), + :ok <- refresh_artist_info_async(record) do + enqueue_collection_file_refresh() + {:ok, record} + end +end + +def update_record(%Record{} = record, attrs) do + with {:ok, updated_record} <- do_update_record(record, attrs), + :ok <- refresh_artist_info_async(updated_record) do + enqueue_collection_file_refresh() + {:ok, updated_record} + end +end + +def delete_record(%Record{} = record) do + with {:ok, record} <- Repo.delete(record) do + record + |> Record.artist_ids() + |> Enum.each(fn artist_id -> + Artists.prune_artist_info_async(artist_id) + end) + + enqueue_collection_file_refresh() + {:ok, record} + end +end + +defp enqueue_collection_file_refresh do + %{} |> MusicLibrary.Worker.RefreshCollectionFile.new() |> Oban.insert() +end +``` + +- `Oban.insert/1` (not `insert!`) — failures to enqueue are silently ignored; the hourly cron acts as a safety net. +- The enqueue happens after the `with` succeeds, ensuring we only refresh when data actually changed. +- All mutation paths are covered: single create (AddRecord, barcode scan), cart import (batch creates via `ImportFromMusicbrainzReleaseGroup` worker, which calls `create_record/1` internally), edit (Index and Show), delete. + +~15 lines. + +#### 4b. Collection context — add `stats_summary/0` and `count_records/0` + +Add to `lib/music_library/collection.ex`: + +```elixir +@spec count_records() :: non_neg_integer() +def count_records do + from(r in Record, where: not is_nil(r.purchased_at)) + |> Repo.aggregate(:count) +end + +@spec stats_summary() :: {String.t(), non_neg_integer()} +def stats_summary do + # Same record load as collection_summary/0 (the heavy SQL query), + # but only computes the stats header — NO per-record catalog formatting. + # Returns {stats_string, group_count}. + # Example stats_string: + # "# Stats: 500 releases, 200 artists\nGenres: rock 150, ...\nFormats: ...\nEras: ..." + records = from(r in Record, where: not is_nil(r.purchased_at), select: ^essential_fields()) |> Repo.all() + groups = records |> Enum.group_by(& &1.musicbrainz_id) + stats = build_stats(records, length(groups)) + {stats, length(groups)} +end +``` + +~10 lines. `collection_summary/0` remains in the module — it's now only called by `FileStore.upload_or_refresh/0` (inside the Oban worker) to generate the catalog text for file upload. `stats_summary/0` extracts the lightweight stats portion for the LiveView to load (still a full table scan, but skips the expensive per-record string formatting — ~200 tokens of output vs ~9,000). + +#### 4c. CollectionLive.Index — load stats instead of full catalog + +Replace the heavy `start_async(:collection_summary, &Collection.collection_summary/0)` with `stats_summary`: + +```elixir +# In mount/3: +|> assign(:collection_stats, {"", 0}) +|> start_async(:collection_stats, fn -> Collection.stats_summary() end) + +# Rename handle_async: +def handle_async(:collection_stats, {:ok, stats}, socket) do + {:noreply, assign(socket, :collection_stats, stats)} +end + +def handle_async(:collection_stats, {:exit, _reason}, socket) do + {:noreply, socket} +end + +# In the template — pass stats as chat_context: +chat_context={@collection_stats} +``` + +The `@collection_summary` assign is removed. The Chat component receives `{stats_string, record_count}`, matching the existing tuple shape so no Chat component changes are needed. + +~8 lines (mostly renames). + +--- + +### Phase 5: Chat streaming changes + +#### 5a. OpenAI facade — accept `:vector_store_ids` option + +Modify `OpenAI.chat_stream/2` to pass tools to the API layer: + +```elixir +def chat_stream(messages, opts) when is_list(messages) do model = Keyword.get(opts, :model, "gpt-4.1") + temperature = Keyword.get(opts, :temperature, 0.7) + instructions = Keyword.get(opts, :instructions, "") + on_chunk = Keyword.fetch!(opts, :on_chunk) vector_store_ids = Keyword.get(opts, :vector_store_ids, []) - - tools = [%{type: "web_search_preview"}] - tools = if vector_store_ids != [], - do: [%{type: "file_search", vector_store_ids: vector_store_ids} | tools], - else: tools - - # ... rest unchanged + + tools = build_tools(vector_store_ids) + + API.chat_stream(messages, instructions, model, temperature, open_ai_config(), on_chunk, tools) +end + +defp build_tools([]), do: [%{type: "web_search_preview"}] +defp build_tools(ids) when is_list(ids) do + [%{type: "file_search", vector_store_ids: ids}, %{type: "web_search_preview"}] end ``` -**`OpenAI.API.chat_stream/6`** — accept tools as parameter instead of hardcoding (or add `tools` parameter): +~10 lines. + +#### 5b. OpenAI.API — accept optional tools parameter + +Add a 7-arity overload to `API.chat_stream`: ```elixir -def chat_stream(messages, instructions, model, temperature, config, cb, tools \\ nil) do - tools = tools || [%{type: "web_search_preview"}] - # ... use tools in json body +def chat_stream(messages, instructions, model, temperature, config, cb) do + chat_stream(messages, instructions, model, temperature, config, cb, [%{type: "web_search_preview"}]) +end + +def chat_stream(messages, instructions, model, temperature, config, cb, tools) when is_list(tools) do + config + |> new_request() + |> Req.merge( + url: "/v1/responses", + receive_timeout: 60_000, + connect_options: [timeout: 5_000], + json: %{ + model: model, + instructions: instructions, + input: messages, + tools: tools, + stream: true, + temperature: temperature + }, + into: :self + ) + |> do_chat_stream(cb) end ``` -**`CollectionChat.stream_response/3`:** +- Uses function clause pattern matching (`when is_list(tools)`) rather than default argument with `||`, avoiding the falsy-empty-list pitfall. +- The 6-arity version is preserved for backward compatibility (existing RecordChat and ArtistChat callers). + +~5 lines. + +#### 5c. CollectionChat — new instructions and streaming logic + +Update `CollectionChat`: ```elixir -def stream_response(messages, {_summary, record_count}, callback) do - instructions = build_instructions(record_count) - - vector_store_opts = case FileStore.get_vector_store_id() do - {:ok, id} -> [vector_store_ids: [id]] - {:error, _} -> [] # fall back to stats-only - end - +@impl true +def stream_response(messages, {stats, record_count}, callback) do + instructions = build_instructions(stats, record_count) + + vector_store_opts = + case FileStore.get_vector_store_id() do + {:ok, id} -> [vector_store_ids: [id]] + {:error, :not_uploaded} -> [] # fall back to stats-only + end + OpenAI.chat_stream(messages, [ on_chunk: callback, instructions: instructions, model: "gpt-5.1" ] ++ vector_store_opts) end -``` -~15 lines. - -### Phase 4: Prompt changes - -Update `CollectionChat.build_instructions/2`: - -```elixir -defp build_instructions(record_count) do +defp build_instructions(stats, record_count) do Prompt.build(""" Answer questions about the user's music collection. - The collection contains #{record_count} records. - Use file search to find specific records when the user asks about \ + Use the provided stats and file search to answer questions + about the collection. + + #{stats} + + The collection contains #{record_count} records in total. + + Use file search to find specific records when the user asks about artists, albums, genres, or formats in their collection. + If file search returns no results for a query, say so honestly — + don't guess or invent records. If you're unsure whether a record + exists, mention that it wasn't found in the collection. + # Mentioning artists/albums - **IF YOU MENTION AN ARTIST NAME OR ALBUM NAME, wrap it in "[[name]]", \ + **IF YOU MENTION AN ARTIST NAME OR ALBUM NAME, wrap it in "[[name]]", for example "[[Steven Wilson]]" """) end ``` -Key changes: -- Remove `#{collection_summary}` interpolation entirely -- Remove `Collection catalog:` section -- Remove `Use the provided collection catalog as your primary reference` -- Add guidance to use file search for specific record lookup -- `stream_response/3` signature changes from `{summary, count}` to just `count` (summary only used for stats, which we no longer pass) +Key changes from current code: +- `stream_response/3` keeps the `{tuple, count}` signature shape — the stats string replaces the catalog text +- `build_instructions/2` interpolates aggregated stats (~200 tokens) instead of the full catalog (~9,000 tokens) +- `FileStore.get_vector_store_id/0` is the only FileStore function called from the chat path +- On `{:error, :not_uploaded}`, vector_store_opts is empty → `build_tools([])` returns only `web_search_preview` → stats-only fallback (still useful for aggregate questions) +- Added explicit guidance for when file search returns no results +- The "Collection catalog:" section is removed; replaced with stats inline -~10 lines. +~18 lines (modifications to existing code). -### Phase 5: Trigger refresh on collection changes - -In `CollectionLive.Index`, handle record add/edit/delete events: - -```elixir -# In handle_info for RecordForm saved / AddRecord imported / delete: -def handle_info({MusicLibraryWeb.Components.RecordForm, {:saved, _record}}, socket) do - Task.start(&MusicLibrary.Chats.CollectionChat.FileStore.refresh/0) - IndexActions.handle_record_saved(socket) -end -``` - -Or use the existing PubSub topic `"records:#{id}"` to trigger refresh from a central place. Debounce rapid changes (multiple quick adds) with a short timer. - -~20 lines. +--- ### Phase 6: Testing -- `test/open_ai/api_test.exs` — test `upload_file`, `create_vector_store`, `add_file_to_vector_store`, `delete_file` endpoints via `Req.Test` stubs -- `test/music_library/chats/collection_chat/file_store_test.exs` — test `ensure_uploaded` (first-time upload), `refresh` (re-upload), `get_vector_store_id` (missing, present), and `Secrets` persistence -- `test/music_library/chats/collection_chat_test.exs` — verify instructions no longer contain catalog; verify `file_search` tool included when vector store is available; verify fallback to stats-only when unavailable -- `test/music_library/collection_test.exs` — existing `collection_summary/0` tests continue to pass unchanged +#### 6a. OpenAI.API endpoint tests (`test/open_ai/api_test.exs`) -Estimated total: ~150 lines across 5-6 files. Low risk — no changes to core streaming infrastructure. +Add test blocks for the 7 new endpoints, following existing `Req.Test.stub` patterns: +- `upload_file/3` — verify multipart body includes `purpose: "assistants"` and file content; test success (200 + file JSON) and error (500) responses +- `create_vector_store/2` — verify request body has `name` field; test success and error +- `add_file_to_vector_store/3` — verify URL path includes store_id; test success and error +- `remove_file_from_vector_store/3` — verify DELETE request; test 200 and 404 (already removed) +- `delete_file/2` — verify DELETE request; test success +- `get_file/2` — test `status: "processed"` (success), `status: "uploaded"` (still processing), and error +- `list_files/1` — test paginated response with file list +- Rate-limit error handling (429 → `ErrorResponse` with `:rate_limit` kind) for each endpoint + +~80 lines. + +#### 6b. FileStore tests (`test/music_library/chats/collection_chat/file_store_test.exs`) + +Test `FileStore` functions with `Req.Test` stubs and direct `Secrets` manipulation: +- `upload_or_refresh/0` — first-time upload (no secrets → creates file + store + attaches → secrets populated) +- `upload_or_refresh/0` — refresh (secrets present → uploads new file → attaches → detaches old → deletes old → updates file_id in secrets) +- `upload_or_refresh/0` — handles file still "uploaded" (polls until "processed") +- `upload_or_refresh/0` — handles OpenAI API errors gracefully (returns `{:error, ...}`) +- `get_vector_store_id/0` — returns `{:ok, id}` when secret exists +- `get_vector_store_id/0` — returns `{:error, :not_uploaded}` when secret missing +- `cleanup_orphaned_files/0` — deletes files not matching known file_id; skips known file + +~70 lines. + +#### 6c. CollectionChat tests (`test/music_library/chats/collection_chat_test.exs`) + +Update existing tests and add new ones: +- Verify instructions **no longer** contain the per-record catalog lines (the key regression test) +- Verify instructions **do** contain the aggregated stats string and record count +- Verify `file_search` tool is included in the API request body when vector store is available (stub `FileStore.get_vector_store_id/0` → `{:ok, "vs_test"}` and assert `tools` array in the JSON body contains `%{"type" => "file_search", "vector_store_ids" => ["vs_test"]}`) +- Verify `file_search` tool is **not** included when vector store is unavailable (stub → `{:error, :not_uploaded}`, assert tools only has `web_search_preview`) +- Verify `web_search_preview` is always included regardless of file_search presence +- Test empty collection edge case (stats = "", record_count = 0) +- Test SSE event handling: include `response.file_search_call.in_progress`, `response.file_search_call.searching`, and `response.file_search_call.completed` events in the test stream fixture to verify they're silently consumed (caught by the `"response." <> _` catch-all) without breaking the stream + +~60 lines (modifications to existing + new tests). + +#### 6d. Worker tests (`test/music_library/worker/refresh_collection_file_test.exs`) + +- `perform/1` delegates to `FileStore.upload_or_refresh/0` and returns `:ok` +- `perform/1` returns `{:error, reason}` on failure +- Unique constraint: inserting two jobs within 300s results in only one executing (test with `Oban.insert/1` returning `{:ok, _}` for first and `{:ok, %{conflict?: true}}` for second) + +~20 lines. + +#### 6e. Records context integration tests + +In existing `test/music_library/records_test.exs` (or equivalent): +- `create_record/1` enqueues `RefreshCollectionFile` worker +- `update_record/2` enqueues `RefreshCollectionFile` worker +- `delete_record/1` enqueues `RefreshCollectionFile` worker +- Verify with `assert_enqueued` (Oban testing helper) + +~15 lines. + +#### 6f. LiveView test + +In `test/music_library_web/live/collection_live/index_test.exs` (or equivalent): +- Verify `@collection_stats` is set after mount (not `@collection_summary`) +- Verify Chat component receives `{stats_string, count}` tuple as `chat_context` + +~8 lines (modifications to existing test). + +#### 6g. Collection context tests + +In existing `test/music_library/collection_test.exs` (or equivalent): +- `stats_summary/0` returns stats string with genre/format/era breakdowns and correct group count +- `stats_summary/0` returns `{"", 0}` for empty collection +- `count_records/0` returns correct count +- Existing `collection_summary/0` tests continue to pass unchanged + +~20 lines. + +Estimated total: ~510 lines across 13-15 files. Risk: low/medium — the core streaming infrastructure (`decode_responses_event/2`, Chat component `do_send_message/2`, SSE event loop) is untouched. New code is additive (API endpoints, Oban worker, FileStore module) and follows existing patterns. ## Definition of Done