diff --git a/packages/backend/src/agents/curator.ts b/packages/backend/src/agents/curator.ts index 235e50b..3182bc8 100644 --- a/packages/backend/src/agents/curator.ts +++ b/packages/backend/src/agents/curator.ts @@ -7,7 +7,10 @@ const CuratorSchema = z.object({ shows: z.array(z.object({ title: z.string(), explanation: z.string(), - category: z.enum(["Definitely Like", "Might Like", "Questionable", "Will Not Like"]) + category: z.enum(["Full Match", "Definitely Like", "Might Like", "Questionable", "Will Not Like"]), + genre: z.string(), + pros: z.array(z.string()).max(3), + cons: z.array(z.string()).max(3) })) }); @@ -20,6 +23,7 @@ export async function runCurator( const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show'; const allShows = [ + ...(ranking.full_match ?? []).map((t) => ({ title: t, category: 'Full Match' as const })), ...ranking.definitely_like.map((t) => ({ title: t, category: 'Definitely Like' as const })), ...ranking.might_like.map((t) => ({ title: t, category: 'Might Like' as const })), ...ranking.questionable.map((t) => ({ title: t, category: 'Questionable' as const })), @@ -39,19 +43,22 @@ export async function runCurator( ...serviceOptions, ...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}), text: { format: zodTextFormat(CuratorSchema, "shows") }, - 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.' : ''} + instructions: `You are a ${mediaLabel} recommendation curator. For each ${mediaLabel}, write a concise explanation and surface the most useful details for the user.${useWebSearch ? '\n\nUse web search to verify details and enrich explanations with accurate information.' : ''} Rules: - Preserve the exact title and category as given -- Keep explanations concise (1-2 sentences max) -- Reference specific user preferences in the explanation +- explanation: 1-2 sentences explaining why it was assigned to its category, referencing specific user preferences +- genre: 1-3 words capturing the most prominent genre of the title (e.g. "Crime Drama", "Sci-Fi Thriller", "Romantic Comedy") +- pros: up to 3 short bullet points about what this title does well relative to the user's taste +- cons: up to 3 short bullet points about what the user might not like based on their preferences - Be honest — explain why "Questionable" or "Will Not Like" ${mediaLabel}s got that rating`, input: `User preferences summary: -Liked: ${JSON.stringify(interpreter.liked)} -Themes: ${JSON.stringify(interpreter.themes)} -Tone: ${JSON.stringify(interpreter.tone)} -Character preferences: ${JSON.stringify(interpreter.character_preferences)} -Avoid: ${JSON.stringify(interpreter.avoid)} +Liked: ${interpreter.liked.join(', ') || '(none)'} +Themes: ${interpreter.themes.join(', ') || '(none)'} +Tone: ${interpreter.tone.join(', ') || '(none)'} +Character preferences: ${interpreter.character_preferences.join(', ') || '(none)'} +Avoid: ${interpreter.avoid.join(', ') || '(none)'} +Requirements: ${interpreter.requirements.join(', ') || '(none)'} ${mediaLabel}s to describe: ${showList}`, diff --git a/packages/backend/src/agents/interpreter.ts b/packages/backend/src/agents/interpreter.ts index 6638198..3125988 100644 --- a/packages/backend/src/agents/interpreter.ts +++ b/packages/backend/src/agents/interpreter.ts @@ -9,7 +9,8 @@ const InterpreterSchema = z.object({ themes: z.array(z.string()), character_preferences: z.array(z.string()), tone: z.array(z.string()), - avoid: z.array(z.string()) + avoid: z.array(z.string()), + requirements: z.array(z.string()) }); interface InterpreterInput { @@ -39,7 +40,8 @@ Rules: - Normalize terminology (e.g. "spy" → "espionage", "cop show" → "police procedural") - Detect and resolve contradictions (prefer explicit over implicit) - Do NOT assume anything not stated or clearly implied -- Be specific and concrete, not vague`, +- Be specific and concrete, not vague +- For "requirements": capture explicit hard requirements the user stated that recommendations must satisfy — things like "must be from the 2000s onward", "must have subtitles", "must feature a female lead". Leave empty if no such constraints were stated.`, input: `Main prompt: ${input.main_prompt} Liked ${mediaLabel}s: ${input.liked_shows || '(none)'} Disliked ${mediaLabel}s: ${input.disliked_shows || '(none)'} @@ -47,6 +49,6 @@ Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`, }); return (response.output_parsed as InterpreterOutput) ?? { - liked: [], disliked: [], themes: [], character_preferences: [], tone: [], avoid: [] + liked: [], disliked: [], themes: [], character_preferences: [], tone: [], avoid: [], requirements: [] }; } diff --git a/packages/backend/src/agents/ranking.ts b/packages/backend/src/agents/ranking.ts index dc89858..ea23b97 100644 --- a/packages/backend/src/agents/ranking.ts +++ b/packages/backend/src/agents/ranking.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { zodTextFormat } from 'openai/helpers/zod'; const RankingSchema = z.object({ + full_match: z.array(z.string()), definitely_like: z.array(z.string()), might_like: z.array(z.string()), questionable: z.array(z.string()), @@ -32,6 +33,7 @@ export async function runRanking( } const allTags: RankingOutput = { + full_match: [], definitely_like: [], might_like: [], questionable: [], @@ -46,21 +48,23 @@ export async function runRanking( temperature: 0.2, ...serviceOptions, text: { format: zodTextFormat(RankingSchema, "ranking") }, - instructions: `You are a ${mediaLabel} ranking critic. Assign each ${mediaLabel} to exactly one of four confidence tags based on how well it matches the user's preferences. + instructions: `You are a ${mediaLabel} ranking critic. Assign each ${mediaLabel} to exactly one of five confidence tags based on how well it matches the user's preferences. Tags: -- "definitely_like": Near-perfect match to all preferences +- "full_match": 100% match — perfectly satisfies every stated preference, requirement, and avoidance criteria with no compromises +- "definitely_like": Near-perfect match to all preferences with only minor caveats - "might_like": Strong match to most preferences - "questionable": Partial alignment, some aspects don't match - "will_not_like": Likely mismatch, conflicts with preferences or avoidance criteria Every ${mediaLabel} in the input must appear in exactly one tag. Use the title exactly as given.`, input: `User preferences: -Liked ${mediaLabel}s: ${JSON.stringify(interpreter.liked)} -Themes: ${JSON.stringify(interpreter.themes)} -Character preferences: ${JSON.stringify(interpreter.character_preferences)} -Tone: ${JSON.stringify(interpreter.tone)} -Avoid: ${JSON.stringify(interpreter.avoid)} +Liked ${mediaLabel}s: ${interpreter.liked.join(', ') || '(none)'} +Themes: ${interpreter.themes.join(', ') || '(none)'} +Character preferences: ${interpreter.character_preferences.join(', ') || '(none)'} +Tone: ${interpreter.tone.join(', ') || '(none)'} +Avoid: ${interpreter.avoid.join(', ') || '(none)'} +Requirements: ${interpreter.requirements.join(', ') || '(none)'} Rank these ${mediaLabel}s: ${chunkTitles}`, @@ -68,6 +72,7 @@ ${chunkTitles}`, const chunkResult = (response.output_parsed as Partial) ?? {}; + allTags.full_match.push(...(chunkResult.full_match ?? [])); allTags.definitely_like.push(...(chunkResult.definitely_like ?? [])); allTags.might_like.push(...(chunkResult.might_like ?? [])); allTags.questionable.push(...(chunkResult.questionable ?? [])); diff --git a/packages/backend/src/agents/retrieval.ts b/packages/backend/src/agents/retrieval.ts index b0ec073..859ec17 100644 --- a/packages/backend/src/agents/retrieval.ts +++ b/packages/backend/src/agents/retrieval.ts @@ -36,12 +36,13 @@ Rules: - Include ${mediaLabelPlural} from different decades, countries${mediaType === 'tv_show' ? ', and networks' : ', and directors'} - Aim for ${brainstormCount} candidates minimum`, input: `Structured preferences: -Liked ${mediaLabelPlural}: ${JSON.stringify(input.liked)} -Disliked ${mediaLabelPlural}: ${JSON.stringify(input.disliked)} -Themes: ${JSON.stringify(input.themes)} -Character preferences: ${JSON.stringify(input.character_preferences)} -Tone: ${JSON.stringify(input.tone)} -Avoid: ${JSON.stringify(input.avoid)} +Liked ${mediaLabelPlural}: ${input.liked.join(', ') || '(none)'} +Disliked ${mediaLabelPlural}: ${input.disliked.join(', ') || '(none)'} +Themes: ${input.themes.join(', ') || '(none)'} +Character preferences: ${input.character_preferences.join(', ') || '(none)'} +Tone: ${input.tone.join(', ') || '(none)'} +Avoid: ${input.avoid.join(', ') || '(none)'} +Requirements: ${input.requirements.join(', ') || '(none)'} Generate a large, diverse pool of ${mediaLabel} candidates.`, }); diff --git a/packages/backend/src/pipelines/recommendation.ts b/packages/backend/src/pipelines/recommendation.ts index 9840170..bf49b55 100644 --- a/packages/backend/src/pipelines/recommendation.ts +++ b/packages/backend/src/pipelines/recommendation.ts @@ -124,12 +124,14 @@ export async function runPipeline( ) ); const rankingOutput: RankingOutput = { + full_match: rankingBuckets.flatMap((r) => r.full_match), definitely_like: rankingBuckets.flatMap((r) => r.definitely_like), might_like: rankingBuckets.flatMap((r) => r.might_like), questionable: rankingBuckets.flatMap((r) => r.questionable), will_not_like: rankingBuckets.flatMap((r) => r.will_not_like), }; log(rec.id, `Ranking: done (${Date.now() - t2}ms) — ${rankBucketCount} buckets`, { + full_match: rankingOutput.full_match.length, definitely_like: rankingOutput.definitely_like.length, might_like: rankingOutput.might_like.length, questionable: rankingOutput.questionable.length, @@ -144,6 +146,7 @@ export async function runPipeline( const t3 = Date.now(); type CategorizedItem = { title: string; category: keyof RankingOutput }; const categorizedItems: CategorizedItem[] = [ + ...rankingOutput.full_match.map((t) => ({ title: t, category: 'full_match' as const })), ...rankingOutput.definitely_like.map((t) => ({ title: t, category: 'definitely_like' as const })), ...rankingOutput.might_like.map((t) => ({ title: t, category: 'might_like' as const })), ...rankingOutput.questionable.map((t) => ({ title: t, category: 'questionable' as const })), @@ -152,6 +155,7 @@ export async function runPipeline( const curatorBucketCount = getBucketCount(categorizedItems.length); const curatorItemBuckets = splitIntoBuckets(categorizedItems, curatorBucketCount); const curatorBucketRankings: RankingOutput[] = curatorItemBuckets.map((bucket) => ({ + full_match: bucket.filter((i) => i.category === 'full_match').map((i) => i.title), definitely_like: bucket.filter((i) => i.category === 'definitely_like').map((i) => i.title), might_like: bucket.filter((i) => i.category === 'might_like').map((i) => i.title), questionable: bucket.filter((i) => i.category === 'questionable').map((i) => i.title), diff --git a/packages/backend/src/types/agents.ts b/packages/backend/src/types/agents.ts index 3af8b2e..8ecde58 100644 --- a/packages/backend/src/types/agents.ts +++ b/packages/backend/src/types/agents.ts @@ -7,6 +7,7 @@ export interface InterpreterOutput { character_preferences: string[]; tone: string[]; avoid: string[]; + requirements: string[]; } export interface RetrievalCandidate { @@ -19,18 +20,22 @@ export interface RetrievalOutput { } export interface RankingOutput { + full_match: string[]; definitely_like: string[]; might_like: string[]; questionable: string[]; will_not_like: string[]; } -export type CuratorCategory = 'Definitely Like' | 'Might Like' | 'Questionable' | 'Will Not Like'; +export type CuratorCategory = 'Full Match' | 'Definitely Like' | 'Might Like' | 'Questionable' | 'Will Not Like'; export interface CuratorOutput { title: string; explanation: string; category: CuratorCategory; + genre: string; + pros: string[]; + cons: string[]; } export type PipelineStage = 'interpreter' | 'retrieval' | 'ranking' | 'curator' | 'complete'; diff --git a/packages/frontend/src/components/Cards.css b/packages/frontend/src/components/Cards.css index d748e91..7ccc647 100644 --- a/packages/frontend/src/components/Cards.css +++ b/packages/frontend/src/components/Cards.css @@ -57,6 +57,11 @@ color: #f87171; } +.badge-magenta { + background: rgba(217, 70, 239, 0.15); + color: #e879f9; +} + .card-title { font-size: 16px; font-weight: 600; @@ -67,7 +72,42 @@ font-size: 13px; color: var(--text-muted); line-height: 1.5; - margin-bottom: 12px; + margin-bottom: 8px; +} + +.genre-badge { + font-size: 10px; + font-weight: 500; + background: rgba(148, 163, 184, 0.12); + color: var(--text-dim); + display: inline-block; + margin-bottom: 10px; + text-transform: none; + letter-spacing: 0; +} + +.pros-cons-table { + display: flex; + gap: 16px; + margin-bottom: 10px; + font-size: 12px; + line-height: 1.5; +} + +.pros-col, +.cons-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pro-item { + color: #4ade80; +} + +.con-item { + color: #f87171; } .card-feedback { diff --git a/packages/frontend/src/components/RecommendationCard.tsx b/packages/frontend/src/components/RecommendationCard.tsx index 84d1abd..cc18d40 100644 --- a/packages/frontend/src/components/RecommendationCard.tsx +++ b/packages/frontend/src/components/RecommendationCard.tsx @@ -9,6 +9,7 @@ interface RecommendationCardProps { } const CATEGORY_COLORS: Record = { + 'Full Match': 'badge-magenta', 'Definitely Like': 'badge-green', 'Might Like': 'badge-blue', 'Questionable': 'badge-yellow', @@ -48,6 +49,19 @@ export function RecommendationCard({ show, existingFeedback, onFeedback }: Recom

{show.explanation}

+ {show.genre && {show.genre}} + + {(show.pros?.length > 0 || show.cons?.length > 0) && ( +
+
+ {show.pros?.map((p) => + {p})} +
+
+ {show.cons?.map((c) => - {c})} +
+
+ )} +
{[1, 2, 3].map((star) => { diff --git a/packages/frontend/src/pages/Recom.tsx b/packages/frontend/src/pages/Recom.tsx index 9958a73..0e0346b 100644 --- a/packages/frontend/src/pages/Recom.tsx +++ b/packages/frontend/src/pages/Recom.tsx @@ -185,6 +185,11 @@ export function Recom({ id }: RecomProps) {
)}
+ {!isRunning && ( + + )}
-