adding movies & web search tool
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
|
|||||||
@@ -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"`,
|
||||||
|
|||||||
@@ -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)],
|
||||||
);
|
);
|
||||||
|
|||||||
32
packages/backend/src/migrate.ts
Normal file
32
packages/backend/src/migrate.ts
Normal 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();
|
||||||
@@ -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)}`);
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type MediaType = 'tv_show' | 'movie';
|
||||||
|
|
||||||
export interface InterpreterOutput {
|
export interface InterpreterOutput {
|
||||||
liked: string[];
|
liked: string[];
|
||||||
disliked: string[];
|
disliked: string[];
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 & 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 & 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user