adding movies & web search tool
All checks were successful
Recommender Build and Deploy (internal) / Build Recommender Image (push) Successful in 4m0s
Recommender Build and Deploy (internal) / Deploy Recommender (internal) (push) Successful in 12s

This commit is contained in:
2026-03-26 20:35:22 -03:00
parent 6fdfc3797a
commit 1437092a42
25 changed files with 450 additions and 135 deletions

View File

@@ -4,5 +4,8 @@ set -e
# Start Nginx in the background # Start Nginx in the background
nginx & nginx &
# Run migrations
node /app/node_modules/.bin/tsx /app/packages/backend/src/migrate.ts
# Start the Node.js backend # Start the Node.js backend
exec node /app/node_modules/.bin/tsx /app/packages/backend/src/index.ts exec node /app/node_modules/.bin/tsx /app/packages/backend/src/index.ts

View File

@@ -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");

View File

@@ -8,6 +8,13 @@
"when": 1774479321371, "when": 1774479321371,
"tag": "0000_wild_joseph", "tag": "0000_wild_joseph",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1774900000000,
"tag": "0001_add_media_type_web_search",
"breakpoints": true
} }
] ]
} }

View File

@@ -5,7 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "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": [], "keywords": [],
"author": "", "author": "",

View File

@@ -1,5 +1,5 @@
import { openai } from '../agent.js'; 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 { z } from 'zod';
import { zodTextFormat } from 'openai/helpers/zod'; import { zodTextFormat } from 'openai/helpers/zod';
@@ -14,7 +14,11 @@ const CuratorSchema = z.object({
export async function runCurator( export async function runCurator(
ranking: RankingOutput, ranking: RankingOutput,
interpreter: InterpreterOutput, interpreter: InterpreterOutput,
mediaType: MediaType = 'tv_show',
useWebSearch = false,
): Promise<CuratorOutput[]> { ): Promise<CuratorOutput[]> {
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
const allShows = [ const allShows = [
...ranking.definitely_like.map((t) => ({ title: t, category: 'Definitely Like' 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 })), ...ranking.might_like.map((t) => ({ title: t, category: 'Might Like' as const })),
@@ -32,17 +36,15 @@ export async function runCurator(
model: 'gpt-5.4', model: 'gpt-5.4',
temperature: 0.5, temperature: 0.5,
service_tier: 'flex', service_tier: 'flex',
tools: [ ...(useWebSearch ? { tools: [{ type: 'web_search' as const }] } : {}),
{ type: 'web_search' }
],
text: { format: zodTextFormat(CuratorSchema, "shows") }, 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: Rules:
- Preserve the exact title and category as given - Preserve the exact title and category as given
- Keep explanations concise (1-2 sentences max) - Keep explanations concise (1-2 sentences max)
- Reference specific user preferences in the explanation - 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: input: `User preferences summary:
Liked: ${JSON.stringify(interpreter.liked)} Liked: ${JSON.stringify(interpreter.liked)}
Themes: ${JSON.stringify(interpreter.themes)} Themes: ${JSON.stringify(interpreter.themes)}
@@ -50,7 +52,7 @@ Tone: ${JSON.stringify(interpreter.tone)}
Character preferences: ${JSON.stringify(interpreter.character_preferences)} Character preferences: ${JSON.stringify(interpreter.character_preferences)}
Avoid: ${JSON.stringify(interpreter.avoid)} Avoid: ${JSON.stringify(interpreter.avoid)}
Shows to describe: ${mediaLabel}s to describe:
${showList}`, ${showList}`,
}); });

View File

@@ -1,5 +1,5 @@
import { openai } from '../agent.js'; 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 { z } from 'zod';
import { zodTextFormat } from 'openai/helpers/zod'; import { zodTextFormat } from 'openai/helpers/zod';
@@ -17,10 +17,12 @@ interface InterpreterInput {
liked_shows: string; liked_shows: string;
disliked_shows: string; disliked_shows: string;
themes: string; themes: string;
media_type: MediaType;
feedback_context?: string; feedback_context?: string;
} }
export async function runInterpreter(input: InterpreterInput): Promise<InterpreterOutput> { export async function runInterpreter(input: InterpreterInput): Promise<InterpreterOutput> {
const mediaLabel = input.media_type === 'movie' ? 'movie' : 'TV show';
const feedbackSection = input.feedback_context const feedbackSection = input.feedback_context
? `\n\nUser Feedback Context (incorporate into preferences):\n${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<Interpret
temperature: 0.2, temperature: 0.2,
service_tier: 'flex', service_tier: 'flex',
text: { format: zodTextFormat(InterpreterSchema, "preferences") }, text: { format: zodTextFormat(InterpreterSchema, "preferences") },
instructions: `You are a TV show preference interpreter. Transform raw user input into structured, normalized preferences. instructions: `You are a ${mediaLabel} preference interpreter. Transform raw user input into structured, normalized preferences.
Rules: Rules:
- Extract implicit preferences from the main prompt - Extract implicit preferences from the main prompt
@@ -39,8 +41,8 @@ Rules:
- Do NOT assume anything not stated or clearly implied - Do NOT assume anything not stated or clearly implied
- Be specific and concrete, not vague`, - Be specific and concrete, not vague`,
input: `Main prompt: ${input.main_prompt} input: `Main prompt: ${input.main_prompt}
Liked shows: ${input.liked_shows || '(none)'} Liked ${mediaLabel}s: ${input.liked_shows || '(none)'}
Disliked shows: ${input.disliked_shows || '(none)'} Disliked ${mediaLabel}s: ${input.disliked_shows || '(none)'}
Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`, Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`,
}); });

View File

@@ -1,5 +1,5 @@
import { openai } from '../agent.js'; import { openai } from '../agent.js';
import type { InterpreterOutput, RetrievalOutput, RankingOutput } from '../types/agents.js'; import type { InterpreterOutput, RetrievalOutput, RankingOutput, MediaType } from '../types/agents.js';
import { z } from 'zod'; import { z } from 'zod';
import { zodTextFormat } from 'openai/helpers/zod'; import { zodTextFormat } from 'openai/helpers/zod';
@@ -13,7 +13,10 @@ const RankingSchema = z.object({
export async function runRanking( export async function runRanking(
interpreter: InterpreterOutput, interpreter: InterpreterOutput,
retrieval: RetrievalOutput, retrieval: RetrievalOutput,
mediaType: MediaType = 'tv_show',
): Promise<RankingOutput> { ): Promise<RankingOutput> {
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
// Phase 1: Pre-filter — remove avoidance violations // Phase 1: Pre-filter — remove avoidance violations
const avoidList = interpreter.avoid.map((a) => a.toLowerCase()); const avoidList = interpreter.avoid.map((a) => a.toLowerCase());
const filtered = retrieval.candidates.filter((c) => { const filtered = retrieval.candidates.filter((c) => {
@@ -43,7 +46,7 @@ export async function runRanking(
temperature: 0.2, temperature: 0.2,
service_tier: 'flex', service_tier: 'flex',
text: { format: zodTextFormat(RankingSchema, "ranking") }, 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: Buckets:
- "definitely_like": Near-perfect match to all preferences - "definitely_like": Near-perfect match to all preferences
@@ -51,15 +54,15 @@ Buckets:
- "questionable": Partial alignment, some aspects don't match - "questionable": Partial alignment, some aspects don't match
- "will_not_like": Likely mismatch, conflicts with preferences or avoidance criteria - "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: input: `User preferences:
Liked shows: ${JSON.stringify(interpreter.liked)} Liked ${mediaLabel}s: ${JSON.stringify(interpreter.liked)}
Themes: ${JSON.stringify(interpreter.themes)} Themes: ${JSON.stringify(interpreter.themes)}
Character preferences: ${JSON.stringify(interpreter.character_preferences)} Character preferences: ${JSON.stringify(interpreter.character_preferences)}
Tone: ${JSON.stringify(interpreter.tone)} Tone: ${JSON.stringify(interpreter.tone)}
Avoid: ${JSON.stringify(interpreter.avoid)} Avoid: ${JSON.stringify(interpreter.avoid)}
Rank these shows: Rank these ${mediaLabel}s:
${chunkTitles}`, ${chunkTitles}`,
}); });

View File

@@ -1,5 +1,5 @@
import { openai } from '../agent.js'; 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 { z } from 'zod';
import { zodTextFormat } from 'openai/helpers/zod'; import { zodTextFormat } from 'openai/helpers/zod';
@@ -10,33 +10,39 @@ const RetrievalSchema = z.object({
})) }))
}); });
export async function runRetrieval(input: InterpreterOutput, brainstormCount = 100): Promise<RetrievalOutput> { export async function runRetrieval(
input: InterpreterOutput,
brainstormCount = 100,
mediaType: MediaType = 'tv_show',
useWebSearch = false,
): Promise<RetrievalOutput> {
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV shows';
const response = await openai.responses.parse({ const response = await openai.responses.parse({
model: 'gpt-5.4', model: 'gpt-5.4',
temperature: 0.9, temperature: 0.9,
service_tier: 'flex', service_tier: 'flex',
tools: [ ...(useWebSearch ? { tools: [{ type: 'web_search' as const }] } : {}),
{ type: 'web_search' }
],
text: { format: zodTextFormat(RetrievalSchema, "candidates") }, 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: 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 - 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 - 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`, - Aim for ${brainstormCount} candidates minimum`,
input: `Structured preferences: input: `Structured preferences:
Liked shows: ${JSON.stringify(input.liked)} Liked ${mediaLabelPlural}: ${JSON.stringify(input.liked)}
Disliked shows: ${JSON.stringify(input.disliked)} Disliked ${mediaLabelPlural}: ${JSON.stringify(input.disliked)}
Themes: ${JSON.stringify(input.themes)} Themes: ${JSON.stringify(input.themes)}
Character preferences: ${JSON.stringify(input.character_preferences)} Character preferences: ${JSON.stringify(input.character_preferences)}
Tone: ${JSON.stringify(input.tone)} Tone: ${JSON.stringify(input.tone)}
Avoid: ${JSON.stringify(input.avoid)} 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: [] }; return (response.output_parsed as RetrievalOutput) ?? { candidates: [] };

View File

@@ -1,12 +1,14 @@
import { openai } from '../agent.js'; 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<string> {
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
export async function generateTitle(interpreter: InterpreterOutput): Promise<string> {
const response = await openai.responses.create({ const response = await openai.responses.create({
model: 'gpt-5.4-mini', model: 'gpt-5.4-mini',
temperature: 0.7, temperature: 0.7,
service_tier: 'flex', 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. Capture the essence of the user's taste — genre, tone, key themes.
Respond with ONLY the title. No quotes, no trailing punctuation. Respond with ONLY the title. No quotes, no trailing punctuation.
Examples: "Dark Crime Dramas With Moral Ambiguity", "Cozy British Mysteries With Quirky Detectives"`, Examples: "Dark Crime Dramas With Moral Ambiguity", "Cozy British Mysteries With Quirky Detectives"`,

View File

@@ -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'; import type { CuratorOutput } from '../types/agents.js';
export const recommendations = pgTable('recommendations', { export const recommendations = pgTable('recommendations', {
@@ -9,6 +9,8 @@ export const recommendations = pgTable('recommendations', {
disliked_shows: text('disliked_shows').notNull().default(''), disliked_shows: text('disliked_shows').notNull().default(''),
themes: text('themes').notNull().default(''), themes: text('themes').notNull().default(''),
brainstorm_count: integer('brainstorm_count').notNull().default(100), 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<CuratorOutput[]>(), recommendations: jsonb('recommendations').$type<CuratorOutput[]>(),
status: text('status').notNull().default('pending'), status: text('status').notNull().default('pending'),
created_at: timestamp('created_at').defaultNow().notNull(), created_at: timestamp('created_at').defaultNow().notNull(),
@@ -18,10 +20,10 @@ export const feedback = pgTable(
'feedback', 'feedback',
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
tv_show_name: text('tv_show_name').notNull(), item_name: text('item_name').notNull(),
stars: integer('stars').notNull(), stars: integer('stars').notNull(),
feedback: text('feedback').notNull().default(''), feedback: text('feedback').notNull().default(''),
created_at: timestamp('created_at').defaultNow().notNull(), 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)],
); );

View File

@@ -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();

View File

@@ -5,14 +5,14 @@ import { runInterpreter } from '../agents/interpreter.js';
import { runRetrieval } from '../agents/retrieval.js'; import { runRetrieval } from '../agents/retrieval.js';
import { runRanking } from '../agents/ranking.js'; import { runRanking } from '../agents/ranking.js';
import { runCurator } from '../agents/curator.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'; import { generateTitle } from '../agents/titleGenerator.js';
/* -- Agent pipeline -- /* -- Agent pipeline --
[1] Interpreter -> gets user input, transforms into structured data [1] Interpreter -> gets user input, transforms into structured data
[2] Retrieval -> gets shows from OpenAI (high temperature) [2] Retrieval -> gets candidates from OpenAI (high temperature)
[3] Ranking -> ranks shows based on user input [3] Ranking -> ranks candidates based on user input
[4] Curator -> curates shows based on user input [4] Curator -> curates candidates based on user input
*/ */
type RecommendationRecord = typeof recommendations.$inferSelect; type RecommendationRecord = typeof recommendations.$inferSelect;
@@ -33,8 +33,10 @@ export async function runPipeline(
): Promise<CuratorOutput[]> { ): Promise<CuratorOutput[]> {
let currentStage: SSEEvent['stage'] = 'interpreter'; let currentStage: SSEEvent['stage'] = 'interpreter';
const startTime = Date.now(); 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 { try {
// Set status to running // Set status to running
@@ -54,6 +56,7 @@ export async function runPipeline(
liked_shows: rec.liked_shows, liked_shows: rec.liked_shows,
disliked_shows: rec.disliked_shows, disliked_shows: rec.disliked_shows,
themes: rec.themes, themes: rec.themes,
media_type: mediaType,
...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}), ...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}),
}); });
log(rec.id, `Interpreter: done (${Date.now() - t0}ms)`, { log(rec.id, `Interpreter: done (${Date.now() - t0}ms)`, {
@@ -70,7 +73,7 @@ export async function runPipeline(
log(rec.id, 'Retrieval: start'); log(rec.id, 'Retrieval: start');
sseWrite({ stage: 'retrieval', status: 'start' }); sseWrite({ stage: 'retrieval', status: 'start' });
const t1 = Date.now(); 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`, { log(rec.id, `Retrieval: done (${Date.now() - t1}ms) — ${retrievalOutput.candidates.length} candidates`, {
titles: retrievalOutput.candidates.map((c) => c.title), titles: retrievalOutput.candidates.map((c) => c.title),
}); });
@@ -81,7 +84,7 @@ export async function runPipeline(
log(rec.id, 'Ranking: start'); log(rec.id, 'Ranking: start');
sseWrite({ stage: 'ranking', status: 'start' }); sseWrite({ stage: 'ranking', status: 'start' });
const t2 = Date.now(); 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)`, { log(rec.id, `Ranking: done (${Date.now() - t2}ms)`, {
definitely_like: rankingOutput.definitely_like.length, definitely_like: rankingOutput.definitely_like.length,
might_like: rankingOutput.might_like.length, might_like: rankingOutput.might_like.length,
@@ -95,15 +98,15 @@ export async function runPipeline(
log(rec.id, 'Curator: start'); log(rec.id, 'Curator: start');
sseWrite({ stage: 'curator', status: 'start' }); sseWrite({ stage: 'curator', status: 'start' });
const t3 = Date.now(); const t3 = Date.now();
const curatorOutput = await runCurator(rankingOutput, interpreterOutput); const curatorOutput = await runCurator(rankingOutput, interpreterOutput, mediaType, useWebSearch);
log(rec.id, `Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} shows curated`); log(rec.id, `Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} items curated`);
sseWrite({ stage: 'curator', status: 'done', data: curatorOutput }); sseWrite({ stage: 'curator', status: 'done', data: curatorOutput });
// Generate AI title // Generate AI title
let aiTitle: string = rec.title; let aiTitle: string = rec.title;
try { try {
log(rec.id, 'Title generation: start'); log(rec.id, 'Title generation: start');
aiTitle = await generateTitle(interpreterOutput); aiTitle = await generateTitle(interpreterOutput, mediaType);
log(rec.id, `Title generation: done — "${aiTitle}"`); log(rec.id, `Title generation: done — "${aiTitle}"`);
} catch (err) { } catch (err) {
log(rec.id, `Title generation failed, keeping initial title: ${String(err)}`); log(rec.id, `Title generation failed, keeping initial title: ${String(err)}`);

View File

@@ -1,13 +1,12 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { db } from '../db.js'; import { db } from '../db.js';
import { feedback } from '../db/schema.js'; import { feedback } from '../db/schema.js';
export default async function feedbackRoute(fastify: FastifyInstance) { 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) => { fastify.post('/feedback', async (request, reply) => {
const body = request.body as { const body = request.body as {
tv_show_name: string; item_name: string;
stars: number; stars: number;
feedback?: string; feedback?: string;
}; };
@@ -15,12 +14,12 @@ export default async function feedbackRoute(fastify: FastifyInstance) {
await db await db
.insert(feedback) .insert(feedback)
.values({ .values({
tv_show_name: body.tv_show_name, item_name: body.item_name,
stars: body.stars, stars: body.stars,
feedback: body.feedback ?? '', feedback: body.feedback ?? '',
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: feedback.tv_show_name, target: feedback.item_name,
set: { set: {
stars: body.stars, stars: body.stars,
feedback: body.feedback ?? '', feedback: body.feedback ?? '',

View File

@@ -3,7 +3,7 @@ import { eq, desc } from 'drizzle-orm';
import { db } from '../db.js'; import { db } from '../db.js';
import { recommendations, feedback } from '../db/schema.js'; import { recommendations, feedback } from '../db/schema.js';
import { runPipeline } from '../pipelines/recommendation.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) { export default async function recommendationsRoute(fastify: FastifyInstance) {
// POST /recommendations — create record, return { id } // POST /recommendations — create record, return { id }
@@ -14,6 +14,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
disliked_shows?: string; disliked_shows?: string;
themes?: string; themes?: string;
brainstorm_count?: number; brainstorm_count?: number;
media_type?: string;
use_web_search?: boolean;
}; };
const title = (body.main_prompt ?? '') const title = (body.main_prompt ?? '')
@@ -24,6 +26,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
const rawCount = Number(body.brainstorm_count ?? 100); const rawCount = Number(body.brainstorm_count ?? 100);
const brainstorm_count = Number.isFinite(rawCount) ? Math.min(200, Math.max(50, rawCount)) : 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 const [rec] = await db
.insert(recommendations) .insert(recommendations)
@@ -34,6 +38,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
disliked_shows: body.disliked_shows ?? '', disliked_shows: body.disliked_shows ?? '',
themes: body.themes ?? '', themes: body.themes ?? '',
brainstorm_count, brainstorm_count,
media_type,
use_web_search,
status: 'pending', status: 'pending',
}) })
.returning({ id: recommendations.id }); .returning({ id: recommendations.id });
@@ -48,6 +54,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
id: recommendations.id, id: recommendations.id,
title: recommendations.title, title: recommendations.title,
status: recommendations.status, status: recommendations.status,
media_type: recommendations.media_type,
created_at: recommendations.created_at, created_at: recommendations.created_at,
}) })
.from(recommendations) .from(recommendations)
@@ -68,7 +75,6 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
}); });
// GET /recommendations/:id/stream — SSE pipeline stream // 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) => { fastify.get('/recommendations/:id/stream', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const [rec] = await db const [rec] = await db
@@ -80,12 +86,13 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
// Load all feedback to potentially inject as context // Load all feedback to potentially inject as context
const feedbackRows = await db.select().from(feedback); const feedbackRows = await db.select().from(feedback);
const mediaLabel = rec.media_type === 'movie' ? 'Movie' : 'Show';
const feedbackContext = const feedbackContext =
feedbackRows.length > 0 feedbackRows.length > 0
? feedbackRows ? feedbackRows
.map( .map(
(f) => (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') .join('\n')
: undefined; : undefined;

View File

@@ -1,3 +1,5 @@
export type MediaType = 'tv_show' | 'movie';
export interface InterpreterOutput { export interface InterpreterOutput {
liked: string[]; liked: string[];
disliked: string[]; disliked: string[];

View File

@@ -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'; const BASE = '/api';
@@ -20,6 +20,8 @@ export function createRecommendation(body: {
disliked_shows: string; disliked_shows: string;
themes: string; themes: string;
brainstorm_count?: number; brainstorm_count?: number;
media_type: MediaType;
use_web_search?: boolean;
}): Promise<{ id: string }> { }): Promise<{ id: string }> {
return request('/recommendations', { return request('/recommendations', {
method: 'POST', method: 'POST',
@@ -40,7 +42,7 @@ export function rerankRecommendation(id: string): Promise<{ ok: boolean }> {
} }
export function submitFeedback(body: { export function submitFeedback(body: {
tv_show_name: string; item_name: string;
stars: number; stars: number;
feedback?: string; feedback?: string;
}): Promise<{ ok: boolean }> { }): Promise<{ ok: boolean }> {

View File

@@ -103,3 +103,145 @@
gap: 10px; gap: 10px;
padding-top: 4px; 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);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import type { MediaType } from '../types/index.js';
import './Modal.css'; import './Modal.css';
interface NewRecommendationModalProps { interface NewRecommendationModalProps {
@@ -9,17 +10,30 @@ interface NewRecommendationModalProps {
disliked_shows: string; disliked_shows: string;
themes: string; themes: string;
brainstorm_count?: number; brainstorm_count?: number;
media_type: MediaType;
use_web_search?: boolean;
}) => Promise<void>; }) => Promise<void>;
} }
export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) { export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) {
const [step, setStep] = useState<'type' | 'form'>('type');
const [mediaType, setMediaType] = useState<MediaType>('tv_show');
const [mainPrompt, setMainPrompt] = useState(''); const [mainPrompt, setMainPrompt] = useState('');
const [likedShows, setLikedShows] = useState(''); const [likedShows, setLikedShows] = useState('');
const [dislikedShows, setDislikedShows] = useState(''); const [dislikedShows, setDislikedShows] = useState('');
const [themes, setThemes] = useState(''); const [themes, setThemes] = useState('');
const [brainstormCount, setBrainstormCount] = useState(100); const [brainstormCount, setBrainstormCount] = useState(100);
const [useWebSearch, setUseWebSearch] = useState(false);
const [loading, setLoading] = 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) => { const handleSubmit = async (e: Event) => {
e.preventDefault(); e.preventDefault();
if (!mainPrompt.trim()) return; if (!mainPrompt.trim()) return;
@@ -31,6 +45,8 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
disliked_shows: dislikedShows.trim(), disliked_shows: dislikedShows.trim(),
themes: themes.trim(), themes: themes.trim(),
brainstorm_count: brainstormCount, brainstorm_count: brainstormCount,
media_type: mediaType,
use_web_search: useWebSearch,
}); });
onClose(); onClose();
} finally { } finally {
@@ -47,10 +63,37 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
return ( return (
<div class="modal-backdrop" onClick={handleBackdropClick}> <div class="modal-backdrop" onClick={handleBackdropClick}>
<div class="modal"> <div class="modal">
{step === 'type' ? (
<>
<div class="modal-header"> <div class="modal-header">
<h2>New Recommendation</h2> <h2>New Recommendation</h2>
<button class="modal-close" onClick={onClose} aria-label="Close">×</button> <button class="modal-close" onClick={onClose} aria-label="Close">×</button>
</div> </div>
<div class="modal-type-select">
<p class="modal-type-hint">What would you like recommendations for?</p>
<div class="modal-type-cards">
<button class="type-card" onClick={() => handleSelectType('tv_show')}>
<span class="type-card-icon">📺</span>
<span class="type-card-label">TV Shows</span>
<span class="type-card-desc">Series &amp; episodic content</span>
</button>
<button class="type-card" onClick={() => handleSelectType('movie')}>
<span class="type-card-icon">🎬</span>
<span class="type-card-label">Movies</span>
<span class="type-card-desc">Feature films &amp; cinema</span>
</button>
</div>
</div>
</>
) : (
<>
<div class="modal-header">
<div class="modal-header-left">
<button class="modal-back" onClick={() => setStep('type')} aria-label="Back"></button>
<h2>New {mediaLabel} Recommendation</h2>
</div>
<button class="modal-close" onClick={onClose} aria-label="Close">×</button>
</div>
<form class="modal-form" onSubmit={handleSubmit}> <form class="modal-form" onSubmit={handleSubmit}>
<div class="form-group"> <div class="form-group">
@@ -58,7 +101,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
<textarea <textarea
id="main-prompt" id="main-prompt"
class="form-textarea" class="form-textarea"
placeholder="Describe what you want to watch. Be as specific as you like — mood, themes, setting, what you've enjoyed before..." placeholder={`Describe what you want to watch. Be as specific as you like — mood, themes, setting, what you've enjoyed before...`}
value={mainPrompt} value={mainPrompt}
onInput={(e) => setMainPrompt((e.target as HTMLTextAreaElement).value)} onInput={(e) => setMainPrompt((e.target as HTMLTextAreaElement).value)}
rows={5} rows={5}
@@ -67,24 +110,24 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="liked-shows">Shows you liked</label> <label for="liked-shows">{mediaLabel}s you liked</label>
<input <input
id="liked-shows" id="liked-shows"
type="text" type="text"
class="form-input" class="form-input"
placeholder="e.g. Breaking Bad, The Wire" placeholder={mediaType === 'movie' ? 'e.g. Inception, The Godfather' : 'e.g. Breaking Bad, The Wire'}
value={likedShows} value={likedShows}
onInput={(e) => setLikedShows((e.target as HTMLInputElement).value)} onInput={(e) => setLikedShows((e.target as HTMLInputElement).value)}
/> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="disliked-shows">Shows you disliked</label> <label for="disliked-shows">{mediaLabel}s you disliked</label>
<input <input
id="disliked-shows" id="disliked-shows"
type="text" type="text"
class="form-input" class="form-input"
placeholder="e.g. Game of Thrones" placeholder={mediaType === 'movie' ? 'e.g. Transformers' : 'e.g. Game of Thrones'}
value={dislikedShows} value={dislikedShows}
onInput={(e) => setDislikedShows((e.target as HTMLInputElement).value)} onInput={(e) => setDislikedShows((e.target as HTMLInputElement).value)}
/> />
@@ -103,7 +146,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="brainstorm-count">Shows to brainstorm ({brainstormCount})</label> <label for="brainstorm-count">{mediaLabel}s to brainstorm ({brainstormCount})</label>
<input <input
id="brainstorm-count" id="brainstorm-count"
type="range" type="range"
@@ -116,6 +159,18 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
/> />
</div> </div>
<div class="form-group-toggle">
<label class="toggle-label" for="web-search">
<div class="toggle-text">
<span class="toggle-title">Web Search</span>
<span class="toggle-desc">Use real-time web search for more accurate and up-to-date {mediaPluralLabel}</span>
</div>
<div class={`toggle-switch${useWebSearch ? ' on' : ''}`} onClick={() => setUseWebSearch((v) => !v)}>
<div class="toggle-knob" />
</div>
</label>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-secondary" onClick={onClose} disabled={loading}> <button type="button" class="btn-secondary" onClick={onClose} disabled={loading}>
Cancel Cancel
@@ -125,6 +180,8 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
</button> </button>
</div> </div>
</form> </form>
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -5,7 +5,7 @@ import type { CuratorOutput, CuratorCategory } from '../types/index.js';
interface RecommendationCardProps { interface RecommendationCardProps {
show: CuratorOutput; show: CuratorOutput;
existingFeedback?: { stars: number; feedback: string }; existingFeedback?: { stars: number; feedback: string };
onFeedback: (tv_show_name: string, stars: number, feedback: string) => Promise<void>; onFeedback: (item_name: string, stars: number, feedback: string) => Promise<void>;
} }
const CATEGORY_COLORS: Record<CuratorCategory, string> = { const CATEGORY_COLORS: Record<CuratorCategory, string> = {

View File

@@ -121,4 +121,25 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1;
}
.sidebar-type-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 2px 5px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sidebar-type-tv_show {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
}
.sidebar-type-movie {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
} }

View File

@@ -53,6 +53,9 @@ export function Sidebar({ list, selectedId, onSelect, onNewClick }: SidebarProps
{statusIcon(item.status)} {statusIcon(item.status)}
</span> </span>
<span class="sidebar-item-title">{item.title}</span> <span class="sidebar-item-title">{item.title}</span>
<span class={`sidebar-type-badge sidebar-type-${item.media_type}`}>
{item.media_type === 'movie' ? 'Film' : 'TV'}
</span>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'preact/hooks'; import { useState, useEffect, useCallback } from 'preact/hooks';
import type { RecommendationSummary, FeedbackEntry } from '../types/index.js'; import type { MediaType, RecommendationSummary, FeedbackEntry } from '../types/index.js';
import { import {
listRecommendations, listRecommendations,
createRecommendation, createRecommendation,
@@ -35,6 +35,8 @@ export function useRecommendations() {
disliked_shows: string; disliked_shows: string;
themes: string; themes: string;
brainstorm_count?: number; brainstorm_count?: number;
media_type: MediaType;
use_web_search?: boolean;
}) => { }) => {
const { id } = await createRecommendation(body); const { id } = await createRecommendation(body);
await refreshList(); await refreshList();
@@ -47,7 +49,6 @@ export function useRecommendations() {
const rerank = useCallback( const rerank = useCallback(
async (id: string) => { async (id: string) => {
await rerankRecommendation(id); await rerankRecommendation(id);
// Update local list to show pending status
setList((prev) => setList((prev) =>
prev.map((r) => (r.id === id ? { ...r, status: 'pending' as const } : r)), prev.map((r) => (r.id === id ? { ...r, status: 'pending' as const } : r)),
); );
@@ -56,7 +57,7 @@ export function useRecommendations() {
); );
const handleSubmitFeedback = useCallback( const handleSubmitFeedback = useCallback(
async (body: { tv_show_name: string; stars: number; feedback?: string }) => { async (body: { item_name: string; stars: number; feedback?: string }) => {
await submitFeedback(body); await submitFeedback(body);
await refreshFeedback(); await refreshFeedback();
}, },

View File

@@ -17,6 +17,8 @@ export function Home() {
disliked_shows: string; disliked_shows: string;
themes: string; themes: string;
brainstorm_count?: number; brainstorm_count?: number;
media_type: import('../types/index.js').MediaType;
use_web_search?: boolean;
}) => { }) => {
const id = await createNew(body); const id = await createNew(body);
route(`/recom/${id}`); route(`/recom/${id}`);
@@ -33,7 +35,7 @@ export function Home() {
/> />
<main class="landing-main"> <main class="landing-main">
<h1 class="landing-title">Recommender</h1> <h1 class="landing-title">Recommender</h1>
<p class="landing-tagline">Discover your next favorite show, powered by AI.</p> <p class="landing-tagline">Discover your next favorite show or movie, powered by AI.</p>
<button class="btn-gradient" onClick={() => setShowModal(true)}> <button class="btn-gradient" onClick={() => setShowModal(true)}>
Get Started Get Started
</button> </button>

View File

@@ -93,13 +93,15 @@ export function Recom({ id }: RecomProps) {
disliked_shows: string; disliked_shows: string;
themes: string; themes: string;
brainstorm_count?: number; brainstorm_count?: number;
media_type: import('../types/index.js').MediaType;
use_web_search?: boolean;
}) => { }) => {
const newId = await createNew(body); const newId = await createNew(body);
route(`/recom/${newId}`); route(`/recom/${newId}`);
}; };
const isRunning = rec?.status === 'running' || rec?.status === 'pending' || !!sseUrl; const isRunning = rec?.status === 'running' || rec?.status === 'pending' || !!sseUrl;
const feedbackMap = new Map(feedback.map((f) => [f.tv_show_name, f])); const feedbackMap = new Map(feedback.map((f) => [f.item_name, f]));
return ( return (
<div class="layout"> <div class="layout">
@@ -128,7 +130,7 @@ export function Recom({ id }: RecomProps) {
show={show} show={show}
existingFeedback={feedbackMap.get(show.title)} existingFeedback={feedbackMap.get(show.title)}
onFeedback={async (name, stars, comment) => { onFeedback={async (name, stars, comment) => {
await submitFeedback({ tv_show_name: name, stars, feedback: comment }); await submitFeedback({ item_name: name, stars, feedback: comment });
}} }}
/> />
))} ))}

View File

@@ -1,3 +1,5 @@
export type MediaType = 'tv_show' | 'movie';
export type CuratorCategory = 'Definitely Like' | 'Might Like' | 'Questionable' | 'Will Not Like'; export type CuratorCategory = 'Definitely Like' | 'Might Like' | 'Questionable' | 'Will Not Like';
export interface CuratorOutput { export interface CuratorOutput {
@@ -15,6 +17,8 @@ export interface Recommendation {
liked_shows: string; liked_shows: string;
disliked_shows: string; disliked_shows: string;
themes: string; themes: string;
media_type: MediaType;
use_web_search: boolean;
recommendations: CuratorOutput[] | null; recommendations: CuratorOutput[] | null;
status: RecommendationStatus; status: RecommendationStatus;
created_at: string; created_at: string;
@@ -24,12 +28,13 @@ export interface RecommendationSummary {
id: string; id: string;
title: string; title: string;
status: RecommendationStatus; status: RecommendationStatus;
media_type: MediaType;
created_at: string; created_at: string;
} }
export interface FeedbackEntry { export interface FeedbackEntry {
id: string; id: string;
tv_show_name: string; item_name: string;
stars: number; stars: number;
feedback: string; feedback: string;
created_at: string; created_at: string;