changes and improvements
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<RankingOutput>) ?? {};
|
||||
|
||||
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 ?? []));
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ interface RecommendationCardProps {
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<CuratorCategory, string> = {
|
||||
'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
|
||||
</div>
|
||||
<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="star-rating">
|
||||
{[1, 2, 3].map((star) => {
|
||||
|
||||
@@ -185,6 +185,11 @@ export function Recom({ id }: RecomProps) {
|
||||
</div>
|
||||
)}
|
||||
<div class="rec-info-row rec-info-delete-row">
|
||||
{!isRunning && (
|
||||
<button class="btn-rerun btn-rerank" onClick={handleRetry}>
|
||||
Re-run Pipeline
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class="btn-danger"
|
||||
onClick={() => {
|
||||
@@ -223,9 +228,6 @@ export function Recom({ id }: RecomProps) {
|
||||
))}
|
||||
</div>
|
||||
<div class="rerank-section">
|
||||
<button class="btn-rerank btn-rerun" onClick={handleRetry}>
|
||||
Re-run Pipeline
|
||||
</button>
|
||||
<button
|
||||
class="btn-rerank"
|
||||
onClick={handleRerank}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 {
|
||||
title: string;
|
||||
explanation: string;
|
||||
category: CuratorCategory;
|
||||
genre: string;
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
}
|
||||
|
||||
export type RecommendationStatus = 'pending' | 'running' | 'done' | 'error';
|
||||
|
||||
Reference in New Issue
Block a user