--- id: ML-156 title: >- Explore alternatives to reduce token usage when providing collection context to LLM for collection chat status: To Do assignee: [] created_date: "2026-05-02 16:02" updated_date: "2026-05-22 22:40" labels: - ready dependencies: [] references: - "backlog://document/doc-1" documentation: - lib/music_library/chats/collection_chat.ex - lib/music_library/collection.ex - lib/music_library/chats/prompt.ex - lib/music_library/chats/stream_provider.ex - lib/open_ai/api.ex - lib/music_library_web/components/chat.ex - lib/music_library_web/live/collection_live/index.ex - docs/architecture.md - docs/production-infrastructure.md priority: high --- ## Description Currently, every new collection chat sends the ENTIRE collection catalog (all records formatted as "Artist - Title (year, format) [genres]") plus aggregated stats as the `instructions` parameter to the OpenAI Responses API. For a collection of 500+ records, this burns ~9,000+ input tokens on EVERY new chat start — regardless of what the user asks. The goal of this task is to analyze alternatives, pick the best one, and implement it. The selected approach should: - Significantly reduce per-chat token usage - Preserve or improve the quality of LLM responses about the collection - Not require architectural overhauls beyond the chat/streaming layer The `Collection.collection_summary/0` function loads ALL records from the DB, formats them, and returns `{summary, count}`. This is computed asynchronously in `CollectionLive.Index.mount/3` and passed to the Chat component as `chat_context`. `CollectionChat.build_instructions/2` then embeds the full summary into the instructions string sent to OpenAI. ## Acceptance Criteria - [ ] #1 `CollectionChat.build_instructions/2` no longer interpolates the full collection catalog into the instructions sent to OpenAI — only aggregated stats and a record count are included - [ ] #2 A `file_search` tool with the collection's vector store ID is included in every collection chat request to the OpenAI Responses API - [ ] #3 A `CollectionChat.FileStore` module manages the collection file lifecycle: upload to OpenAI Files API, vector store creation, and file-to-store attachment, persisting IDs via `Secrets` - [ ] #4 When a record is added, edited, or deleted, the collection file at OpenAI is refreshed (async, non-blocking) so the LLM always searches up-to-date data - [ ] #5 If the file/vector store is unavailable (upload failed, first deploy), the chat falls back to stats-only instructions without errors - [ ] #6 Existing record and artist chats continue to work without changes (no regression in streaming) - [ ] #7 Tests cover: Files API endpoints, file upload/create/refresh lifecycle, `file_search` tool inclusion in chat requests, fallback when file store is unavailable, empty collection edge case - [ ] #8 Per-chat input tokens for the collection chat instructions are O(1) relative to collection size (the file is searched by OpenAI on demand, not embedded in instructions) - [ ] #9 Refresh scheduling uses a true debounce/coalescing strategy so rapid/bulk mutations and mutations during an executing refresh cannot leave the active file stale - [ ] #10 FileStore only switches the active vector store after vector-store-file indexing succeeds; failed refreshes keep the previous active store available - [ ] #11 Production documentation includes OpenAI file_search/vector-store cost profile, initial refresh behavior, cleanup/rollback notes, and whether new environment variables are required ## Implementation Plan ## Approach: OpenAI `file_search` tool + Oban-managed collection catalog lifecycle Upload the collection catalog to OpenAI, index it in a vector store, and include the Responses API `file_search` tool in collection chat requests. Keep only aggregated stats and record count in the prompt instructions. This makes per-chat instructions O(1) relative to collection size while preserving the ability to answer record-specific questions through retrieval. Research and alternative analysis: see [ML-156 Research document](backlog://document/doc-1). ### Why this approach - **Stats-only** is much simpler, but it removes record-specific collection awareness. - **Custom function calling** would avoid OpenAI file storage, but requires a streaming tool-call orchestration loop and changes shared chat streaming code for all chat types. - **Local RAG with SQLite FTS/sqlite-vec** avoids external vector-store lifecycle, but still requires retrieval orchestration before/inside every chat request and would either be mostly lexical or depend on the existing embedding pipeline being complete and fresh. It remains a good future option if OpenAI file_search quality/cost is poor. - **OpenAI file_search** gives semantic retrieval with minimal streaming changes: file-search events are `response.*` events and can be ignored by the existing catch-all while output text streams normally. ### Performance and cost profile - **Per chat:** instructions contain stats + count only (~100-300 tokens), not the full catalog (~9,000+ tokens for ~500 records). File-search result tokens are paid only when the model searches. - **Per refresh:** catalog generation, upload, and indexing are O(collection size). This is moved out of LiveView/chat paths and into debounced Oban jobs. - **Page load:** `CollectionLive.Index` loads a stats-only context, not the full catalog. Measure `stats_summary/0` once on realistic data; if it is noticeably slow, switch it from in-memory grouping to SQL aggregate queries. - **Hash skip:** store a SHA-256 hash of the uploaded catalog. Cron/manual refreshes skip upload/index work when the catalog content is unchanged. - **Benchmarks:** no recurring benchmark is required. Do one implementation-time measurement comparing before/after instruction byte/token estimates for representative collection sizes (current data, 1,000 records if easy to synthesize) and record catalog refresh runtime. - **Paid resources:** document current OpenAI pricing at implementation time in `docs/production-infrastructure.md` because prices change. Expected profile for current scale is negligible storage for a small text file/vector store plus occasional indexing after debounced mutations, and per-chat retrieval/tool charges only when file_search runs. ### Architecture decisions 1. **Chat sessions never upload or refresh files.** They only read the active vector store ID from `FileStore.get_vector_store_id/0`. Missing/unavailable store falls back to stats-only instructions without surfacing an error to the user. 2. **Use a new vector store per successful refresh.** Build and index the new file/store off to the side, then switch the active secret only after vector-store-file indexing completes. Failed refreshes leave the previous active store untouched. 3. **Persist FileStore state as one JSON secret.** Store `%{file_id, vector_store_id, catalog_hash, updated_at}` in a single encrypted `Secrets` entry to avoid partial multi-secret updates. `get_vector_store_id/0` only returns `{:ok, id}` when the state contains a usable active file and vector store. 4. **True debounce/coalescing.** Record mutations schedule a refresh for 5 minutes in the future. Additional mutations while a refresh is scheduled replace `scheduled_at`, pushing the refresh out. Mutations that happen while a worker is already executing can schedule a follow-up refresh, so no changes are dropped. 5. **Refresh triggers cover all catalog-affecting mutations.** CRUD paths enqueue refreshes, and direct enrichment `Repo.update` paths that affect catalog content (notably genres and any title/artist/release-date/format/purchased-state changes) either go through `Records.update_record/2` or explicitly enqueue on success. 6. **OpenAI API work uses an `:open_ai` Oban queue.** Add a serialized queue (`open_ai: 1`) for OpenAI-calling workers, in addition to the existing Req rate limiter. 7. **Cleanup is scoped and safe.** Only delete OpenAI files/vector stores with the ML-managed name/filename prefix, and never delete unrelated OpenAI resources. --- ### Phase 0: Baseline measurement and API documentation check Before implementing: 1. Confirm current OpenAI Responses API `file_search`, Files, and Vector Stores endpoint shapes. 2. Confirm the status values for vector-store file indexing and poll the vector-store-file endpoint, not the generic file metadata endpoint. 3. Record a one-off baseline: - current collection chat instruction byte length / estimated tokens; - expected stats-only instruction byte length / estimated tokens; - current `Collection.collection_summary/0` runtime on realistic data. Do not add a permanent benchmark unless this measurement shows refresh/page-load costs are high enough to justify ongoing tracking. --- ### Phase 1: OpenAI API extensions Add public functions to `OpenAI.API` following the existing `new_request/1` pattern, Req rate limiting, and `OpenAI.API.ErrorResponse.from_response/1` error handling: - `upload_file(file_content, filename, config)` — `POST /v1/files` with multipart body (`purpose: "assistants"`, file text content). - `create_vector_store(name, config)` — `POST /v1/vector_stores` with an ML-managed name such as `music-library-collection-chat--`. - `add_file_to_vector_store(vector_store_id, file_id, config)` — `POST /v1/vector_stores/{vector_store_id}/files`. - `get_vector_store_file(vector_store_id, file_id, config)` — `GET /v1/vector_stores/{vector_store_id}/files/{file_id}`. Use this for indexing status polling. - `delete_vector_store(vector_store_id, config)` — `DELETE /v1/vector_stores/{vector_store_id}` for cleanup/rollback. - `delete_file(file_id, config)` — `DELETE /v1/files/{file_id}`. - `list_files(config)` — `GET /v1/files`, used by scoped cleanup. - `list_vector_stores(config)` — `GET /v1/vector_stores`, used by scoped cleanup. - Add `remove_file_from_vector_store/3` only if the confirmed API flow requires detach without deleting the whole old vector store. Keep the existing 6-arity `chat_stream/6` intact and add a 7-arity `chat_stream/7` that accepts a tools list. --- ### Phase 2: Collection context changes Add lightweight collection context functions: ```elixir @spec count_records() :: non_neg_integer() def count_records @spec stats_summary() :: {String.t(), non_neg_integer()} def stats_summary @spec file_search_catalog() :: {String.t(), non_neg_integer(), String.t()} def file_search_catalog ``` - `stats_summary/0` returns `{stats_text, release_group_count}` with no per-record catalog lines. Empty collection returns `{ "", 0 }`. - `file_search_catalog/0` returns deterministic text for upload plus record count and SHA-256 hash. Include the stats header and per-record lines, ordered deterministically, with only collection records (`purchased_at` not nil). - Keep any existing `collection_summary/0` tests passing if the function remains. If renamed/extracted, update tests and callers so only FileStore uses the full catalog. - The LiveView/chat path must never receive the full catalog. If the one-off measurement shows `stats_summary/0` is too expensive, compute stats with SQL aggregates rather than loading all essential fields. --- ### Phase 3: `CollectionChat.FileStore` Create `MusicLibrary.Chats.CollectionChat.FileStore` with `@moduledoc`, specs, and public functions: ```elixir @spec upload_or_refresh() :: :ok | {:error, term()} def upload_or_refresh @spec get_vector_store_id() :: {:ok, String.t()} | {:error, :not_uploaded} def get_vector_store_id @spec cleanup_orphaned_resources() :: :ok | {:error, term()} def cleanup_orphaned_resources ``` #### Active-state secret Use one encrypted secret value, JSON-encoded, for example under `collection_chat_file_store`: ```json { "file_id": "file_...", "vector_store_id": "vs_...", "catalog_hash": "sha256...", "updated_at": "2026-05-22T...Z" } ``` #### Upload/refresh flow 1. Build `{catalog_text, record_count, hash}` via `Collection.file_search_catalog/0`. 2. If `record_count == 0`: - delete old active vector store/file best-effort; - clear the active FileStore secret; - return `:ok` so collection chat falls back to stats-only. 3. Read the active state secret. 4. If active state exists and `catalog_hash == hash`, return `:ok` without OpenAI API calls. 5. Upload a new file with an ML-managed filename prefix and the hash in the name. 6. Create a new vector store with an ML-managed name prefix and the hash/timestamp in the name. 7. Attach the uploaded file to the new vector store. 8. Poll `get_vector_store_file/3` until indexing is complete. Use bounded attempts/backoff; return `{:error, reason}` for failed/cancelled/timeouts. 9. Only after indexing succeeds, persist the new active-state JSON secret. 10. Best-effort delete the previous active vector store and file. Log cleanup failures and rely on the cleanup worker to retry later. If any step before the active secret switch fails, best-effort delete the newly created resources and return `{:error, reason}`. The old active store remains available to chat users. #### Read path `get_vector_store_id/0` is read-only and returns: - `{:ok, vector_store_id}` only when the active JSON secret has both a `file_id` and `vector_store_id`; - `{:error, :not_uploaded}` otherwise. #### Cleanup flow `cleanup_orphaned_resources/0`: 1. Read the active state. 2. List OpenAI vector stores and files. 3. Select only resources with the ML-managed collection-chat prefix. 4. Delete inactive vector stores first. 5. Delete inactive files. 6. Never delete resources without the ML-managed prefix. --- ### Phase 4: Oban workers and queue config Add `:open_ai` to Oban queues in `config/config.exs`: ```elixir queues: [default: 10, heavy_writes: 1, music_brainz: 1, discogs: 1, wikipedia: 1, last_fm: 1, open_ai: 1] ``` Create `MusicLibrary.Worker.RefreshCollectionFile` as a thin wrapper: ```elixir defmodule MusicLibrary.Worker.RefreshCollectionFile do use Oban.Worker, queue: :open_ai, max_attempts: 3, unique: [fields: [:worker], states: [:scheduled], period: :infinity], replace: [scheduled: [:scheduled_at]] @impl true def perform(_job) do case MusicLibrary.Chats.CollectionChat.FileStore.upload_or_refresh() do :ok -> :ok {:error, reason} -> MusicLibrary.Worker.ErrorHandler.to_oban_result(reason) end end @spec enqueue_debounced() :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()} def enqueue_debounced do %{} |> new(schedule_in: {5, :minutes}) |> Oban.insert() end end ``` Important debounce semantics: - uniqueness checks only `:scheduled` jobs, so mutations during an executing refresh can enqueue a follow-up scheduled refresh; - `replace: [scheduled: [:scheduled_at]]` means rapid mutations push the single scheduled refresh later instead of first-writer-wins; - the hourly cron and catalog hash skip provide recovery without repeated uploads. Create `MusicLibrary.Worker.CleanupOrphanedOpenAICollectionResources`: - queue `:open_ai`; - `max_attempts: 2`; - delegates to `FileStore.cleanup_orphaned_resources/0`; - routes errors through `Worker.ErrorHandler`. Cron: - production: hourly `RefreshCollectionFile` for initial upload/recovery; monthly cleanup after existing monthly jobs; - development: every 15 minutes for refresh if desired; monthly cleanup can stay production-only unless useful in dev. --- ### Phase 5: Record mutation triggers Add a shared enqueue helper in the Records context, e.g.: ```elixir @spec enqueue_collection_file_refresh() :: :ok def enqueue_collection_file_refresh do case MusicLibrary.Worker.RefreshCollectionFile.enqueue_debounced() do {:ok, _job} -> :ok {:error, reason} -> Logger.warning("Failed to enqueue collection file refresh: #{inspect(reason)}") :ok end end ``` Use it after successful mutations, non-blocking: - `Records.create_record/1` - `Records.update_record/2` - `Records.delete_record/1` - enrichment/direct update paths that affect catalog content, especially `Records.Enrichment.populate_genres/1` - any other direct update path discovered during implementation that changes title, artists, release date, format, genres, MusicBrainz ID, or purchased status. Do not enqueue on failed changesets/API errors. It is acceptable to enqueue for some harmless non-catalog changes because the FileStore hash skip avoids unnecessary uploads. --- ### Phase 6: LiveView and collection chat changes #### CollectionLive.Index Replace full catalog loading: ```elixir |> assign(:collection_stats, {"", 0}) |> start_async(:collection_stats, fn -> Collection.stats_summary() end) ``` Pass `chat_context={@collection_stats}` to the Chat component. Remove the `@collection_summary` assign from the LiveView path. #### OpenAI facade/API Update `OpenAI.chat_stream/2` to accept `:vector_store_ids`: ```elixir vector_store_ids = Keyword.get(opts, :vector_store_ids, []) tools = build_tools(vector_store_ids) API.chat_stream(messages, instructions, model, temperature, open_ai_config(), on_chunk, tools) ``` Build tools as: - no vector store: `[%{type: "web_search_preview"}]` - vector store present: `[%{type: "file_search", vector_store_ids: ids}, %{type: "web_search_preview"}]` Preserve the existing `API.chat_stream/6` behavior for record and artist chats. #### CollectionChat `CollectionChat.stream_response/3`: 1. Build instructions from stats + count only. 2. Call `FileStore.get_vector_store_id/0`. 3. Include `vector_store_ids: [id]` only when available. 4. Fall back to stats-only instructions when not uploaded. Instructions must: - tell the model to use stats for aggregate questions; - tell the model to use file search for artists/albums/genres/formats; - tell the model not to invent records when search returns no results; - keep the existing `[[artist/album]]` linking rule. --- ### Phase 7: Tests #### OpenAI.API tests Add coverage for new endpoints: - multipart upload includes `purpose: "assistants"`, filename, and file content; - vector store creation request body/name; - file-to-vector-store attachment; - vector-store-file status polling endpoint, including completed, in-progress, and failed statuses; - delete vector store and delete file; - list files and list vector stores for cleanup; - representative 429/5xx responses return `OpenAI.API.ErrorResponse` with the expected kind. #### FileStore tests Use `Req.Test` stubs and the `Secrets` context (not raw DB writes): - first upload creates file + vector store, attaches file, polls vector-store-file until complete, and stores active JSON state; - refresh creates a new file/store and does not switch active state until indexing succeeds; - failed refresh leaves previous active state available; - unchanged catalog hash skips all OpenAI API calls; - empty collection clears/deletes active resources so old records are not searchable; - partial failures best-effort clean up newly created resources; - cleanup deletes only inactive ML-managed resources and skips unrelated OpenAI files/vector stores; - `get_vector_store_id/0` returns `{:ok, id}` only for complete active state and `{:error, :not_uploaded}` otherwise. #### Worker tests - `perform/1` delegates to FileStore and returns `:ok` on success; - structured OpenAI errors are routed through `Worker.ErrorHandler`; - debounced enqueue creates one scheduled job and later enqueue replaces `scheduled_at` rather than first-writer-wins; - a mutation can enqueue a follow-up job when another refresh job is already executing or no scheduled job exists. #### Records/enrichment tests - `create_record/1`, `update_record/2`, and `delete_record/1` enqueue `RefreshCollectionFile` via `assert_enqueued`; - failed changes do not enqueue; - catalog-affecting enrichment/direct update paths enqueue after success, especially genre population; - bulk import or repeated creates coalesce into a single scheduled refresh with the latest scheduled time. #### Collection/LiveView tests - `stats_summary/0` returns stats string and correct count; - `stats_summary/0` returns `{ "", 0 }` for empty collection; - `file_search_catalog/0` returns deterministic catalog text and hash; - `CollectionLive.Index` uses `@collection_stats`, not `@collection_summary`, and Chat receives the stats tuple. #### CollectionChat/OpenAI request tests - instructions include stats and count; - instructions do **not** include per-record catalog lines, even with records present; - active FileStore secret causes request tools to include `file_search` with the vector store ID; - missing/incomplete FileStore secret omits `file_search` and keeps `web_search_preview`; - `web_search_preview` remains present for all chat types; - SSE fixtures including `response.file_search_call.*` events stream successfully via the existing `response.*` catch-all; - existing record and artist chat request shapes remain unchanged. --- ### Phase 8: Verification Run the normal checks required by the task DoD: - `mix compile --warnings-as-errors` - `mix test` - `mix format --check-formatted` - `mix credo` Implementation-time manual verification: 1. Compare before/after collection chat instruction byte/token estimates. 2. Run one FileStore refresh against test stubs and inspect logs for successful upload/index/switch/cleanup ordering. 3. Confirm a simulated failed refresh leaves the previous vector store ID active. 4. Confirm empty collection does not leave the old catalog searchable. 5. If UI-visible code changes beyond the assign rename, use browser tooling for the collection page; otherwise no visual verification is required. --- ### Phase 9: Production and documentation No new server environment variables are expected; the implementation uses the existing `OPENAI_KEY`. Document this explicitly. Confirm the OpenAI project/key has access to Files, Vector Stores, and Responses `file_search`. Production rollout behavior: - initial upload happens through the hourly cron after deploy; - if the user wants immediate availability, ask before performing any production action, then trigger `RefreshCollectionFile` through existing admin tooling/Oban UI; - rollback/fallback is safe because collection chat works stats-only when the active FileStore secret is missing or incomplete; - cleanup worker removes stale ML-managed OpenAI files/vector stores, never unrelated resources. Update documentation: - `docs/architecture.md` — add `CollectionChat.FileStore`, OpenAI file/vector-store endpoints, `:open_ai` queue, refresh/cleanup workers, and updated collection chat data flow. - `docs/production-infrastructure.md` — add OpenAI Files/Vector Stores usage, cost profile with current pricing checked at implementation time, no-new-env-var note, initial refresh behavior, cleanup/rollback notes, and operational cautions. - Update any relevant tests/docs comments if function names change (`collection_summary/0`, `stats_summary/0`, `file_search_catalog/0`). ## Definition of Done - [ ] #1 All new and modified modules have @moduledoc - [ ] #2 All public functions have @spec and @doc - [ ] #3 Mix compile --warnings-as-errors passes - [ ] #4 mix test passes with no failures - [ ] #5 mix format --check-formatted passes - [ ] #6 mix credo passes - [ ] #7 Documentation updated (architecture.md if new modules/schemas added) - [ ] #8 Commit subject references ML-156