From 1437092a4251da35ff039afe1b7387ed6b80c7d3 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Thu, 26 Mar 2026 20:35:22 -0300 Subject: [PATCH] adding movies & web search tool --- entrypoint.sh | 3 + .../0001_add_media_type_web_search.sql | 9 + packages/backend/drizzle/meta/_journal.json | 7 + packages/backend/package.json | 3 +- packages/backend/src/agents/curator.ts | 16 +- packages/backend/src/agents/interpreter.ts | 10 +- packages/backend/src/agents/ranking.ts | 13 +- packages/backend/src/agents/retrieval.ts | 30 +-- packages/backend/src/agents/titleGenerator.ts | 8 +- packages/backend/src/db/schema.ts | 8 +- packages/backend/src/migrate.ts | 32 +++ .../backend/src/pipelines/recommendation.ts | 23 +- packages/backend/src/routes/feedback.ts | 9 +- .../backend/src/routes/recommendations.ts | 13 +- packages/backend/src/types/agents.ts | 2 + packages/frontend/src/api/client.ts | 6 +- packages/frontend/src/components/Modal.css | 142 +++++++++++++ .../src/components/NewRecommendationModal.tsx | 201 +++++++++++------- .../src/components/RecommendationCard.tsx | 2 +- packages/frontend/src/components/Sidebar.css | 21 ++ packages/frontend/src/components/Sidebar.tsx | 3 + .../frontend/src/hooks/useRecommendations.ts | 7 +- packages/frontend/src/pages/Home.tsx | 4 +- packages/frontend/src/pages/Recom.tsx | 6 +- packages/frontend/src/types/index.ts | 7 +- 25 files changed, 450 insertions(+), 135 deletions(-) create mode 100644 packages/backend/drizzle/0001_add_media_type_web_search.sql create mode 100644 packages/backend/src/migrate.ts diff --git a/entrypoint.sh b/entrypoint.sh index ae2f9c8..2eca8ec 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,5 +4,8 @@ set -e # Start Nginx in the background nginx & +# Run migrations +node /app/node_modules/.bin/tsx /app/packages/backend/src/migrate.ts + # Start the Node.js backend exec node /app/node_modules/.bin/tsx /app/packages/backend/src/index.ts diff --git a/packages/backend/drizzle/0001_add_media_type_web_search.sql b/packages/backend/drizzle/0001_add_media_type_web_search.sql new file mode 100644 index 0000000..9c8772e --- /dev/null +++ b/packages/backend/drizzle/0001_add_media_type_web_search.sql @@ -0,0 +1,9 @@ +ALTER TABLE "recommendations" ADD COLUMN "media_type" text DEFAULT 'tv_show' NOT NULL; +--> statement-breakpoint +ALTER TABLE "recommendations" ADD COLUMN "use_web_search" boolean DEFAULT false NOT NULL; +--> statement-breakpoint +ALTER TABLE "feedback" RENAME COLUMN "tv_show_name" TO "item_name"; +--> statement-breakpoint +DROP INDEX "feedback_tv_show_name_idx"; +--> statement-breakpoint +CREATE UNIQUE INDEX "feedback_item_name_idx" ON "feedback" USING btree ("item_name"); diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index 6dda52f..fbab087 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1774479321371, "tag": "0000_wild_joseph", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1774900000000, + "tag": "0001_add_media_type_web_search", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json index 9fb401d..b390838 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "tsx watch src/index.ts" + "dev": "tsx watch src/index.ts", + "migrate": "tsx src/migrate.ts" }, "keywords": [], "author": "", diff --git a/packages/backend/src/agents/curator.ts b/packages/backend/src/agents/curator.ts index 6efad20..a7a202b 100644 --- a/packages/backend/src/agents/curator.ts +++ b/packages/backend/src/agents/curator.ts @@ -1,5 +1,5 @@ import { openai } from '../agent.js'; -import type { InterpreterOutput, RankingOutput, CuratorOutput } from '../types/agents.js'; +import type { InterpreterOutput, RankingOutput, CuratorOutput, MediaType } from '../types/agents.js'; import { z } from 'zod'; import { zodTextFormat } from 'openai/helpers/zod'; @@ -14,7 +14,11 @@ const CuratorSchema = z.object({ export async function runCurator( ranking: RankingOutput, interpreter: InterpreterOutput, + mediaType: MediaType = 'tv_show', + useWebSearch = false, ): Promise { + const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; + const allShows = [ ...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,17 +36,15 @@ export async function runCurator( model: 'gpt-5.4', temperature: 0.5, service_tier: 'flex', - tools: [ - { type: 'web_search' } - ], + ...(useWebSearch ? { tools: [{ type: 'web_search' as const }] } : {}), text: { format: zodTextFormat(CuratorSchema, "shows") }, - instructions: `You are a TV show recommendation curator. For each show, write a concise 1-2 sentence explanation of why it was assigned to its category based on the user's preferences. + instructions: `You are a ${mediaLabel} recommendation curator. For each ${mediaLabel}, write a concise 1-2 sentence explanation of why it was assigned to its category based on the user's preferences.${useWebSearch ? '\n\nUse web search to verify details and enrich explanations with accurate information.' : ''} Rules: - Preserve the exact title and category as given - Keep explanations concise (1-2 sentences max) - Reference specific user preferences in the explanation -- Be honest — explain why "Questionable" or "Will Not Like" shows got that rating`, +- Be honest — explain why "Questionable" or "Will Not Like" ${mediaLabel}s got that rating`, input: `User preferences summary: Liked: ${JSON.stringify(interpreter.liked)} Themes: ${JSON.stringify(interpreter.themes)} @@ -50,7 +52,7 @@ Tone: ${JSON.stringify(interpreter.tone)} Character preferences: ${JSON.stringify(interpreter.character_preferences)} Avoid: ${JSON.stringify(interpreter.avoid)} -Shows to describe: +${mediaLabel}s to describe: ${showList}`, }); diff --git a/packages/backend/src/agents/interpreter.ts b/packages/backend/src/agents/interpreter.ts index bfde989..69ae3de 100644 --- a/packages/backend/src/agents/interpreter.ts +++ b/packages/backend/src/agents/interpreter.ts @@ -1,5 +1,5 @@ import { openai } from '../agent.js'; -import type { InterpreterOutput } from '../types/agents.js'; +import type { InterpreterOutput, MediaType } from '../types/agents.js'; import { z } from 'zod'; import { zodTextFormat } from 'openai/helpers/zod'; @@ -17,10 +17,12 @@ interface InterpreterInput { liked_shows: string; disliked_shows: string; themes: string; + media_type: MediaType; feedback_context?: string; } export async function runInterpreter(input: InterpreterInput): Promise { + const mediaLabel = input.media_type === 'movie' ? 'movie' : 'TV show'; const feedbackSection = input.feedback_context ? `\n\nUser Feedback Context (incorporate into preferences):\n${input.feedback_context}` : ''; @@ -30,7 +32,7 @@ export async function runInterpreter(input: InterpreterInput): Promise { + const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; + // Phase 1: Pre-filter — remove avoidance violations const avoidList = interpreter.avoid.map((a) => a.toLowerCase()); const filtered = retrieval.candidates.filter((c) => { @@ -43,7 +46,7 @@ export async function runRanking( temperature: 0.2, service_tier: 'flex', text: { format: zodTextFormat(RankingSchema, "ranking") }, - instructions: `You are a TV show ranking critic. Assign each show to exactly one of four confidence buckets based on how well it matches the user's preferences. + instructions: `You are a ${mediaLabel} ranking critic. Assign each ${mediaLabel} to exactly one of four confidence buckets based on how well it matches the user's preferences. Buckets: - "definitely_like": Near-perfect match to all preferences @@ -51,15 +54,15 @@ Buckets: - "questionable": Partial alignment, some aspects don't match - "will_not_like": Likely mismatch, conflicts with preferences or avoidance criteria -Every show in the input must appear in exactly one bucket. Use the title exactly as given.`, +Every ${mediaLabel} in the input must appear in exactly one bucket. Use the title exactly as given.`, input: `User preferences: -Liked shows: ${JSON.stringify(interpreter.liked)} +Liked ${mediaLabel}s: ${JSON.stringify(interpreter.liked)} Themes: ${JSON.stringify(interpreter.themes)} Character preferences: ${JSON.stringify(interpreter.character_preferences)} Tone: ${JSON.stringify(interpreter.tone)} Avoid: ${JSON.stringify(interpreter.avoid)} -Rank these shows: +Rank these ${mediaLabel}s: ${chunkTitles}`, }); diff --git a/packages/backend/src/agents/retrieval.ts b/packages/backend/src/agents/retrieval.ts index cca74c7..8e96ca8 100644 --- a/packages/backend/src/agents/retrieval.ts +++ b/packages/backend/src/agents/retrieval.ts @@ -1,5 +1,5 @@ import { openai } from '../agent.js'; -import type { InterpreterOutput, RetrievalOutput } from '../types/agents.js'; +import type { InterpreterOutput, RetrievalOutput, MediaType } from '../types/agents.js'; import { z } from 'zod'; import { zodTextFormat } from 'openai/helpers/zod'; @@ -10,33 +10,39 @@ const RetrievalSchema = z.object({ })) }); -export async function runRetrieval(input: InterpreterOutput, brainstormCount = 100): Promise { +export async function runRetrieval( + input: InterpreterOutput, + brainstormCount = 100, + mediaType: MediaType = 'tv_show', + useWebSearch = false, +): Promise { + const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; + const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV shows'; + const response = await openai.responses.parse({ model: 'gpt-5.4', temperature: 0.9, service_tier: 'flex', - tools: [ - { type: 'web_search' } - ], + ...(useWebSearch ? { tools: [{ type: 'web_search' as const }] } : {}), text: { format: zodTextFormat(RetrievalSchema, "candidates") }, - instructions: `You are a TV show candidate generator. Your goal is to brainstorm a LARGE, DIVERSE pool of ${brainstormCount} TV show candidates that match the user's structured preferences. + instructions: `You are a ${mediaLabel} candidate generator. Your goal is to brainstorm a LARGE, DIVERSE pool of ${brainstormCount} ${mediaLabel} candidates that match the user's structured preferences.${useWebSearch ? '\n\nUse web search to find recent and accurate titles, including newer releases.' : ''} Rules: -- Include both well-known and obscure shows +- Include both well-known and obscure ${mediaLabelPlural} - Prioritize RECALL over precision — it's better to include too many than too few -- Each "reason" should briefly explain why the show matches the preferences +- Each "reason" should briefly explain why the ${mediaLabel} matches the preferences - Avoid duplicates -- Include shows from different decades, countries, and networks +- Include ${mediaLabelPlural} from different decades, countries${mediaType === 'tv_show' ? ', and networks' : ', and directors'} - Aim for ${brainstormCount} candidates minimum`, input: `Structured preferences: -Liked shows: ${JSON.stringify(input.liked)} -Disliked shows: ${JSON.stringify(input.disliked)} +Liked ${mediaLabelPlural}: ${JSON.stringify(input.liked)} +Disliked ${mediaLabelPlural}: ${JSON.stringify(input.disliked)} Themes: ${JSON.stringify(input.themes)} Character preferences: ${JSON.stringify(input.character_preferences)} Tone: ${JSON.stringify(input.tone)} Avoid: ${JSON.stringify(input.avoid)} -Generate a large, diverse pool of TV show candidates.`, +Generate a large, diverse pool of ${mediaLabel} candidates.`, }); return (response.output_parsed as RetrievalOutput) ?? { candidates: [] }; diff --git a/packages/backend/src/agents/titleGenerator.ts b/packages/backend/src/agents/titleGenerator.ts index 851fddb..1b8375f 100644 --- a/packages/backend/src/agents/titleGenerator.ts +++ b/packages/backend/src/agents/titleGenerator.ts @@ -1,12 +1,14 @@ import { openai } from '../agent.js'; -import type { InterpreterOutput } from '../types/agents.js'; +import type { InterpreterOutput, MediaType } from '../types/agents.js'; + +export async function generateTitle(interpreter: InterpreterOutput, mediaType: MediaType = 'tv_show'): Promise { + const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; -export async function generateTitle(interpreter: InterpreterOutput): Promise { const response = await openai.responses.create({ model: 'gpt-5.4-mini', temperature: 0.7, service_tier: 'flex', - instructions: `Generate a concise 5-8 word title for a TV show recommendation session. + instructions: `Generate a concise 5-8 word title for a ${mediaLabel} recommendation session. Capture the essence of the user's taste — genre, tone, key themes. Respond with ONLY the title. No quotes, no trailing punctuation. Examples: "Dark Crime Dramas With Moral Ambiguity", "Cozy British Mysteries With Quirky Detectives"`, diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index ded9d8e..7ed3130 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, jsonb, timestamp, integer, uniqueIndex } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, jsonb, timestamp, integer, uniqueIndex, boolean } from 'drizzle-orm/pg-core'; import type { CuratorOutput } from '../types/agents.js'; export const recommendations = pgTable('recommendations', { @@ -9,6 +9,8 @@ export const recommendations = pgTable('recommendations', { disliked_shows: text('disliked_shows').notNull().default(''), themes: text('themes').notNull().default(''), brainstorm_count: integer('brainstorm_count').notNull().default(100), + media_type: text('media_type').notNull().default('tv_show'), + use_web_search: boolean('use_web_search').notNull().default(false), recommendations: jsonb('recommendations').$type(), status: text('status').notNull().default('pending'), created_at: timestamp('created_at').defaultNow().notNull(), @@ -18,10 +20,10 @@ export const feedback = pgTable( 'feedback', { id: uuid('id').defaultRandom().primaryKey(), - tv_show_name: text('tv_show_name').notNull(), + item_name: text('item_name').notNull(), stars: integer('stars').notNull(), feedback: text('feedback').notNull().default(''), created_at: timestamp('created_at').defaultNow().notNull(), }, - (table) => [uniqueIndex('feedback_tv_show_name_idx').on(table.tv_show_name)], + (table) => [uniqueIndex('feedback_item_name_idx').on(table.item_name)], ); diff --git a/packages/backend/src/migrate.ts b/packages/backend/src/migrate.ts new file mode 100644 index 0000000..edc7a41 --- /dev/null +++ b/packages/backend/src/migrate.ts @@ -0,0 +1,32 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: ['.env.local', '.env'] }); + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + console.error('DATABASE_URL is not set'); + process.exit(1); +} + +// Using max: 1 connection since it's only for migration +const migrationClient = postgres(connectionString, { max: 1 }); +const db = drizzle(migrationClient); + +const runMigrations = async () => { + console.log('Running database migrations...'); + try { + await migrate(db, { migrationsFolder: './drizzle' }); + console.log('Migrations completed successfully.'); + } catch (err) { + console.error('Error running migrations:', err); + process.exit(1); + } finally { + await migrationClient.end(); + } +}; + +runMigrations(); diff --git a/packages/backend/src/pipelines/recommendation.ts b/packages/backend/src/pipelines/recommendation.ts index ffd144a..de0bee9 100644 --- a/packages/backend/src/pipelines/recommendation.ts +++ b/packages/backend/src/pipelines/recommendation.ts @@ -5,14 +5,14 @@ import { runInterpreter } from '../agents/interpreter.js'; import { runRetrieval } from '../agents/retrieval.js'; import { runRanking } from '../agents/ranking.js'; import { runCurator } from '../agents/curator.js'; -import type { CuratorOutput, SSEEvent } from '../types/agents.js'; +import type { CuratorOutput, MediaType, SSEEvent } from '../types/agents.js'; import { generateTitle } from '../agents/titleGenerator.js'; /* -- Agent pipeline -- [1] Interpreter -> gets user input, transforms into structured data -[2] Retrieval -> gets shows from OpenAI (high temperature) -[3] Ranking -> ranks shows based on user input -[4] Curator -> curates shows based on user input +[2] Retrieval -> gets candidates from OpenAI (high temperature) +[3] Ranking -> ranks candidates based on user input +[4] Curator -> curates candidates based on user input */ type RecommendationRecord = typeof recommendations.$inferSelect; @@ -33,8 +33,10 @@ export async function runPipeline( ): Promise { let currentStage: SSEEvent['stage'] = 'interpreter'; const startTime = Date.now(); + const mediaType = (rec.media_type ?? 'tv_show') as MediaType; + const useWebSearch = rec.use_web_search ?? false; - log(rec.id, `Starting pipeline for "${rec.title}"${feedbackContext ? ' (with feedback context)' : ''}`); + log(rec.id, `Starting pipeline for "${rec.title}" [${mediaType}${useWebSearch ? ', web_search' : ''}]${feedbackContext ? ' (with feedback context)' : ''}`); try { // Set status to running @@ -54,6 +56,7 @@ export async function runPipeline( liked_shows: rec.liked_shows, disliked_shows: rec.disliked_shows, themes: rec.themes, + media_type: mediaType, ...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}), }); log(rec.id, `Interpreter: done (${Date.now() - t0}ms)`, { @@ -70,7 +73,7 @@ export async function runPipeline( log(rec.id, 'Retrieval: start'); sseWrite({ stage: 'retrieval', status: 'start' }); const t1 = Date.now(); - const retrievalOutput = await runRetrieval(interpreterOutput, rec.brainstorm_count); + const retrievalOutput = await runRetrieval(interpreterOutput, rec.brainstorm_count, mediaType, useWebSearch); log(rec.id, `Retrieval: done (${Date.now() - t1}ms) — ${retrievalOutput.candidates.length} candidates`, { titles: retrievalOutput.candidates.map((c) => c.title), }); @@ -81,7 +84,7 @@ export async function runPipeline( log(rec.id, 'Ranking: start'); sseWrite({ stage: 'ranking', status: 'start' }); const t2 = Date.now(); - const rankingOutput = await runRanking(interpreterOutput, retrievalOutput); + const rankingOutput = await runRanking(interpreterOutput, retrievalOutput, mediaType); log(rec.id, `Ranking: done (${Date.now() - t2}ms)`, { definitely_like: rankingOutput.definitely_like.length, might_like: rankingOutput.might_like.length, @@ -95,15 +98,15 @@ export async function runPipeline( log(rec.id, 'Curator: start'); sseWrite({ stage: 'curator', status: 'start' }); const t3 = Date.now(); - const curatorOutput = await runCurator(rankingOutput, interpreterOutput); - log(rec.id, `Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} shows curated`); + const curatorOutput = await runCurator(rankingOutput, interpreterOutput, mediaType, useWebSearch); + log(rec.id, `Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} items curated`); sseWrite({ stage: 'curator', status: 'done', data: curatorOutput }); // Generate AI title let aiTitle: string = rec.title; try { log(rec.id, 'Title generation: start'); - aiTitle = await generateTitle(interpreterOutput); + aiTitle = await generateTitle(interpreterOutput, mediaType); log(rec.id, `Title generation: done — "${aiTitle}"`); } catch (err) { log(rec.id, `Title generation failed, keeping initial title: ${String(err)}`); diff --git a/packages/backend/src/routes/feedback.ts b/packages/backend/src/routes/feedback.ts index b7ebc47..faf45db 100644 --- a/packages/backend/src/routes/feedback.ts +++ b/packages/backend/src/routes/feedback.ts @@ -1,13 +1,12 @@ import type { FastifyInstance } from 'fastify'; -import { eq } from 'drizzle-orm'; import { db } from '../db.js'; import { feedback } from '../db/schema.js'; export default async function feedbackRoute(fastify: FastifyInstance) { - // POST /feedback — upsert by tv_show_name + // POST /feedback — upsert by item_name fastify.post('/feedback', async (request, reply) => { const body = request.body as { - tv_show_name: string; + item_name: string; stars: number; feedback?: string; }; @@ -15,12 +14,12 @@ export default async function feedbackRoute(fastify: FastifyInstance) { await db .insert(feedback) .values({ - tv_show_name: body.tv_show_name, + item_name: body.item_name, stars: body.stars, feedback: body.feedback ?? '', }) .onConflictDoUpdate({ - target: feedback.tv_show_name, + target: feedback.item_name, set: { stars: body.stars, feedback: body.feedback ?? '', diff --git a/packages/backend/src/routes/recommendations.ts b/packages/backend/src/routes/recommendations.ts index f74596b..0bb0948 100644 --- a/packages/backend/src/routes/recommendations.ts +++ b/packages/backend/src/routes/recommendations.ts @@ -3,7 +3,7 @@ import { eq, desc } from 'drizzle-orm'; import { db } from '../db.js'; import { recommendations, feedback } from '../db/schema.js'; import { runPipeline } from '../pipelines/recommendation.js'; -import type { SSEEvent } from '../types/agents.js'; +import type { MediaType, SSEEvent } from '../types/agents.js'; export default async function recommendationsRoute(fastify: FastifyInstance) { // POST /recommendations — create record, return { id } @@ -14,6 +14,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { disliked_shows?: string; themes?: string; brainstorm_count?: number; + media_type?: string; + use_web_search?: boolean; }; const title = (body.main_prompt ?? '') @@ -24,6 +26,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { const rawCount = Number(body.brainstorm_count ?? 100); const brainstorm_count = Number.isFinite(rawCount) ? Math.min(200, Math.max(50, rawCount)) : 100; + const media_type: MediaType = body.media_type === 'movie' ? 'movie' : 'tv_show'; + const use_web_search = body.use_web_search === true; const [rec] = await db .insert(recommendations) @@ -34,6 +38,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { disliked_shows: body.disliked_shows ?? '', themes: body.themes ?? '', brainstorm_count, + media_type, + use_web_search, status: 'pending', }) .returning({ id: recommendations.id }); @@ -48,6 +54,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { id: recommendations.id, title: recommendations.title, status: recommendations.status, + media_type: recommendations.media_type, created_at: recommendations.created_at, }) .from(recommendations) @@ -68,7 +75,6 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { }); // GET /recommendations/:id/stream — SSE pipeline stream - // Always fetches all current feedback and injects if present (supports rerank flow) fastify.get('/recommendations/:id/stream', async (request, reply) => { const { id } = request.params as { id: string }; const [rec] = await db @@ -80,12 +86,13 @@ export default async function recommendationsRoute(fastify: FastifyInstance) { // Load all feedback to potentially inject as context const feedbackRows = await db.select().from(feedback); + const mediaLabel = rec.media_type === 'movie' ? 'Movie' : 'Show'; const feedbackContext = feedbackRows.length > 0 ? feedbackRows .map( (f) => - `Show: "${f.tv_show_name}" — Rating: ${f.stars}/3 stars${f.feedback ? ` — Comment: ${f.feedback}` : ''}`, + `${mediaLabel}: "${f.item_name}" — Rating: ${f.stars}/3 stars${f.feedback ? ` — Comment: ${f.feedback}` : ''}`, ) .join('\n') : undefined; diff --git a/packages/backend/src/types/agents.ts b/packages/backend/src/types/agents.ts index d5f407c..3af8b2e 100644 --- a/packages/backend/src/types/agents.ts +++ b/packages/backend/src/types/agents.ts @@ -1,3 +1,5 @@ +export type MediaType = 'tv_show' | 'movie'; + export interface InterpreterOutput { liked: string[]; disliked: string[]; diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index d8820c9..780668b 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Recommendation, RecommendationSummary, FeedbackEntry } from '../types/index.js'; +import type { MediaType, Recommendation, RecommendationSummary, FeedbackEntry } from '../types/index.js'; const BASE = '/api'; @@ -20,6 +20,8 @@ export function createRecommendation(body: { disliked_shows: string; themes: string; brainstorm_count?: number; + media_type: MediaType; + use_web_search?: boolean; }): Promise<{ id: string }> { return request('/recommendations', { method: 'POST', @@ -40,7 +42,7 @@ export function rerankRecommendation(id: string): Promise<{ ok: boolean }> { } export function submitFeedback(body: { - tv_show_name: string; + item_name: string; stars: number; feedback?: string; }): Promise<{ ok: boolean }> { diff --git a/packages/frontend/src/components/Modal.css b/packages/frontend/src/components/Modal.css index 5655185..c5e89ef 100644 --- a/packages/frontend/src/components/Modal.css +++ b/packages/frontend/src/components/Modal.css @@ -103,3 +103,145 @@ gap: 10px; padding-top: 4px; } + +/* ── Type selection step ─────────────────────────────────── */ + +.modal-type-select { + padding: 16px 20px 28px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.modal-type-hint { + font-size: 14px; + color: var(--text-muted); + margin: 0; +} + +.modal-type-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.type-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 4rem 2rem; + background: var(--bg-surface-2); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + text-align: center; +} + +.type-card:hover { + border-color: var(--accent); + background: var(--bg-surface); +} + +.type-card-icon { + font-size: 32px; + line-height: 1; +} + +.type-card-label { + font-size: 16px; + font-weight: 700; + color: var(--text); +} + +.type-card-desc { + font-size: 12px; + color: var(--text-muted); +} + +/* ── Modal header back button ────────────────────────────── */ + +.modal-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.modal-back { + background: none; + border: none; + font-size: 18px; + color: var(--text-muted); + cursor: pointer; + line-height: 1; + padding: 0 4px 0 0; +} + +.modal-back:hover { + color: var(--text); +} + +/* ── Web search toggle ───────────────────────────────────── */ + +.form-group-toggle { + padding: 12px 0 4px; + border-top: 1px solid var(--border); +} + +.toggle-label { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + gap: 16px; +} + +.toggle-text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.toggle-title { + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.toggle-desc { + font-size: 12px; + color: var(--text-muted); +} + +.toggle-switch { + flex-shrink: 0; + width: 40px; + height: 22px; + background: var(--bg-surface-2); + border: 1px solid var(--border); + border-radius: 11px; + position: relative; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.toggle-switch.on { + background: var(--accent); + border-color: var(--accent); +} + +.toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} + +.toggle-switch.on .toggle-knob { + transform: translateX(18px); +} \ No newline at end of file diff --git a/packages/frontend/src/components/NewRecommendationModal.tsx b/packages/frontend/src/components/NewRecommendationModal.tsx index b6b5551..f824899 100644 --- a/packages/frontend/src/components/NewRecommendationModal.tsx +++ b/packages/frontend/src/components/NewRecommendationModal.tsx @@ -1,4 +1,5 @@ import { useState } from 'preact/hooks'; +import type { MediaType } from '../types/index.js'; import './Modal.css'; interface NewRecommendationModalProps { @@ -9,17 +10,30 @@ interface NewRecommendationModalProps { disliked_shows: string; themes: string; brainstorm_count?: number; + media_type: MediaType; + use_web_search?: boolean; }) => Promise; } export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) { + const [step, setStep] = useState<'type' | 'form'>('type'); + const [mediaType, setMediaType] = useState('tv_show'); const [mainPrompt, setMainPrompt] = useState(''); const [likedShows, setLikedShows] = useState(''); const [dislikedShows, setDislikedShows] = useState(''); const [themes, setThemes] = useState(''); const [brainstormCount, setBrainstormCount] = useState(100); + const [useWebSearch, setUseWebSearch] = useState(false); const [loading, setLoading] = useState(false); + const mediaLabel = mediaType === 'movie' ? 'Movie' : 'TV Show'; + const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'shows'; + + const handleSelectType = (type: MediaType) => { + setMediaType(type); + setStep('form'); + }; + const handleSubmit = async (e: Event) => { e.preventDefault(); if (!mainPrompt.trim()) return; @@ -31,6 +45,8 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM disliked_shows: dislikedShows.trim(), themes: themes.trim(), brainstorm_count: brainstormCount, + media_type: mediaType, + use_web_search: useWebSearch, }); onClose(); } finally { @@ -47,84 +63,125 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM return (