diff --git a/package-lock.json b/package-lock.json index 7953fa2..38d09e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4455,6 +4455,15 @@ "dev": true, "license": "MIT" }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/backend": { "version": "1.0.0", "license": "ISC", @@ -4463,7 +4472,8 @@ "drizzle-orm": "^0.45.1", "fastify": "^5.8.4", "openai": "^6.32.0", - "postgres": "^3.4.8" + "postgres": "^3.4.8", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^24.12.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 7b1fa97..9fb401d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,7 +16,8 @@ "drizzle-orm": "^0.45.1", "fastify": "^5.8.4", "openai": "^6.32.0", - "postgres": "^3.4.8" + "postgres": "^3.4.8", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^24.12.0", diff --git a/packages/backend/src/agents/curator.ts b/packages/backend/src/agents/curator.ts index 6c11769..6efad20 100644 --- a/packages/backend/src/agents/curator.ts +++ b/packages/backend/src/agents/curator.ts @@ -1,5 +1,15 @@ import { openai } from '../agent.js'; import type { InterpreterOutput, RankingOutput, CuratorOutput } from '../types/agents.js'; +import { z } from 'zod'; +import { zodTextFormat } from 'openai/helpers/zod'; + +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"]) + })) +}); export async function runCurator( ranking: RankingOutput, @@ -18,36 +28,22 @@ export async function runCurator( .map((s) => `- "${s.title}" (${s.category})`) .join('\n'); - const response = await openai.chat.completions.create({ - model: 'gpt-5.4-mini', + const response = await openai.responses.parse({ + model: 'gpt-5.4', temperature: 0.5, service_tier: 'flex', - response_format: { type: 'json_object' }, - messages: [ - { - role: 'system', - content: `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. - -Your output MUST be valid JSON: -{ - "shows": [ - { - "title": string, - "explanation": string, - "category": "Definitely Like" | "Might Like" | "Questionable" | "Will Not Like" - } - ] -} + tools: [ + { type: 'web_search' } + ], + 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. Rules: - Preserve the exact title and category as given - Keep explanations concise (1-2 sentences max) - Reference specific user preferences in the explanation - Be honest — explain why "Questionable" or "Will Not Like" shows got that rating`, - }, - { - role: 'user', - content: `User preferences summary: + input: `User preferences summary: Liked: ${JSON.stringify(interpreter.liked)} Themes: ${JSON.stringify(interpreter.themes)} Tone: ${JSON.stringify(interpreter.tone)} @@ -56,11 +52,7 @@ Avoid: ${JSON.stringify(interpreter.avoid)} Shows to describe: ${showList}`, - }, - ], }); - const content = response.choices[0]?.message?.content ?? '{"shows":[]}'; - const result = JSON.parse(content) as { shows: CuratorOutput[] }; - return result.shows ?? []; + return response.output_parsed?.shows ?? []; } diff --git a/packages/backend/src/agents/interpreter.ts b/packages/backend/src/agents/interpreter.ts index 259ed50..bfde989 100644 --- a/packages/backend/src/agents/interpreter.ts +++ b/packages/backend/src/agents/interpreter.ts @@ -1,5 +1,16 @@ import { openai } from '../agent.js'; import type { InterpreterOutput } from '../types/agents.js'; +import { z } from 'zod'; +import { zodTextFormat } from 'openai/helpers/zod'; + +const InterpreterSchema = z.object({ + liked: z.array(z.string()), + disliked: z.array(z.string()), + themes: z.array(z.string()), + character_preferences: z.array(z.string()), + tone: z.array(z.string()), + avoid: z.array(z.string()) +}); interface InterpreterInput { main_prompt: string; @@ -14,25 +25,12 @@ export async function runInterpreter(input: InterpreterInput): Promise "espionage") - "character_preferences": string[], // character types they prefer - "tone": string[], // tone descriptors (e.g. "serious", "grounded", "dark") - "avoid": string[] // things to explicitly avoid -} + text: { format: zodTextFormat(InterpreterSchema, "preferences") }, + instructions: `You are a TV show preference interpreter. Transform raw user input into structured, normalized preferences. Rules: - Extract implicit preferences from the main prompt @@ -40,17 +38,13 @@ Rules: - Detect and resolve contradictions (prefer explicit over implicit) - Do NOT assume anything not stated or clearly implied - Be specific and concrete, not vague`, - }, - { - role: 'user', - content: `Main prompt: ${input.main_prompt} + input: `Main prompt: ${input.main_prompt} Liked shows: ${input.liked_shows || '(none)'} Disliked shows: ${input.disliked_shows || '(none)'} Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`, - }, - ], }); - const content = response.choices[0]?.message?.content ?? '{}'; - return JSON.parse(content) as InterpreterOutput; + return (response.output_parsed as InterpreterOutput) ?? { + liked: [], disliked: [], themes: [], character_preferences: [], tone: [], avoid: [] + }; } diff --git a/packages/backend/src/agents/ranking.ts b/packages/backend/src/agents/ranking.ts index b1c04ee..bf0d53b 100644 --- a/packages/backend/src/agents/ranking.ts +++ b/packages/backend/src/agents/ranking.ts @@ -1,5 +1,14 @@ import { openai } from '../agent.js'; import type { InterpreterOutput, RetrievalOutput, RankingOutput } from '../types/agents.js'; +import { z } from 'zod'; +import { zodTextFormat } from 'openai/helpers/zod'; + +const RankingSchema = z.object({ + definitely_like: z.array(z.string()), + might_like: z.array(z.string()), + questionable: z.array(z.string()), + will_not_like: z.array(z.string()) +}); export async function runRanking( interpreter: InterpreterOutput, @@ -29,15 +38,12 @@ export async function runRanking( for (const chunk of chunks) { const chunkTitles = chunk.map((c) => `- ${c.title}: ${c.reason}`).join('\n'); - const response = await openai.chat.completions.create({ + const response = await openai.responses.parse({ model: 'gpt-5.4', temperature: 0.2, service_tier: 'flex', - response_format: { type: 'json_object' }, - messages: [ - { - role: 'system', - content: `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. + 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. Buckets: - "definitely_like": Near-perfect match to all preferences @@ -45,19 +51,8 @@ Buckets: - "questionable": Partial alignment, some aspects don't match - "will_not_like": Likely mismatch, conflicts with preferences or avoidance criteria -Your output MUST be valid JSON: -{ - "definitely_like": string[], - "might_like": string[], - "questionable": string[], - "will_not_like": string[] -} - Every show in the input must appear in exactly one bucket. Use the title exactly as given.`, - }, - { - role: 'user', - content: `User preferences: + input: `User preferences: Liked shows: ${JSON.stringify(interpreter.liked)} Themes: ${JSON.stringify(interpreter.themes)} Character preferences: ${JSON.stringify(interpreter.character_preferences)} @@ -66,12 +61,9 @@ Avoid: ${JSON.stringify(interpreter.avoid)} Rank these shows: ${chunkTitles}`, - }, - ], }); - const content = response.choices[0]?.message?.content ?? '{}'; - const chunkResult = JSON.parse(content) as Partial; + const chunkResult = (response.output_parsed as Partial) ?? {}; allBuckets.definitely_like.push(...(chunkResult.definitely_like ?? [])); allBuckets.might_like.push(...(chunkResult.might_like ?? [])); diff --git a/packages/backend/src/agents/retrieval.ts b/packages/backend/src/agents/retrieval.ts index 06640f8..cca74c7 100644 --- a/packages/backend/src/agents/retrieval.ts +++ b/packages/backend/src/agents/retrieval.ts @@ -1,23 +1,25 @@ import { openai } from '../agent.js'; import type { InterpreterOutput, RetrievalOutput } from '../types/agents.js'; +import { z } from 'zod'; +import { zodTextFormat } from 'openai/helpers/zod'; + +const RetrievalSchema = z.object({ + candidates: z.array(z.object({ + title: z.string(), + reason: z.string() + })) +}); export async function runRetrieval(input: InterpreterOutput, brainstormCount = 100): Promise { - const response = await openai.chat.completions.create({ + const response = await openai.responses.parse({ model: 'gpt-5.4', temperature: 0.9, service_tier: 'flex', - response_format: { type: 'json_object' }, - messages: [ - { - role: 'system', - content: `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. - -Your output MUST be valid JSON matching this schema: -{ - "candidates": [ - { "title": string, "reason": string } - ] -} + tools: [ + { type: 'web_search' } + ], + 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. Rules: - Include both well-known and obscure shows @@ -26,10 +28,7 @@ Rules: - Avoid duplicates - Include shows from different decades, countries, and networks - Aim for ${brainstormCount} candidates minimum`, - }, - { - role: 'user', - content: `Structured preferences: + input: `Structured preferences: Liked shows: ${JSON.stringify(input.liked)} Disliked shows: ${JSON.stringify(input.disliked)} Themes: ${JSON.stringify(input.themes)} @@ -38,10 +37,7 @@ Tone: ${JSON.stringify(input.tone)} Avoid: ${JSON.stringify(input.avoid)} Generate a large, diverse pool of TV show candidates.`, - }, - ], }); - const content = response.choices[0]?.message?.content ?? '{"candidates":[]}'; - return JSON.parse(content) as RetrievalOutput; + return (response.output_parsed as RetrievalOutput) ?? { candidates: [] }; } diff --git a/packages/backend/src/agents/titleGenerator.ts b/packages/backend/src/agents/titleGenerator.ts index b3f8841..851fddb 100644 --- a/packages/backend/src/agents/titleGenerator.ts +++ b/packages/backend/src/agents/titleGenerator.ts @@ -2,27 +2,19 @@ import { openai } from '../agent.js'; import type { InterpreterOutput } from '../types/agents.js'; export async function generateTitle(interpreter: InterpreterOutput): Promise { - const response = await openai.chat.completions.create({ - model: 'gpt-4o-mini', + const response = await openai.responses.create({ + model: 'gpt-5.4-mini', temperature: 0.7, service_tier: 'flex', - messages: [ - { - role: 'system', - content: `Generate a concise 5-8 word title for a TV show recommendation session. + instructions: `Generate a concise 5-8 word title for a TV show recommendation session. Capture the essence of the user's taste — genre, tone, key themes. Respond with ONLY the title. No quotes, no trailing punctuation. Examples: "Dark Crime Dramas With Moral Ambiguity", "Cozy British Mysteries With Quirky Detectives"`, - }, - { - role: 'user', - content: `Liked: ${JSON.stringify(interpreter.liked)} + input: `Liked: ${JSON.stringify(interpreter.liked)} Themes: ${JSON.stringify(interpreter.themes)} Tone: ${JSON.stringify(interpreter.tone)} Character preferences: ${JSON.stringify(interpreter.character_preferences)}`, - }, - ], }); - return (response.choices[0]?.message?.content ?? '').trim() || 'My Recommendation Session'; + return (response.output_text ?? '').trim() || 'My Recommendation Session'; }