changes and improvements
All checks were successful
Recommender Build and Deploy (internal) / Build Recommender Image (push) Successful in 5m40s
Recommender Build and Deploy (internal) / Deploy Recommender (internal) (push) Successful in 38s

This commit is contained in:
2026-04-01 18:31:14 -03:00
parent a73bc27356
commit 39edec4a7c
10 changed files with 114 additions and 31 deletions

View File

@@ -7,7 +7,10 @@ const CuratorSchema = z.object({
shows: z.array(z.object({ shows: z.array(z.object({
title: z.string(), title: z.string(),
explanation: 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 mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
const allShows = [ 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.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 })),
...ranking.questionable.map((t) => ({ title: t, category: 'Questionable' as const })), ...ranking.questionable.map((t) => ({ title: t, category: 'Questionable' as const })),
@@ -39,19 +43,22 @@ export async function runCurator(
...serviceOptions, ...serviceOptions,
...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}), ...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}),
text: { format: zodTextFormat(CuratorSchema, "shows") }, 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: Rules:
- Preserve the exact title and category as given - Preserve the exact title and category as given
- Keep explanations concise (1-2 sentences max) - explanation: 1-2 sentences explaining why it was assigned to its category, referencing specific user preferences
- Reference specific user preferences in the explanation - 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`, - 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: ${interpreter.liked.join(', ') || '(none)'}
Themes: ${JSON.stringify(interpreter.themes)} Themes: ${interpreter.themes.join(', ') || '(none)'}
Tone: ${JSON.stringify(interpreter.tone)} Tone: ${interpreter.tone.join(', ') || '(none)'}
Character preferences: ${JSON.stringify(interpreter.character_preferences)} Character preferences: ${interpreter.character_preferences.join(', ') || '(none)'}
Avoid: ${JSON.stringify(interpreter.avoid)} Avoid: ${interpreter.avoid.join(', ') || '(none)'}
Requirements: ${interpreter.requirements.join(', ') || '(none)'}
${mediaLabel}s to describe: ${mediaLabel}s to describe:
${showList}`, ${showList}`,

View File

@@ -9,7 +9,8 @@ const InterpreterSchema = z.object({
themes: z.array(z.string()), themes: z.array(z.string()),
character_preferences: z.array(z.string()), character_preferences: z.array(z.string()),
tone: 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 { interface InterpreterInput {
@@ -39,7 +40,8 @@ Rules:
- Normalize terminology (e.g. "spy" → "espionage", "cop show" → "police procedural") - Normalize terminology (e.g. "spy" → "espionage", "cop show" → "police procedural")
- Detect and resolve contradictions (prefer explicit over implicit) - Detect and resolve contradictions (prefer explicit over implicit)
- 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
- 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} input: `Main prompt: ${input.main_prompt}
Liked ${mediaLabel}s: ${input.liked_shows || '(none)'} Liked ${mediaLabel}s: ${input.liked_shows || '(none)'}
Disliked ${mediaLabel}s: ${input.disliked_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) ?? { return (response.output_parsed as InterpreterOutput) ?? {
liked: [], disliked: [], themes: [], character_preferences: [], tone: [], avoid: [] liked: [], disliked: [], themes: [], character_preferences: [], tone: [], avoid: [], requirements: []
}; };
} }

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { zodTextFormat } from 'openai/helpers/zod'; import { zodTextFormat } from 'openai/helpers/zod';
const RankingSchema = z.object({ const RankingSchema = z.object({
full_match: z.array(z.string()),
definitely_like: z.array(z.string()), definitely_like: z.array(z.string()),
might_like: z.array(z.string()), might_like: z.array(z.string()),
questionable: z.array(z.string()), questionable: z.array(z.string()),
@@ -32,6 +33,7 @@ export async function runRanking(
} }
const allTags: RankingOutput = { const allTags: RankingOutput = {
full_match: [],
definitely_like: [], definitely_like: [],
might_like: [], might_like: [],
questionable: [], questionable: [],
@@ -46,21 +48,23 @@ export async function runRanking(
temperature: 0.2, temperature: 0.2,
...serviceOptions, ...serviceOptions,
text: { format: zodTextFormat(RankingSchema, "ranking") }, 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: 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 - "might_like": Strong match to most preferences
- "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 ${mediaLabel} in the input must appear in exactly one tag. Use the title exactly as given.`, Every ${mediaLabel} in the input must appear in exactly one tag. Use the title exactly as given.`,
input: `User preferences: input: `User preferences:
Liked ${mediaLabel}s: ${JSON.stringify(interpreter.liked)} Liked ${mediaLabel}s: ${interpreter.liked.join(', ') || '(none)'}
Themes: ${JSON.stringify(interpreter.themes)} Themes: ${interpreter.themes.join(', ') || '(none)'}
Character preferences: ${JSON.stringify(interpreter.character_preferences)} Character preferences: ${interpreter.character_preferences.join(', ') || '(none)'}
Tone: ${JSON.stringify(interpreter.tone)} Tone: ${interpreter.tone.join(', ') || '(none)'}
Avoid: ${JSON.stringify(interpreter.avoid)} Avoid: ${interpreter.avoid.join(', ') || '(none)'}
Requirements: ${interpreter.requirements.join(', ') || '(none)'}
Rank these ${mediaLabel}s: Rank these ${mediaLabel}s:
${chunkTitles}`, ${chunkTitles}`,
@@ -68,6 +72,7 @@ ${chunkTitles}`,
const chunkResult = (response.output_parsed as Partial<RankingOutput>) ?? {}; const chunkResult = (response.output_parsed as Partial<RankingOutput>) ?? {};
allTags.full_match.push(...(chunkResult.full_match ?? []));
allTags.definitely_like.push(...(chunkResult.definitely_like ?? [])); allTags.definitely_like.push(...(chunkResult.definitely_like ?? []));
allTags.might_like.push(...(chunkResult.might_like ?? [])); allTags.might_like.push(...(chunkResult.might_like ?? []));
allTags.questionable.push(...(chunkResult.questionable ?? [])); allTags.questionable.push(...(chunkResult.questionable ?? []));

View File

@@ -36,12 +36,13 @@ Rules:
- Include ${mediaLabelPlural} from different decades, countries${mediaType === 'tv_show' ? ', and networks' : ', and directors'} - 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 ${mediaLabelPlural}: ${JSON.stringify(input.liked)} Liked ${mediaLabelPlural}: ${input.liked.join(', ') || '(none)'}
Disliked ${mediaLabelPlural}: ${JSON.stringify(input.disliked)} Disliked ${mediaLabelPlural}: ${input.disliked.join(', ') || '(none)'}
Themes: ${JSON.stringify(input.themes)} Themes: ${input.themes.join(', ') || '(none)'}
Character preferences: ${JSON.stringify(input.character_preferences)} Character preferences: ${input.character_preferences.join(', ') || '(none)'}
Tone: ${JSON.stringify(input.tone)} Tone: ${input.tone.join(', ') || '(none)'}
Avoid: ${JSON.stringify(input.avoid)} Avoid: ${input.avoid.join(', ') || '(none)'}
Requirements: ${input.requirements.join(', ') || '(none)'}
Generate a large, diverse pool of ${mediaLabel} candidates.`, Generate a large, diverse pool of ${mediaLabel} candidates.`,
}); });

View File

@@ -124,12 +124,14 @@ export async function runPipeline(
) )
); );
const rankingOutput: RankingOutput = { const rankingOutput: RankingOutput = {
full_match: rankingBuckets.flatMap((r) => r.full_match),
definitely_like: rankingBuckets.flatMap((r) => r.definitely_like), definitely_like: rankingBuckets.flatMap((r) => r.definitely_like),
might_like: rankingBuckets.flatMap((r) => r.might_like), might_like: rankingBuckets.flatMap((r) => r.might_like),
questionable: rankingBuckets.flatMap((r) => r.questionable), questionable: rankingBuckets.flatMap((r) => r.questionable),
will_not_like: rankingBuckets.flatMap((r) => r.will_not_like), will_not_like: rankingBuckets.flatMap((r) => r.will_not_like),
}; };
log(rec.id, `Ranking: done (${Date.now() - t2}ms) — ${rankBucketCount} buckets`, { log(rec.id, `Ranking: done (${Date.now() - t2}ms) — ${rankBucketCount} buckets`, {
full_match: rankingOutput.full_match.length,
definitely_like: rankingOutput.definitely_like.length, definitely_like: rankingOutput.definitely_like.length,
might_like: rankingOutput.might_like.length, might_like: rankingOutput.might_like.length,
questionable: rankingOutput.questionable.length, questionable: rankingOutput.questionable.length,
@@ -144,6 +146,7 @@ export async function runPipeline(
const t3 = Date.now(); const t3 = Date.now();
type CategorizedItem = { title: string; category: keyof RankingOutput }; type CategorizedItem = { title: string; category: keyof RankingOutput };
const categorizedItems: CategorizedItem[] = [ 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.definitely_like.map((t) => ({ title: t, category: 'definitely_like' as const })),
...rankingOutput.might_like.map((t) => ({ title: t, category: 'might_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 })), ...rankingOutput.questionable.map((t) => ({ title: t, category: 'questionable' as const })),
@@ -152,6 +155,7 @@ export async function runPipeline(
const curatorBucketCount = getBucketCount(categorizedItems.length); const curatorBucketCount = getBucketCount(categorizedItems.length);
const curatorItemBuckets = splitIntoBuckets(categorizedItems, curatorBucketCount); const curatorItemBuckets = splitIntoBuckets(categorizedItems, curatorBucketCount);
const curatorBucketRankings: RankingOutput[] = curatorItemBuckets.map((bucket) => ({ 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), 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), might_like: bucket.filter((i) => i.category === 'might_like').map((i) => i.title),
questionable: bucket.filter((i) => i.category === 'questionable').map((i) => i.title), questionable: bucket.filter((i) => i.category === 'questionable').map((i) => i.title),

View File

@@ -7,6 +7,7 @@ export interface InterpreterOutput {
character_preferences: string[]; character_preferences: string[];
tone: string[]; tone: string[];
avoid: string[]; avoid: string[];
requirements: string[];
} }
export interface RetrievalCandidate { export interface RetrievalCandidate {
@@ -19,18 +20,22 @@ export interface RetrievalOutput {
} }
export interface RankingOutput { export interface RankingOutput {
full_match: string[];
definitely_like: string[]; definitely_like: string[];
might_like: string[]; might_like: string[];
questionable: string[]; questionable: string[];
will_not_like: 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 { export interface CuratorOutput {
title: string; title: string;
explanation: string; explanation: string;
category: CuratorCategory; category: CuratorCategory;
genre: string;
pros: string[];
cons: string[];
} }
export type PipelineStage = 'interpreter' | 'retrieval' | 'ranking' | 'curator' | 'complete'; export type PipelineStage = 'interpreter' | 'retrieval' | 'ranking' | 'curator' | 'complete';

View File

@@ -57,6 +57,11 @@
color: #f87171; color: #f87171;
} }
.badge-magenta {
background: rgba(217, 70, 239, 0.15);
color: #e879f9;
}
.card-title { .card-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -67,7 +72,42 @@
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.5; 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 { .card-feedback {

View File

@@ -9,6 +9,7 @@ interface RecommendationCardProps {
} }
const CATEGORY_COLORS: Record<CuratorCategory, string> = { const CATEGORY_COLORS: Record<CuratorCategory, string> = {
'Full Match': 'badge-magenta',
'Definitely Like': 'badge-green', 'Definitely Like': 'badge-green',
'Might Like': 'badge-blue', 'Might Like': 'badge-blue',
'Questionable': 'badge-yellow', 'Questionable': 'badge-yellow',
@@ -48,6 +49,19 @@ export function RecommendationCard({ show, existingFeedback, onFeedback }: Recom
</div> </div>
<p class="card-explanation">{show.explanation}</p> <p class="card-explanation">{show.explanation}</p>
{show.genre && <span class="badge genre-badge">{show.genre}</span>}
{(show.pros?.length > 0 || show.cons?.length > 0) && (
<div class="pros-cons-table">
<div class="pros-col">
{show.pros?.map((p) => <span class="pro-item">+ {p}</span>)}
</div>
<div class="cons-col">
{show.cons?.map((c) => <span class="con-item">- {c}</span>)}
</div>
</div>
)}
<div class="card-feedback"> <div class="card-feedback">
<div class="star-rating"> <div class="star-rating">
{[1, 2, 3].map((star) => { {[1, 2, 3].map((star) => {

View File

@@ -185,6 +185,11 @@ export function Recom({ id }: RecomProps) {
</div> </div>
)} )}
<div class="rec-info-row rec-info-delete-row"> <div class="rec-info-row rec-info-delete-row">
{!isRunning && (
<button class="btn-rerun btn-rerank" onClick={handleRetry}>
Re-run Pipeline
</button>
)}
<button <button
class="btn-danger" class="btn-danger"
onClick={() => { onClick={() => {
@@ -223,9 +228,6 @@ export function Recom({ id }: RecomProps) {
))} ))}
</div> </div>
<div class="rerank-section"> <div class="rerank-section">
<button class="btn-rerank btn-rerun" onClick={handleRetry}>
Re-run Pipeline
</button>
<button <button
class="btn-rerank" class="btn-rerank"
onClick={handleRerank} onClick={handleRerank}

View File

@@ -1,11 +1,14 @@
export type MediaType = 'tv_show' | 'movie'; export type MediaType = 'tv_show' | 'movie';
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 { export interface CuratorOutput {
title: string; title: string;
explanation: string; explanation: string;
category: CuratorCategory; category: CuratorCategory;
genre: string;
pros: string[];
cons: string[];
} }
export type RecommendationStatus = 'pending' | 'running' | 'done' | 'error'; export type RecommendationStatus = 'pending' | 'running' | 'done' | 'error';