tv shows to tv series
This commit is contained in:
277
CONTEXT.md
Normal file
277
CONTEXT.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# TV Show Recommendation System (Self-Hosted, Multi-Agent)
|
||||||
|
|
||||||
|
## 🎯 Purpose
|
||||||
|
|
||||||
|
This document provides **complete context for AI agents and developers** to build and evolve a local, self-hosted TV show recommendation system.
|
||||||
|
|
||||||
|
The system is designed to:
|
||||||
|
|
||||||
|
* Prioritize **current user intent** over historical behavior
|
||||||
|
* Use **multi-agent architecture**
|
||||||
|
* Generate, evaluate, and organize recommendations
|
||||||
|
* Handle **50–100 candidate series per request**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧠 Core Principles
|
||||||
|
|
||||||
|
1. **Context over history**
|
||||||
|
|
||||||
|
* Do NOT persist long-term taste profiles
|
||||||
|
* Every request must be evaluated independently
|
||||||
|
|
||||||
|
2. **Generation ≠ Evaluation**
|
||||||
|
|
||||||
|
* Candidate generation and ranking must be separated
|
||||||
|
|
||||||
|
3. **High recall → aggressive filtering**
|
||||||
|
|
||||||
|
* Generate many candidates (50–100)
|
||||||
|
* Filter and rank later
|
||||||
|
|
||||||
|
4. **Structured communication between agents**
|
||||||
|
|
||||||
|
* All agents must exchange structured data (JSON-like)
|
||||||
|
|
||||||
|
5. **Deterministic where possible**
|
||||||
|
|
||||||
|
* Interpretation and ranking should be consistent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🏗️ System Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
User Input
|
||||||
|
↓
|
||||||
|
[1] Interpreter Agent
|
||||||
|
↓
|
||||||
|
[2] Retrieval Agent
|
||||||
|
↓
|
||||||
|
[3] Ranking Agent
|
||||||
|
↓
|
||||||
|
[4] Curator Agent
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. 🧾 Interpreter Agent
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Convert raw user input into structured data.
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
Free-form user text describing:
|
||||||
|
|
||||||
|
* Liked series
|
||||||
|
* Disliked series
|
||||||
|
* Preferences (themes, tone, characters)
|
||||||
|
* Constraints (things to avoid)
|
||||||
|
|
||||||
|
## Output Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"liked": ["string"],
|
||||||
|
"disliked": ["string"],
|
||||||
|
"themes": ["string"],
|
||||||
|
"character_preferences": ["string"],
|
||||||
|
"tone": ["string"],
|
||||||
|
"avoid": ["string"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* Normalize terminology (e.g., "spy" → "espionage")
|
||||||
|
* Infer implicit preferences
|
||||||
|
* Detect contradictions
|
||||||
|
* Be deterministic (low temperature)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. 🔎 Retrieval Agent
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Generate a **large and diverse pool (50–100)** of candidate TV series.
|
||||||
|
|
||||||
|
## Strategy: LLM Generation
|
||||||
|
|
||||||
|
* Generate candidate series from structured input
|
||||||
|
* Focus on diversity and coverage (high LLM temperature)
|
||||||
|
|
||||||
|
## Output Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"metadata": {
|
||||||
|
"themes": [],
|
||||||
|
"tone": [],
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
* Do NOT use external APIs
|
||||||
|
* Favor recall over precision
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. ⚖️ Ranking Agent
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Categorize candidates into 4 confidence levels using relative comparison.
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
* Definitely Like
|
||||||
|
* Might Like
|
||||||
|
* Questionable
|
||||||
|
* Will Not Like
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
### Step 1: Pre-filter
|
||||||
|
|
||||||
|
* Remove obvious mismatches
|
||||||
|
* Enforce "avoid" constraints
|
||||||
|
|
||||||
|
### Step 2: Pairwise Comparison
|
||||||
|
|
||||||
|
* Compare candidates relative to each other and input
|
||||||
|
|
||||||
|
### Step 3: Tagging
|
||||||
|
|
||||||
|
* Assign each show to one of the four categories
|
||||||
|
|
||||||
|
## Output Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"definitely_like": ["title"],
|
||||||
|
"might_like": ["title"],
|
||||||
|
"questionable": ["title"],
|
||||||
|
"will_not_like": ["title"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* Keep logic simple and consistent
|
||||||
|
* Be deterministic (low temperature)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. 🎯 Curator Agent
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Produce a clean, user-facing recommendation output.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
* Group series by category
|
||||||
|
* Provide short explanations
|
||||||
|
* Ensure readability
|
||||||
|
|
||||||
|
## Output Example
|
||||||
|
|
||||||
|
```
|
||||||
|
Definitely Like:
|
||||||
|
- Show A → reason
|
||||||
|
|
||||||
|
Might Like:
|
||||||
|
- Show B → reason
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔁 Feedback Loop (Re-Ranking Only)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Improve ranking without storing long-term user preferences.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
* External summaries, reviews, or insights
|
||||||
|
* User-provided feedback from other sources
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
* Adjust ranking dynamically
|
||||||
|
* Re-rank existing candidates
|
||||||
|
* Do NOT persist user taste
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⚙️ Agent Configuration
|
||||||
|
|
||||||
|
## Temperature Guidelines
|
||||||
|
|
||||||
|
* Interpreter: Low
|
||||||
|
* Retrieval: Medium/High
|
||||||
|
* Ranking: Low
|
||||||
|
* Curator: Medium
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
* Agents must be stateless
|
||||||
|
* Pass all required context explicitly
|
||||||
|
* No hidden memory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧱 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/agents
|
||||||
|
interpreter
|
||||||
|
retrieval
|
||||||
|
ranking
|
||||||
|
curator
|
||||||
|
|
||||||
|
/pipelines
|
||||||
|
recommendation_flow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🚫 Non-Goals
|
||||||
|
|
||||||
|
* No persistent taste model
|
||||||
|
* No long-term user profiling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧭 Implementation Notes for AI Agents
|
||||||
|
|
||||||
|
When extending or modifying this system:
|
||||||
|
|
||||||
|
1. Do NOT introduce long-term memory of user preferences
|
||||||
|
2. Do NOT merge agent responsibilities
|
||||||
|
3. Always preserve structured input/output between agents
|
||||||
|
4. Prefer simplicity over overengineering
|
||||||
|
5. Ensure ranking remains interpretable and consistent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ✅ Summary
|
||||||
|
|
||||||
|
This system is a **stateless, multi-agent recommendation pipeline** focused on:
|
||||||
|
|
||||||
|
* Strong alignment with current input
|
||||||
|
* High candidate diversity
|
||||||
|
* Structured filtering and ranking
|
||||||
|
* Scalable handling of large candidate sets
|
||||||
|
|
||||||
|
The architecture is intentionally simple, modular, and extensible.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Recommender
|
# Recommender
|
||||||
|
|
||||||
A pure TypeScript monolith AI agent application that will recommend TV shows based on a very customized user profile and input.
|
A pure TypeScript monolith AI agent application that will recommend TV series based on a very customized user profile and input.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ CREATE TABLE IF NOT EXISTS "recommendations" (
|
|||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
"title" text NOT NULL,
|
"title" text NOT NULL,
|
||||||
"main_prompt" text NOT NULL,
|
"main_prompt" text NOT NULL,
|
||||||
"liked_shows" text DEFAULT '' NOT NULL,
|
"liked_series" text DEFAULT '' NOT NULL,
|
||||||
"disliked_shows" text DEFAULT '' NOT NULL,
|
"disliked_series" text DEFAULT '' NOT NULL,
|
||||||
"themes" text DEFAULT '' NOT NULL,
|
"themes" text DEFAULT '' NOT NULL,
|
||||||
"brainstorm_count" integer DEFAULT 100 NOT NULL,
|
"brainstorm_count" integer DEFAULT 100 NOT NULL,
|
||||||
"recommendations" jsonb,
|
"recommendations" jsonb,
|
||||||
|
|||||||
2
packages/backend/drizzle/0003_changing_tv_series.sql
Normal file
2
packages/backend/drizzle/0003_changing_tv_series.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "recommendations" RENAME COLUMN "liked_shows" TO "liked_series";
|
||||||
|
ALTER TABLE "recommendations" RENAME COLUMN "disliked_shows" TO "disliked_series";
|
||||||
@@ -89,15 +89,15 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"liked_shows": {
|
"liked_series": {
|
||||||
"name": "liked_shows",
|
"name": "liked_series",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "''"
|
"default": "''"
|
||||||
},
|
},
|
||||||
"disliked_shows": {
|
"disliked_series": {
|
||||||
"name": "disliked_shows",
|
"name": "disliked_series",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { zodTextFormat } from 'openai/helpers/zod';
|
import { zodTextFormat } from 'openai/helpers/zod';
|
||||||
|
|
||||||
const CuratorSchema = z.object({
|
const CuratorSchema = z.object({
|
||||||
shows: z.array(z.object({
|
series: z.array(z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
explanation: z.string(),
|
explanation: z.string(),
|
||||||
category: z.enum(["Full Match", "Definitely Like", "Might Like", "Questionable", "Will Not Like"]),
|
category: z.enum(["Full Match", "Definitely Like", "Might Like", "Questionable", "Will Not Like"]),
|
||||||
@@ -24,7 +24,7 @@ export async function runCurator(
|
|||||||
): Promise<CuratorOutput[]> {
|
): Promise<CuratorOutput[]> {
|
||||||
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
|
|
||||||
const allShows = [
|
const allSeries = [
|
||||||
...(ranking.full_match ?? []).map((t) => ({ title: t, category: 'Full Match' as const })),
|
...(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 })),
|
||||||
@@ -32,7 +32,7 @@ export async function runCurator(
|
|||||||
...ranking.will_not_like.map((t) => ({ title: t, category: 'Will Not Like' as const })),
|
...ranking.will_not_like.map((t) => ({ title: t, category: 'Will Not Like' as const })),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (allShows.length === 0) return [];
|
if (allSeries.length === 0) return [];
|
||||||
|
|
||||||
const canSearch = useWebSearch && supportsWebSearch;
|
const canSearch = useWebSearch && supportsWebSearch;
|
||||||
const preferenceSummary = `User preferences summary:
|
const preferenceSummary = `User preferences summary:
|
||||||
@@ -53,9 +53,9 @@ Rules:
|
|||||||
- cons: up to 3 short bullet points about what the user might not like based on their preferences
|
- 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`;
|
||||||
|
|
||||||
const chunks: typeof allShows[] = [];
|
const chunks: typeof allSeries[] = [];
|
||||||
for (let i = 0; i < allShows.length; i += CHUNK_SIZE) {
|
for (let i = 0; i < allSeries.length; i += CHUNK_SIZE) {
|
||||||
chunks.push(allShows.slice(i, i + CHUNK_SIZE));
|
chunks.push(allSeries.slice(i, i + CHUNK_SIZE));
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: CuratorOutput[] = [];
|
const results: CuratorOutput[] = [];
|
||||||
@@ -66,14 +66,14 @@ Rules:
|
|||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
...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, "series") },
|
||||||
instructions,
|
instructions,
|
||||||
input: `${preferenceSummary}
|
input: `${preferenceSummary}
|
||||||
|
|
||||||
${mediaLabel}s to describe:
|
${mediaLabel}s to describe:
|
||||||
${showList}`,
|
${showList}`,
|
||||||
}));
|
}));
|
||||||
results.push(...(response.output_parsed?.shows ?? []));
|
results.push(...(response.output_parsed?.series ?? []));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const InterpreterSchema = z.object({
|
|||||||
|
|
||||||
interface InterpreterInput {
|
interface InterpreterInput {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows: string;
|
disliked_series: string;
|
||||||
themes: string;
|
themes: string;
|
||||||
media_type: MediaType;
|
media_type: MediaType;
|
||||||
feedback_context?: string;
|
feedback_context?: string;
|
||||||
@@ -43,8 +43,8 @@ Rules:
|
|||||||
- 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.`,
|
- 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_series || '(none)'}
|
||||||
Disliked ${mediaLabel}s: ${input.disliked_shows || '(none)'}
|
Disliked ${mediaLabel}s: ${input.disliked_series || '(none)'}
|
||||||
Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`,
|
Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function runRetrieval(
|
|||||||
previousFullMatches: string[] = [],
|
previousFullMatches: string[] = [],
|
||||||
): Promise<RetrievalOutput> {
|
): Promise<RetrievalOutput> {
|
||||||
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV shows';
|
const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV series';
|
||||||
|
|
||||||
const canSearch = useWebSearch && supportsWebSearch;
|
const canSearch = useWebSearch && supportsWebSearch;
|
||||||
const response = await parseWithRetry(() => openai.responses.parse({
|
const response = await parseWithRetry(() => openai.responses.parse({
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function buildSystemPrompt(
|
|||||||
seenTitles: string[]
|
seenTitles: string[]
|
||||||
): string {
|
): string {
|
||||||
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV shows';
|
const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV series';
|
||||||
|
|
||||||
let prompt = `You are a ${mediaLabel} recommendation specialist. Your task is to recommend titles that match the user's taste profile.
|
let prompt = `You are a ${mediaLabel} recommendation specialist. Your task is to recommend titles that match the user's taste profile.
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export const recommendations = pgTable('recommendations', {
|
|||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
main_prompt: text('main_prompt').notNull(),
|
main_prompt: text('main_prompt').notNull(),
|
||||||
liked_shows: text('liked_shows').notNull().default(''),
|
liked_series: text('liked_series').notNull().default(''),
|
||||||
disliked_shows: text('disliked_shows').notNull().default(''),
|
disliked_series: text('disliked_series').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'),
|
media_type: text('media_type').notNull().default('tv_show'),
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ function mergeCuratorOutputs(a: CuratorOutput[], b: CuratorOutput[]): CuratorOut
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ContinuousPipelineInput {
|
interface ContinuousPipelineInput {
|
||||||
likedShows: string;
|
likedSeries: string;
|
||||||
dislikedShows?: string;
|
dislikedSeries?: string;
|
||||||
themes?: string;
|
themes?: string;
|
||||||
requirements?: string;
|
requirements?: string;
|
||||||
avoid?: string;
|
avoid?: string;
|
||||||
@@ -57,8 +57,8 @@ export async function runContinuousPipeline(
|
|||||||
): Promise<CuratorOutput[]> {
|
): Promise<CuratorOutput[]> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const {
|
const {
|
||||||
likedShows,
|
likedSeries,
|
||||||
dislikedShows = '',
|
dislikedSeries = '',
|
||||||
themes = '',
|
themes = '',
|
||||||
requirements = '',
|
requirements = '',
|
||||||
avoid = '',
|
avoid = '',
|
||||||
@@ -83,9 +83,9 @@ export async function runContinuousPipeline(
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
|
||||||
interpreterOutput = await runInterpreter({
|
interpreterOutput = await runInterpreter({
|
||||||
main_prompt: themes || 'recommend shows based on user preferences',
|
main_prompt: themes || 'recommend series based on user preferences',
|
||||||
liked_shows: likedShows,
|
liked_series: likedSeries,
|
||||||
disliked_shows: dislikedShows,
|
disliked_series: dislikedSeries,
|
||||||
themes: themes,
|
themes: themes,
|
||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
});
|
});
|
||||||
@@ -267,8 +267,8 @@ export async function runContinuousPipeline(
|
|||||||
.set({
|
.set({
|
||||||
title: aiTitle,
|
title: aiTitle,
|
||||||
main_prompt: themes || 'Continuous recommendations',
|
main_prompt: themes || 'Continuous recommendations',
|
||||||
liked_shows: likedShows,
|
liked_series: likedSeries,
|
||||||
disliked_shows: dislikedShows,
|
disliked_series: dislikedSeries,
|
||||||
themes: themes,
|
themes: themes,
|
||||||
brainstorm_count: totalCount,
|
brainstorm_count: totalCount,
|
||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
|
|||||||
@@ -201,8 +201,8 @@ export async function runPipeline(
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const interpreterOutput = await runInterpreter({
|
const interpreterOutput = await runInterpreter({
|
||||||
main_prompt: rec.main_prompt,
|
main_prompt: rec.main_prompt,
|
||||||
liked_shows: rec.liked_shows,
|
liked_series: rec.liked_series,
|
||||||
disliked_shows: rec.disliked_shows,
|
disliked_series: rec.disliked_series,
|
||||||
themes: rec.themes,
|
themes: rec.themes,
|
||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}),
|
...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}),
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
fastify.post('/recommendations', async (request, reply) => {
|
fastify.post('/recommendations', async (request, reply) => {
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows?: string;
|
liked_series?: string;
|
||||||
disliked_shows?: string;
|
disliked_series?: string;
|
||||||
themes?: string;
|
themes?: string;
|
||||||
brainstorm_count?: number;
|
brainstorm_count?: number;
|
||||||
media_type?: string;
|
media_type?: string;
|
||||||
@@ -64,8 +64,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
.values({
|
.values({
|
||||||
title: title || 'Untitled',
|
title: title || 'Untitled',
|
||||||
main_prompt: body.main_prompt ?? '',
|
main_prompt: body.main_prompt ?? '',
|
||||||
liked_shows: body.liked_shows ?? '',
|
liked_series: body.liked_series ?? '',
|
||||||
disliked_shows: body.disliked_shows ?? '',
|
disliked_series: body.disliked_series ?? '',
|
||||||
themes: body.themes ?? '',
|
themes: body.themes ?? '',
|
||||||
brainstorm_count,
|
brainstorm_count,
|
||||||
media_type,
|
media_type,
|
||||||
@@ -85,8 +85,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
// POST /recommendations/continuous — create record and run continuous pipeline with SSE
|
// POST /recommendations/continuous — create record and run continuous pipeline with SSE
|
||||||
fastify.post('/recommendations/continuous', async (request, reply) => {
|
fastify.post('/recommendations/continuous', async (request, reply) => {
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows?: string;
|
disliked_series?: string;
|
||||||
themes?: string;
|
themes?: string;
|
||||||
requirements?: string;
|
requirements?: string;
|
||||||
avoid?: string;
|
avoid?: string;
|
||||||
@@ -108,8 +108,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
.values({
|
.values({
|
||||||
title,
|
title,
|
||||||
main_prompt: body.themes ?? 'Continuous recommendations',
|
main_prompt: body.themes ?? 'Continuous recommendations',
|
||||||
liked_shows: body.liked_shows,
|
liked_series: body.liked_series,
|
||||||
disliked_shows: body.disliked_shows ?? '',
|
disliked_series: body.disliked_series ?? '',
|
||||||
themes: body.themes ?? '',
|
themes: body.themes ?? '',
|
||||||
brainstorm_count: totalCount,
|
brainstorm_count: totalCount,
|
||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
@@ -155,8 +155,8 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Run the continuous pipeline (it will now update the existing record)
|
// Run the continuous pipeline (it will now update the existing record)
|
||||||
await runContinuousPipeline(recId, {
|
await runContinuousPipeline(recId, {
|
||||||
likedShows: body.liked_shows,
|
likedSeries: body.liked_series,
|
||||||
dislikedShows: body.disliked_shows ?? '',
|
dislikedSeries: body.disliked_series ?? '',
|
||||||
themes: body.themes ?? '',
|
themes: body.themes ?? '',
|
||||||
requirements: body.requirements ?? '',
|
requirements: body.requirements ?? '',
|
||||||
avoid: body.avoid ?? '',
|
avoid: body.avoid ?? '',
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export interface ContinuousSession {
|
|||||||
|
|
||||||
export interface ContinuousStartRequest {
|
export interface ContinuousStartRequest {
|
||||||
mediaType: 'tv_show' | 'movie';
|
mediaType: 'tv_show' | 'movie';
|
||||||
likedShows: string;
|
likedSeries: string;
|
||||||
dislikedShows?: string;
|
dislikedSeries?: string;
|
||||||
themes?: string;
|
themes?: string;
|
||||||
requirements?: string;
|
requirements?: string;
|
||||||
avoid?: string;
|
avoid?: string;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
export function createRecommendation(body: {
|
export function createRecommendation(body: {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows: string;
|
disliked_series: string;
|
||||||
themes: string;
|
themes: string;
|
||||||
brainstorm_count?: number;
|
brainstorm_count?: number;
|
||||||
media_type: MediaType;
|
media_type: MediaType;
|
||||||
@@ -68,8 +68,8 @@ export function deleteRecommendation(id: string): Promise<{ ok: boolean }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createContinuousRecommendation(body: {
|
export function createContinuousRecommendation(body: {
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows?: string;
|
disliked_series?: string;
|
||||||
themes?: string;
|
themes?: string;
|
||||||
requirements?: string;
|
requirements?: string;
|
||||||
avoid?: string;
|
avoid?: string;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ interface NewRecommendationModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (body: {
|
onSubmit: (body: {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows: string;
|
disliked_series: string;
|
||||||
themes: string;
|
themes: string;
|
||||||
requirements?: string;
|
requirements?: string;
|
||||||
avoid?: string;
|
avoid?: string;
|
||||||
@@ -36,7 +36,7 @@ const MEDIA_OPTIONS: Array<{
|
|||||||
{
|
{
|
||||||
type: 'tv_show',
|
type: 'tv_show',
|
||||||
icon: '📺',
|
icon: '📺',
|
||||||
label: 'TV Shows',
|
label: 'TV series',
|
||||||
description: 'Serialized stories, limited series, and long-form comfort watches.',
|
description: 'Serialized stories, limited series, and long-form comfort watches.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,7 +45,7 @@ const MEDIA_OPTIONS: Array<{
|
|||||||
label: 'Movies',
|
label: 'Movies',
|
||||||
description: 'Feature films, prestige cinema, and one-night picks.',
|
description: 'Feature films, prestige cinema, and one-night picks.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const MODE_OPTIONS: Array<{
|
const MODE_OPTIONS: Array<{
|
||||||
mode: GenerationMode;
|
mode: GenerationMode;
|
||||||
@@ -65,15 +65,15 @@ const MODE_OPTIONS: Array<{
|
|||||||
badge: 'Best for deep search',
|
badge: 'Best for deep search',
|
||||||
description: 'Generate recommendations in chained batches for a steadier, longer-running hunt.',
|
description: 'Generate recommendations in chained batches for a steadier, longer-running hunt.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) {
|
export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) {
|
||||||
const [step, setStep] = useState<'type' | 'mode' | 'form'>('type');
|
const [step, setStep] = useState<'type' | 'mode' | 'form'>('type');
|
||||||
const [mediaType, setMediaType] = useState<MediaType>('tv_show');
|
const [mediaType, setMediaType] = useState<MediaType>('tv_show');
|
||||||
const [generationMode, setGenerationMode] = useState<GenerationMode>('brainstorm');
|
const [generationMode, setGenerationMode] = useState<GenerationMode>('brainstorm');
|
||||||
const [mainPrompt, setMainPrompt] = useState('');
|
const [mainPrompt, setMainPrompt] = useState('');
|
||||||
const [likedShows, setLikedShows] = useState('');
|
const [likedSeries, setLikedSeries] = useState('');
|
||||||
const [dislikedShows, setDislikedShows] = useState('');
|
const [dislikedSeries, setDislikedSeries] = useState('');
|
||||||
const [themes, setThemes] = useState('');
|
const [themes, setThemes] = useState('');
|
||||||
const [requirements, setRequirements] = useState('');
|
const [requirements, setRequirements] = useState('');
|
||||||
const [avoid, setAvoid] = useState('');
|
const [avoid, setAvoid] = useState('');
|
||||||
@@ -100,7 +100,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
}, [loading, onClose]);
|
}, [loading, onClose]);
|
||||||
|
|
||||||
const mediaLabel = mediaType === 'movie' ? 'Movie' : 'TV Show';
|
const mediaLabel = mediaType === 'movie' ? 'Movie' : 'TV Show';
|
||||||
const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'shows';
|
const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'series';
|
||||||
|
|
||||||
const handleSelectType = (type: MediaType) => {
|
const handleSelectType = (type: MediaType) => {
|
||||||
setMediaType(type);
|
setMediaType(type);
|
||||||
@@ -119,15 +119,15 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
const handleSubmit = async (e: Event) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (generationMode === 'brainstorm' && !mainPrompt.trim()) return;
|
if (generationMode === 'brainstorm' && !mainPrompt.trim()) return;
|
||||||
if (!likedShows.trim()) return;
|
if (!likedSeries.trim()) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (generationMode === 'brainstorm') {
|
if (generationMode === 'brainstorm') {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
main_prompt: mainPrompt.trim(),
|
main_prompt: mainPrompt.trim(),
|
||||||
liked_shows: likedShows.trim(),
|
liked_series: likedSeries.trim(),
|
||||||
disliked_shows: dislikedShows.trim(),
|
disliked_series: dislikedSeries.trim(),
|
||||||
themes: themes.trim(),
|
themes: themes.trim(),
|
||||||
brainstorm_count: brainstormCount,
|
brainstorm_count: brainstormCount,
|
||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
@@ -142,8 +142,8 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
} else {
|
} else {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
main_prompt: '',
|
main_prompt: '',
|
||||||
liked_shows: likedShows.trim(),
|
liked_series: likedSeries.trim(),
|
||||||
disliked_shows: dislikedShows.trim(),
|
disliked_series: dislikedSeries.trim(),
|
||||||
themes: themes.trim(),
|
themes: themes.trim(),
|
||||||
requirements: requirements.trim(),
|
requirements: requirements.trim(),
|
||||||
avoid: avoid.trim(),
|
avoid: avoid.trim(),
|
||||||
@@ -258,7 +258,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
|
|
||||||
<div class="modal-type-footer">
|
<div class="modal-type-footer">
|
||||||
<div class="modal-selection-summary">
|
<div class="modal-selection-summary">
|
||||||
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV Shows'}</span>
|
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV series'}</span>
|
||||||
<span class="summary-pill">{selectedMode?.label}</span>
|
<span class="summary-pill">{selectedMode?.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +281,7 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
|
|
||||||
<form class="modal-form" onSubmit={handleSubmit}>
|
<form class="modal-form" onSubmit={handleSubmit}>
|
||||||
<div class="modal-summary-strip">
|
<div class="modal-summary-strip">
|
||||||
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV Shows'}</span>
|
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV series'}</span>
|
||||||
<span class="summary-pill summary-pill--accent">{selectedMode?.label}</span>
|
<span class="summary-pill summary-pill--accent">{selectedMode?.label}</span>
|
||||||
<span class="summary-caption">
|
<span class="summary-caption">
|
||||||
{generationMode === 'brainstorm'
|
{generationMode === 'brainstorm'
|
||||||
@@ -307,28 +307,28 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
|
|
||||||
<div class="modal-form-grid">
|
<div class="modal-form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="liked-shows">{mediaLabel}s you liked</label>
|
<label for="liked-series">{mediaLabel}s you liked</label>
|
||||||
<input
|
<input
|
||||||
id="liked-shows"
|
id="liked-series"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
placeholder={mediaType === 'movie' ? 'e.g. Inception, The Godfather' : 'e.g. Breaking Bad, The Wire'}
|
placeholder={mediaType === 'movie' ? 'e.g. Inception, The Godfather' : 'e.g. Breaking Bad, The Wire'}
|
||||||
value={likedShows}
|
value={likedSeries}
|
||||||
onInput={(e) => setLikedShows((e.target as HTMLInputElement).value)}
|
onInput={(e) => setLikedSeries((e.target as HTMLInputElement).value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span class="form-help">A few strong examples help the pipeline lock onto your taste.</span>
|
<span class="form-help">A few strong examples help the pipeline lock onto your taste.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="disliked-shows">{mediaLabel}s you disliked</label>
|
<label for="disliked-series">{mediaLabel}s you disliked</label>
|
||||||
<input
|
<input
|
||||||
id="disliked-shows"
|
id="disliked-series"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
placeholder={mediaType === 'movie' ? 'e.g. Transformers' : 'e.g. Game of Thrones'}
|
placeholder={mediaType === 'movie' ? 'e.g. Transformers' : 'e.g. Game of Thrones'}
|
||||||
value={dislikedShows}
|
value={dislikedSeries}
|
||||||
onInput={(e) => setDislikedShows((e.target as HTMLInputElement).value)}
|
onInput={(e) => setDislikedSeries((e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
<span class="form-help">Optional, but useful when you want to steer away from common misses.</span>
|
<span class="form-help">Optional, but useful when you want to steer away from common misses.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ type GenerationMode = 'brainstorm' | 'continuous';
|
|||||||
|
|
||||||
interface CreateBody {
|
interface CreateBody {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows: string;
|
disliked_series: string;
|
||||||
themes: string;
|
themes: string;
|
||||||
requirements?: string;
|
requirements?: string;
|
||||||
avoid?: string;
|
avoid?: string;
|
||||||
@@ -58,8 +58,8 @@ export function useRecommendations() {
|
|||||||
|
|
||||||
if (body.generation_mode === 'continuous') {
|
if (body.generation_mode === 'continuous') {
|
||||||
const result = await createContinuousRecommendation({
|
const result = await createContinuousRecommendation({
|
||||||
liked_shows: body.liked_shows,
|
liked_series: body.liked_series,
|
||||||
disliked_shows: body.disliked_shows,
|
disliked_series: body.disliked_series,
|
||||||
themes: body.themes,
|
themes: body.themes,
|
||||||
requirements: body.requirements ?? '',
|
requirements: body.requirements ?? '',
|
||||||
avoid: body.avoid ?? '',
|
avoid: body.avoid ?? '',
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export function Home() {
|
|||||||
|
|
||||||
const handleCreateNew = async (body: {
|
const handleCreateNew = async (body: {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows: string;
|
disliked_series: string;
|
||||||
themes: string;
|
themes: string;
|
||||||
requirements?: string;
|
requirements?: string;
|
||||||
avoid?: string;
|
avoid?: string;
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ export function Recom({ id }: RecomProps) {
|
|||||||
|
|
||||||
const handleCreateNew = async (body: {
|
const handleCreateNew = async (body: {
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows: string;
|
disliked_series: string;
|
||||||
themes: string;
|
themes: string;
|
||||||
brainstorm_count?: number;
|
brainstorm_count?: number;
|
||||||
media_type: import('../types/index.js').MediaType;
|
media_type: import('../types/index.js').MediaType;
|
||||||
@@ -234,16 +234,16 @@ export function Recom({ id }: RecomProps) {
|
|||||||
<span class="rec-info-value">{rec.main_prompt}</span>
|
<span class="rec-info-value">{rec.main_prompt}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rec.liked_shows && (
|
{rec.liked_series && (
|
||||||
<div class="rec-info-row">
|
<div class="rec-info-row">
|
||||||
<span class="rec-info-label">Liked</span>
|
<span class="rec-info-label">Liked</span>
|
||||||
<span class="rec-info-value">{rec.liked_shows}</span>
|
<span class="rec-info-value">{rec.liked_series}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rec.disliked_shows && (
|
{rec.disliked_series && (
|
||||||
<div class="rec-info-row">
|
<div class="rec-info-row">
|
||||||
<span class="rec-info-label">Disliked</span>
|
<span class="rec-info-label">Disliked</span>
|
||||||
<span class="rec-info-value">{rec.disliked_shows}</span>
|
<span class="rec-info-value">{rec.disliked_series}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rec.themes && (
|
{rec.themes && (
|
||||||
@@ -254,7 +254,7 @@ export function Recom({ id }: RecomProps) {
|
|||||||
)}
|
)}
|
||||||
<div class="rec-info-row">
|
<div class="rec-info-row">
|
||||||
<span class="rec-info-label">Media</span>
|
<span class="rec-info-label">Media</span>
|
||||||
<span class="rec-info-value">{rec.media_type === 'tv_show' ? 'TV Shows' : 'Movies'}</span>
|
<span class="rec-info-value">{rec.media_type === 'tv_show' ? 'TV series' : 'Movies'}</span>
|
||||||
</div>
|
</div>
|
||||||
{rec.use_web_search && (
|
{rec.use_web_search && (
|
||||||
<div class="rec-info-row">
|
<div class="rec-info-row">
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export interface Recommendation {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
main_prompt: string;
|
main_prompt: string;
|
||||||
liked_shows: string;
|
liked_series: string;
|
||||||
disliked_shows: string;
|
disliked_series: string;
|
||||||
themes: string;
|
themes: string;
|
||||||
media_type: MediaType;
|
media_type: MediaType;
|
||||||
use_web_search: boolean;
|
use_web_search: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user