diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6c7e224 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,277 @@ +# TV Show Recommendation System (Self-Hosted, Multi-Agent) + +## ๐ŸŽฏ Purpose + +This document provides **complete context for AI agents and developers** to build and evolve a local, self-hosted TV show recommendation system. + +The system is designed to: + +* Prioritize **current user intent** over historical behavior +* Use **multi-agent architecture** +* Generate, evaluate, and organize recommendations +* Handle **50โ€“100 candidate series per request** + +--- + +# ๐Ÿง  Core Principles + +1. **Context over history** + + * Do NOT persist long-term taste profiles + * Every request must be evaluated independently + +2. **Generation โ‰  Evaluation** + + * Candidate generation and ranking must be separated + +3. **High recall โ†’ aggressive filtering** + + * Generate many candidates (50โ€“100) + * Filter and rank later + +4. **Structured communication between agents** + + * All agents must exchange structured data (JSON-like) + +5. **Deterministic where possible** + + * Interpretation and ranking should be consistent + +--- + +# ๐Ÿ—๏ธ System Overview + +``` +User Input + โ†“ +[1] Interpreter Agent + โ†“ +[2] Retrieval Agent + โ†“ +[3] Ranking Agent + โ†“ +[4] Curator Agent +``` + +--- + +# 1. ๐Ÿงพ Interpreter Agent + +## Goal + +Convert raw user input into structured data. + +## Input + +Free-form user text describing: + +* Liked series +* Disliked series +* Preferences (themes, tone, characters) +* Constraints (things to avoid) + +## Output Schema + +```json +{ + "liked": ["string"], + "disliked": ["string"], + "themes": ["string"], + "character_preferences": ["string"], + "tone": ["string"], + "avoid": ["string"] +} +``` + +## Requirements + +* Normalize terminology (e.g., "spy" โ†’ "espionage") +* Infer implicit preferences +* Detect contradictions +* Be deterministic (low temperature) + +--- + +# 2. ๐Ÿ”Ž Retrieval Agent + +## Goal + +Generate a **large and diverse pool (50โ€“100)** of candidate TV series. + +## Strategy: LLM Generation + +* Generate candidate series from structured input +* Focus on diversity and coverage (high LLM temperature) + +## Output Schema + +```json +[ + { + "title": "string", + "metadata": { + "themes": [], + "tone": [], + "tags": [] + } + } +] +``` + +## Constraints + +* Do NOT use external APIs +* Favor recall over precision + +--- + +# 3. โš–๏ธ Ranking Agent + +## Goal + +Categorize candidates into 4 confidence levels using relative comparison. + +## Categories + +* Definitely Like +* Might Like +* Questionable +* Will Not Like + +## Method + +### Step 1: Pre-filter + +* Remove obvious mismatches +* Enforce "avoid" constraints + +### Step 2: Pairwise Comparison + +* Compare candidates relative to each other and input + +### Step 3: Tagging + +* Assign each show to one of the four categories + +## Output Schema + +```json +{ + "definitely_like": ["title"], + "might_like": ["title"], + "questionable": ["title"], + "will_not_like": ["title"] +} +``` + +## Requirements + +* Keep logic simple and consistent +* Be deterministic (low temperature) + +--- + +# 4. ๐ŸŽฏ Curator Agent + +## Goal + +Produce a clean, user-facing recommendation output. + +## Responsibilities + +* Group series by category +* Provide short explanations +* Ensure readability + +## Output Example + +``` +Definitely Like: +- Show A โ†’ reason + +Might Like: +- Show B โ†’ reason +``` + +--- + +# ๐Ÿ” Feedback Loop (Re-Ranking Only) + +## Goal + +Improve ranking without storing long-term user preferences. + +## Inputs + +* External summaries, reviews, or insights +* User-provided feedback from other sources + +## Behavior + +* Adjust ranking dynamically +* Re-rank existing candidates +* Do NOT persist user taste + +--- + +# โš™๏ธ Agent Configuration + +## Temperature Guidelines + +* Interpreter: Low +* Retrieval: Medium/High +* Ranking: Low +* Curator: Medium + +## Rules + +* Agents must be stateless +* Pass all required context explicitly +* No hidden memory + +--- + +# ๐Ÿงฑ Project Structure + +``` +/agents + interpreter + retrieval + ranking + curator + +/pipelines + recommendation_flow +``` + +--- + +# ๐Ÿšซ Non-Goals + +* No persistent taste model +* No long-term user profiling + +--- + +# ๐Ÿงญ Implementation Notes for AI Agents + +When extending or modifying this system: + +1. Do NOT introduce long-term memory of user preferences +2. Do NOT merge agent responsibilities +3. Always preserve structured input/output between agents +4. Prefer simplicity over overengineering +5. Ensure ranking remains interpretable and consistent + +--- + +# โœ… Summary + +This system is a **stateless, multi-agent recommendation pipeline** focused on: + +* Strong alignment with current input +* High candidate diversity +* Structured filtering and ranking +* Scalable handling of large candidate sets + +The architecture is intentionally simple, modular, and extensible. diff --git a/README.md b/README.md index 11a0724..b9df95b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Recommender -A pure TypeScript monolith AI agent application that will recommend TV shows based on a very customized user profile and input. +A pure TypeScript monolith AI agent application that will recommend TV series based on a very customized user profile and input. ## Project Structure diff --git a/packages/backend/drizzle/0000_wild_joseph.sql b/packages/backend/drizzle/0000_wild_joseph.sql index 6cab67b..fad65b1 100644 --- a/packages/backend/drizzle/0000_wild_joseph.sql +++ b/packages/backend/drizzle/0000_wild_joseph.sql @@ -10,8 +10,8 @@ CREATE TABLE IF NOT EXISTS "recommendations" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "title" text NOT NULL, "main_prompt" text NOT NULL, - "liked_shows" text DEFAULT '' NOT NULL, - "disliked_shows" text DEFAULT '' NOT NULL, + "liked_series" text DEFAULT '' NOT NULL, + "disliked_series" text DEFAULT '' NOT NULL, "themes" text DEFAULT '' NOT NULL, "brainstorm_count" integer DEFAULT 100 NOT NULL, "recommendations" jsonb, diff --git a/packages/backend/drizzle/0003_changing_tv_series.sql b/packages/backend/drizzle/0003_changing_tv_series.sql new file mode 100644 index 0000000..e367289 --- /dev/null +++ b/packages/backend/drizzle/0003_changing_tv_series.sql @@ -0,0 +1,2 @@ +ALTER TABLE "recommendations" RENAME COLUMN "liked_shows" TO "liked_series"; +ALTER TABLE "recommendations" RENAME COLUMN "disliked_shows" TO "disliked_series"; \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0000_snapshot.json b/packages/backend/drizzle/meta/0000_snapshot.json index a7f72ea..865daca 100644 --- a/packages/backend/drizzle/meta/0000_snapshot.json +++ b/packages/backend/drizzle/meta/0000_snapshot.json @@ -89,15 +89,15 @@ "primaryKey": false, "notNull": true }, - "liked_shows": { - "name": "liked_shows", + "liked_series": { + "name": "liked_series", "type": "text", "primaryKey": false, "notNull": true, "default": "''" }, - "disliked_shows": { - "name": "disliked_shows", + "disliked_series": { + "name": "disliked_series", "type": "text", "primaryKey": false, "notNull": true, diff --git a/packages/backend/src/agents/curator.ts b/packages/backend/src/agents/curator.ts index b65a2a8..30d656c 100644 --- a/packages/backend/src/agents/curator.ts +++ b/packages/backend/src/agents/curator.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { zodTextFormat } from 'openai/helpers/zod'; const CuratorSchema = z.object({ - shows: z.array(z.object({ + series: z.array(z.object({ title: z.string(), explanation: z.string(), category: z.enum(["Full Match", "Definitely Like", "Might Like", "Questionable", "Will Not Like"]), @@ -24,7 +24,7 @@ export async function runCurator( ): Promise { const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; - const allShows = [ + const allSeries = [ ...(ranking.full_match ?? []).map((t) => ({ title: t, category: 'Full Match' as const })), ...ranking.definitely_like.map((t) => ({ title: t, category: 'Definitely Like' as const })), ...ranking.might_like.map((t) => ({ title: t, category: 'Might Like' as const })), @@ -32,7 +32,7 @@ export async function runCurator( ...ranking.will_not_like.map((t) => ({ title: t, category: 'Will Not Like' as const })), ]; - if (allShows.length === 0) return []; + if (allSeries.length === 0) return []; const canSearch = useWebSearch && supportsWebSearch; const preferenceSummary = `User preferences summary: @@ -53,9 +53,9 @@ Rules: - cons: up to 3 short bullet points about what the user might not like based on their preferences - Be honest โ€” explain why "Questionable" or "Will Not Like" ${mediaLabel}s got that rating`; - const chunks: typeof allShows[] = []; - for (let i = 0; i < allShows.length; i += CHUNK_SIZE) { - chunks.push(allShows.slice(i, i + CHUNK_SIZE)); + const chunks: typeof allSeries[] = []; + for (let i = 0; i < allSeries.length; i += CHUNK_SIZE) { + chunks.push(allSeries.slice(i, i + CHUNK_SIZE)); } const results: CuratorOutput[] = []; @@ -66,14 +66,14 @@ Rules: temperature: 0.5, ...serviceOptions, ...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}), - text: { format: zodTextFormat(CuratorSchema, "shows") }, + text: { format: zodTextFormat(CuratorSchema, "series") }, instructions, input: `${preferenceSummary} ${mediaLabel}s to describe: ${showList}`, })); - results.push(...(response.output_parsed?.shows ?? [])); + results.push(...(response.output_parsed?.series ?? [])); } return results; diff --git a/packages/backend/src/agents/interpreter.ts b/packages/backend/src/agents/interpreter.ts index b904cf4..f3b155c 100644 --- a/packages/backend/src/agents/interpreter.ts +++ b/packages/backend/src/agents/interpreter.ts @@ -15,8 +15,8 @@ const InterpreterSchema = z.object({ interface InterpreterInput { main_prompt: string; - liked_shows: string; - disliked_shows: string; + liked_series: string; + disliked_series: string; themes: string; media_type: MediaType; feedback_context?: string; @@ -43,8 +43,8 @@ Rules: - Be specific and concrete, not vague - For "requirements": capture explicit hard requirements the user stated that recommendations must satisfy โ€” things like "must be from the 2000s onward", "must have subtitles", "must feature a female lead". Leave empty if no such constraints were stated.`, input: `Main prompt: ${input.main_prompt} -Liked ${mediaLabel}s: ${input.liked_shows || '(none)'} -Disliked ${mediaLabel}s: ${input.disliked_shows || '(none)'} +Liked ${mediaLabel}s: ${input.liked_series || '(none)'} +Disliked ${mediaLabel}s: ${input.disliked_series || '(none)'} Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`, })); diff --git a/packages/backend/src/agents/retrieval.ts b/packages/backend/src/agents/retrieval.ts index caad2ec..0057cd4 100644 --- a/packages/backend/src/agents/retrieval.ts +++ b/packages/backend/src/agents/retrieval.ts @@ -19,7 +19,7 @@ export async function runRetrieval( previousFullMatches: string[] = [], ): Promise { const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; - const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV shows'; + const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV series'; const canSearch = useWebSearch && supportsWebSearch; const response = await parseWithRetry(() => openai.responses.parse({ diff --git a/packages/backend/src/agents/retrievalContinuous.ts b/packages/backend/src/agents/retrievalContinuous.ts index 056ac9e..b900e40 100644 --- a/packages/backend/src/agents/retrievalContinuous.ts +++ b/packages/backend/src/agents/retrievalContinuous.ts @@ -38,7 +38,7 @@ function buildSystemPrompt( seenTitles: string[] ): string { const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; - const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV shows'; + const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV series'; let prompt = `You are a ${mediaLabel} recommendation specialist. Your task is to recommend titles that match the user's taste profile. diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index 8a3609a..5ee415f 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -5,8 +5,8 @@ export const recommendations = pgTable('recommendations', { id: uuid('id').defaultRandom().primaryKey(), title: text('title').notNull(), main_prompt: text('main_prompt').notNull(), - liked_shows: text('liked_shows').notNull().default(''), - disliked_shows: text('disliked_shows').notNull().default(''), + liked_series: text('liked_series').notNull().default(''), + disliked_series: text('disliked_series').notNull().default(''), themes: text('themes').notNull().default(''), brainstorm_count: integer('brainstorm_count').notNull().default(100), media_type: text('media_type').notNull().default('tv_show'), diff --git a/packages/backend/src/pipelines/continuous.ts b/packages/backend/src/pipelines/continuous.ts index cdb0b3a..6cb8901 100644 --- a/packages/backend/src/pipelines/continuous.ts +++ b/packages/backend/src/pipelines/continuous.ts @@ -39,8 +39,8 @@ function mergeCuratorOutputs(a: CuratorOutput[], b: CuratorOutput[]): CuratorOut } interface ContinuousPipelineInput { - likedShows: string; - dislikedShows?: string; + likedSeries: string; + dislikedSeries?: string; themes?: string; requirements?: string; avoid?: string; @@ -57,8 +57,8 @@ export async function runContinuousPipeline( ): Promise { const startTime = Date.now(); const { - likedShows, - dislikedShows = '', + likedSeries, + dislikedSeries = '', themes = '', requirements = '', avoid = '', @@ -83,9 +83,9 @@ export async function runContinuousPipeline( const t0 = Date.now(); interpreterOutput = await runInterpreter({ - main_prompt: themes || 'recommend shows based on user preferences', - liked_shows: likedShows, - disliked_shows: dislikedShows, + main_prompt: themes || 'recommend series based on user preferences', + liked_series: likedSeries, + disliked_series: dislikedSeries, themes: themes, media_type: mediaType, }); @@ -267,8 +267,8 @@ export async function runContinuousPipeline( .set({ title: aiTitle, main_prompt: themes || 'Continuous recommendations', - liked_shows: likedShows, - disliked_shows: dislikedShows, + liked_series: likedSeries, + disliked_series: dislikedSeries, themes: themes, brainstorm_count: totalCount, media_type: mediaType, diff --git a/packages/backend/src/pipelines/recommendation.ts b/packages/backend/src/pipelines/recommendation.ts index f67d227..13f2947 100644 --- a/packages/backend/src/pipelines/recommendation.ts +++ b/packages/backend/src/pipelines/recommendation.ts @@ -201,8 +201,8 @@ export async function runPipeline( const t0 = Date.now(); const interpreterOutput = await runInterpreter({ main_prompt: rec.main_prompt, - liked_shows: rec.liked_shows, - disliked_shows: rec.disliked_shows, + liked_series: rec.liked_series, + disliked_series: rec.disliked_series, themes: rec.themes, media_type: mediaType, ...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}), diff --git a/packages/backend/src/routes/recommendations.ts b/packages/backend/src/routes/recommendations.ts index 72709f8..50b4204 100644 --- a/packages/backend/src/routes/recommendations.ts +++ b/packages/backend/src/routes/recommendations.ts @@ -29,8 +29,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { fastify.post('/recommendations', async (request, reply) => { const body = request.body as { main_prompt: string; - liked_shows?: string; - disliked_shows?: string; + liked_series?: string; + disliked_series?: string; themes?: string; brainstorm_count?: number; media_type?: string; @@ -64,8 +64,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { .values({ title: title || 'Untitled', main_prompt: body.main_prompt ?? '', - liked_shows: body.liked_shows ?? '', - disliked_shows: body.disliked_shows ?? '', + liked_series: body.liked_series ?? '', + disliked_series: body.disliked_series ?? '', themes: body.themes ?? '', brainstorm_count, media_type, @@ -85,8 +85,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // POST /recommendations/continuous โ€” create record and run continuous pipeline with SSE fastify.post('/recommendations/continuous', async (request, reply) => { const body = request.body as { - liked_shows: string; - disliked_shows?: string; + liked_series: string; + disliked_series?: string; themes?: string; requirements?: string; avoid?: string; @@ -108,8 +108,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { .values({ title, main_prompt: body.themes ?? 'Continuous recommendations', - liked_shows: body.liked_shows, - disliked_shows: body.disliked_shows ?? '', + liked_series: body.liked_series, + disliked_series: body.disliked_series ?? '', themes: body.themes ?? '', brainstorm_count: totalCount, media_type: mediaType, @@ -155,8 +155,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // Run the continuous pipeline (it will now update the existing record) await runContinuousPipeline(recId, { - likedShows: body.liked_shows, - dislikedShows: body.disliked_shows ?? '', + likedSeries: body.liked_series, + dislikedSeries: body.disliked_series ?? '', themes: body.themes ?? '', requirements: body.requirements ?? '', avoid: body.avoid ?? '', @@ -305,11 +305,11 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { const feedbackContext = feedbackRows.length > 0 ? feedbackRows - .map( - (f) => - `${mediaLabel}: "${f.item_name}" โ€” Rating: ${f.stars}/3 stars${f.feedback ? ` โ€” Comment: ${f.feedback}` : ''}`, - ) - .join('\n') + .map( + (f) => + `${mediaLabel}: "${f.item_name}" โ€” Rating: ${f.stars}/3 stars${f.feedback ? ` โ€” Comment: ${f.feedback}` : ''}`, + ) + .join('\n') : undefined; await runPipeline(rec, (event) => { diff --git a/packages/backend/src/types/agents.ts b/packages/backend/src/types/agents.ts index 63ca1f3..0554f3c 100644 --- a/packages/backend/src/types/agents.ts +++ b/packages/backend/src/types/agents.ts @@ -83,8 +83,8 @@ export interface ContinuousSession { export interface ContinuousStartRequest { mediaType: 'tv_show' | 'movie'; - likedShows: string; - dislikedShows?: string; + likedSeries: string; + dislikedSeries?: string; themes?: string; requirements?: string; avoid?: string; diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index f100603..e77684b 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -18,8 +18,8 @@ async function request(path: string, options?: RequestInit): Promise { export function createRecommendation(body: { main_prompt: string; - liked_shows: string; - disliked_shows: string; + liked_series: string; + disliked_series: string; themes: string; brainstorm_count?: number; media_type: MediaType; @@ -68,8 +68,8 @@ export function deleteRecommendation(id: string): Promise<{ ok: boolean }> { } export function createContinuousRecommendation(body: { - liked_shows: string; - disliked_shows?: string; + liked_series: string; + disliked_series?: string; themes?: string; requirements?: string; avoid?: string; diff --git a/packages/frontend/src/components/NewRecommendationModal.tsx b/packages/frontend/src/components/NewRecommendationModal.tsx index dde92a1..cae1f0a 100644 --- a/packages/frontend/src/components/NewRecommendationModal.tsx +++ b/packages/frontend/src/components/NewRecommendationModal.tsx @@ -8,8 +8,8 @@ interface NewRecommendationModalProps { onClose: () => void; onSubmit: (body: { main_prompt: string; - liked_shows: string; - disliked_shows: string; + liked_series: string; + disliked_series: string; themes: string; requirements?: string; avoid?: string; @@ -33,19 +33,19 @@ const MEDIA_OPTIONS: Array<{ label: string; description: string; }> = [ - { - type: 'tv_show', - icon: '๐Ÿ“บ', - label: 'TV Shows', - description: 'Serialized stories, limited series, and long-form comfort watches.', - }, - { - type: 'movie', - icon: '๐ŸŽฌ', - label: 'Movies', - description: 'Feature films, prestige cinema, and one-night picks.', - }, -]; + { + type: 'tv_show', + icon: '๐Ÿ“บ', + label: 'TV series', + description: 'Serialized stories, limited series, and long-form comfort watches.', + }, + { + type: 'movie', + icon: '๐ŸŽฌ', + label: 'Movies', + description: 'Feature films, prestige cinema, and one-night picks.', + }, + ]; const MODE_OPTIONS: Array<{ mode: GenerationMode; @@ -53,27 +53,27 @@ const MODE_OPTIONS: Array<{ badge: string; description: string; }> = [ - { - mode: 'brainstorm', - label: 'Brainstorm', - badge: 'Best for variety', - description: 'Explore a broad pool of options, then rank and curate the strongest fits.', - }, - { - mode: 'continuous', - label: 'Continuous', - badge: 'Best for deep search', - description: 'Generate recommendations in chained batches for a steadier, longer-running hunt.', - }, -]; + { + mode: 'brainstorm', + label: 'Brainstorm', + badge: 'Best for variety', + description: 'Explore a broad pool of options, then rank and curate the strongest fits.', + }, + { + mode: 'continuous', + label: 'Continuous', + badge: 'Best for deep search', + description: 'Generate recommendations in chained batches for a steadier, longer-running hunt.', + }, + ]; export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) { const [step, setStep] = useState<'type' | 'mode' | 'form'>('type'); const [mediaType, setMediaType] = useState('tv_show'); const [generationMode, setGenerationMode] = useState('brainstorm'); const [mainPrompt, setMainPrompt] = useState(''); - const [likedShows, setLikedShows] = useState(''); - const [dislikedShows, setDislikedShows] = useState(''); + const [likedSeries, setLikedSeries] = useState(''); + const [dislikedSeries, setDislikedSeries] = useState(''); const [themes, setThemes] = useState(''); const [requirements, setRequirements] = useState(''); const [avoid, setAvoid] = useState(''); @@ -100,7 +100,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM }, [loading, onClose]); const mediaLabel = mediaType === 'movie' ? 'Movie' : 'TV Show'; - const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'shows'; + const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'series'; const handleSelectType = (type: MediaType) => { setMediaType(type); @@ -119,15 +119,15 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM const handleSubmit = async (e: Event) => { e.preventDefault(); if (generationMode === 'brainstorm' && !mainPrompt.trim()) return; - if (!likedShows.trim()) return; + if (!likedSeries.trim()) return; setLoading(true); try { if (generationMode === 'brainstorm') { await onSubmit({ main_prompt: mainPrompt.trim(), - liked_shows: likedShows.trim(), - disliked_shows: dislikedShows.trim(), + liked_series: likedSeries.trim(), + disliked_series: dislikedSeries.trim(), themes: themes.trim(), brainstorm_count: brainstormCount, media_type: mediaType, @@ -142,8 +142,8 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM } else { await onSubmit({ main_prompt: '', - liked_shows: likedShows.trim(), - disliked_shows: dislikedShows.trim(), + liked_series: likedSeries.trim(), + disliked_series: dislikedSeries.trim(), themes: themes.trim(), requirements: requirements.trim(), avoid: avoid.trim(), @@ -258,7 +258,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM @@ -281,7 +281,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM