Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db2dfe4774 | ||
|
|
07524f9aaf | ||
|
|
0c3661e919 | ||
|
|
8bd1dce63f | ||
|
|
d53ed3d05c | ||
|
|
56de1bb11e | ||
|
|
5e02ca267f | ||
|
|
c319dea2bf | ||
|
|
6e9cfc5d30 | ||
|
|
910d26add3 | ||
|
|
0944ec62bb | ||
|
|
a7d12acce6 | ||
|
|
0c704cf2f6 | ||
|
|
98860835d9 | ||
|
|
fd3ad4c77f | ||
|
|
d849b67f3d | ||
|
|
ba38092784 | ||
|
|
91870f4046 | ||
|
|
39edec4a7c | ||
|
|
a73bc27356 | ||
|
|
148755243a | ||
|
|
bb8a5da45e | ||
|
|
be2d8d70cb | ||
|
|
77757ace5e | ||
|
|
88c839e768 | ||
|
|
749ae42acb | ||
|
|
28204d29ab | ||
|
|
deb3a89f56 | ||
|
|
c730a53dd0 | ||
|
|
1437092a42 | ||
|
|
6fdfc3797a |
+20
-72
@@ -7,80 +7,28 @@ on:
|
|||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY_HOST: git.ivanch.me
|
IMAGE: git.ivanch.me/ivanch/recommender
|
||||||
REGISTRY_USERNAME: ivanch
|
|
||||||
IMAGE: ${{ env.REGISTRY_HOST }}/ivanch/recommender
|
|
||||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_recommender:
|
build:
|
||||||
name: Build Recommender Image
|
name: Build Image
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
|
||||||
| docker login "${{ env.REGISTRY_HOST }}" \
|
|
||||||
-u "${{ env.REGISTRY_USERNAME }}" \
|
|
||||||
--password-stdin
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and Push Multi-Arch Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
push: true
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: |
|
|
||||||
${{ env.IMAGE }}:latest
|
|
||||||
|
|
||||||
deploy_recommender:
|
|
||||||
name: Deploy Recommender (internal)
|
|
||||||
runs-on: ubuntu-amd64
|
runs-on: ubuntu-amd64
|
||||||
needs: build_recommender
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check KUBE_CONFIG validity
|
- uses: actions/checkout@v4
|
||||||
run: |
|
- name: Build and Push Multi-Arch Image
|
||||||
if [ -z "${KUBE_CONFIG}" ] || [ "${KUBE_CONFIG}" = "" ] || [ "${KUBE_CONFIG// }" = "" ]; then
|
uses: https://git.ivanch.me/ivanch/pipeline-actions/build-and-push@main
|
||||||
echo "KUBE_CONFIG is not set or is empty."
|
with:
|
||||||
exit 1
|
image: ${{ env.IMAGE }}
|
||||||
fi
|
registry_password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
ssh_key: ${{ secrets.SSH_KEY_DOCKERBUILD }}
|
||||||
|
|
||||||
- name: Check out repository
|
deploy:
|
||||||
uses: actions/checkout@v2
|
name: Deploy (internal)
|
||||||
|
runs-on: ubuntu-amd64
|
||||||
- name: Download and install dependencies
|
needs: build
|
||||||
run: |
|
steps:
|
||||||
apt-get update -y
|
- uses: https://git.ivanch.me/ivanch/pipeline-actions/deploy-restart@main
|
||||||
apt-get install -y curl
|
with:
|
||||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
kube_config: ${{ secrets.KUBE_CONFIG }}
|
||||||
install -m 0755 kubectl /usr/local/bin/kubectl
|
deployment_name: recommender
|
||||||
kubectl version --client
|
namespace: media
|
||||||
|
|
||||||
- name: Set up kubeconfig
|
|
||||||
run: |
|
|
||||||
cd deploy
|
|
||||||
echo "$KUBE_CONFIG" > kubeconfig.yaml
|
|
||||||
env:
|
|
||||||
KUBE_CONFIG: ${{ env.KUBE_CONFIG }}
|
|
||||||
|
|
||||||
- name: Check connection to cluster
|
|
||||||
run: |
|
|
||||||
cd deploy
|
|
||||||
kubectl --kubeconfig=kubeconfig.yaml cluster-info
|
|
||||||
|
|
||||||
- name: Apply Recommender deployment
|
|
||||||
run: |
|
|
||||||
cd deploy
|
|
||||||
kubectl --kubeconfig=kubeconfig.yaml apply -f recommender.yaml
|
|
||||||
|
|
||||||
- name: Rollout restart
|
|
||||||
run: |
|
|
||||||
cd deploy
|
|
||||||
kubectl --kubeconfig=kubeconfig.yaml rollout restart deployment recommender -n media
|
|
||||||
|
|||||||
@@ -25,3 +25,10 @@ documents/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
deploy/terraform/.terraform
|
||||||
|
deploy/terraform/*.tfstate
|
||||||
|
deploy/terraform/*.tfstate.*
|
||||||
|
deploy/terraform/*.tfplan
|
||||||
|
deploy/terraform/.terraform.lock.hcl
|
||||||
+277
@@ -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
-1
@@ -7,7 +7,7 @@ COPY package.json package-lock.json ./
|
|||||||
COPY packages/backend/package.json ./packages/backend/package.json
|
COPY packages/backend/package.json ./packages/backend/package.json
|
||||||
COPY packages/frontend/package.json ./packages/frontend/package.json
|
COPY packages/frontend/package.json ./packages/frontend/package.json
|
||||||
|
|
||||||
RUN npm ci --ignore-scripts
|
RUN npm ci
|
||||||
|
|
||||||
# ─── Stage 2: Build the Preact frontend ──────────────────────────────────────
|
# ─── Stage 2: Build the Preact frontend ──────────────────────────────────────
|
||||||
FROM deps AS build-frontend
|
FROM deps AS build-frontend
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: recommender
|
|
||||||
namespace: media
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: recommender
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: recommender
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: recommender
|
|
||||||
image: git.ivanch.me/ivanch/recommender:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- containerPort: 80
|
|
||||||
env:
|
|
||||||
- name: OPENAI_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: recommender-secrets
|
|
||||||
key: OPENAI_API_KEY
|
|
||||||
- name: DATABASE_URL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: recommender-secrets
|
|
||||||
key: DATABASE_URL
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
limits:
|
|
||||||
memory: "512Mi"
|
|
||||||
cpu: "1"
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: recommender
|
|
||||||
namespace: media
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: recommender
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 80
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: recommender
|
|
||||||
namespace: media
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: recommender
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
- host: "recommender.haven"
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: "/"
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: recommender
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
@@ -4,5 +4,8 @@ set -e
|
|||||||
# Start Nginx in the background
|
# Start Nginx in the background
|
||||||
nginx &
|
nginx &
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
node /app/node_modules/.bin/tsx /app/packages/backend/src/migrate.ts
|
||||||
|
|
||||||
# Start the Node.js backend
|
# Start the Node.js backend
|
||||||
exec node /app/node_modules/.bin/tsx /app/packages/backend/src/index.ts
|
exec node /app/node_modules/.bin/tsx /app/packages/backend/src/index.ts
|
||||||
|
|||||||
Generated
+11
-1
@@ -4455,6 +4455,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"packages/backend": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -4463,7 +4472,8 @@
|
|||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.8.4",
|
"fastify": "^5.8.4",
|
||||||
"openai": "^6.32.0",
|
"openai": "^6.32.0",
|
||||||
"postgres": "^3.4.8"
|
"postgres": "^3.4.8",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
|
|||||||
@@ -3,4 +3,8 @@
|
|||||||
# In production / Docker, supply these as environment variables.
|
# In production / Docker, supply these as environment variables.
|
||||||
|
|
||||||
DATABASE_URL=postgres://user:password@localhost:5432/recommender
|
DATABASE_URL=postgres://user:password@localhost:5432/recommender
|
||||||
|
|
||||||
OPENAI_API_KEY=your-openai-api-key-here
|
OPENAI_API_KEY=your-openai-api-key-here
|
||||||
|
|
||||||
|
# provider settings
|
||||||
|
PROVIDER_URL=https://your-provider.example.com/v1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE "feedback" (
|
CREATE TABLE IF NOT EXISTS "feedback" (
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
"tv_show_name" text NOT NULL,
|
"tv_show_name" text NOT NULL,
|
||||||
"stars" integer NOT NULL,
|
"stars" integer NOT NULL,
|
||||||
@@ -6,12 +6,12 @@ CREATE TABLE "feedback" (
|
|||||||
"created_at" timestamp DEFAULT now() NOT NULL
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "recommendations" (
|
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,
|
||||||
@@ -19,4 +19,4 @@ CREATE TABLE "recommendations" (
|
|||||||
"created_at" timestamp DEFAULT now() NOT NULL
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX "feedback_tv_show_name_idx" ON "feedback" USING btree ("tv_show_name");
|
CREATE UNIQUE INDEX IF NOT EXISTS "feedback_tv_show_name_idx" ON "feedback" USING btree ("tv_show_name");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "recommendations" ADD COLUMN "media_type" text DEFAULT 'tv_show' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "recommendations" ADD COLUMN "use_web_search" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "feedback" RENAME COLUMN "tv_show_name" TO "item_name";
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP INDEX "feedback_tv_show_name_idx";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "feedback_item_name_idx" ON "feedback" USING btree ("item_name");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "recommendations" ADD COLUMN "use_validator" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "recommendations" ADD COLUMN "hard_requirements" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "recommendations" ADD COLUMN "self_expansive" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "recommendations" ADD COLUMN "expansive_passes" integer DEFAULT 1 NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "recommendations" ADD COLUMN "expansive_mode" text DEFAULT 'soft' NOT NULL;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1774479321371,
|
"when": 1774479321371,
|
||||||
"tag": "0000_wild_joseph",
|
"tag": "0000_wild_joseph",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774900000000,
|
||||||
|
"tag": "0001_add_media_type_web_search",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"dev": "tsx watch src/index.ts"
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"migrate": "tsx src/migrate.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.8.4",
|
"fastify": "^5.8.4",
|
||||||
"openai": "^6.32.0",
|
"openai": "^6.32.0",
|
||||||
"postgres": "^3.4.8"
|
"postgres": "^3.4.8",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
|
|||||||
@@ -3,19 +3,37 @@ import * as dotenv from 'dotenv';
|
|||||||
dotenv.config({ path: ['.env.local', '.env'] });
|
dotenv.config({ path: ['.env.local', '.env'] });
|
||||||
|
|
||||||
export const openai = new OpenAI({
|
export const openai = new OpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: process.env.BEARER_TOKEN,
|
||||||
|
baseURL: process.env.PROVIDER_URL,
|
||||||
|
timeout: 600000, // 10 minutes
|
||||||
|
maxRetries: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function askAgent(prompt: string) {
|
export const defaultModel = process.env.MODEL_NAME ?? 'gpt-5.4';
|
||||||
|
export const miniModel = process.env.MODEL_NAME ?? 'gpt-5.4-mini';
|
||||||
|
export const serviceOptions = { service_tier: 'flex' as const };
|
||||||
|
export const supportsWebSearch = true;
|
||||||
|
|
||||||
|
function isJsonParseError(err: unknown): boolean {
|
||||||
|
if (err instanceof SyntaxError) return true;
|
||||||
|
if (!(err instanceof Error)) return false;
|
||||||
|
|
||||||
|
// Some providers wrap JSON parsing failures in plain Error objects.
|
||||||
|
return /not valid json|unexpected token|json/i.test(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseWithRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const response = await openai.chat.completions.create({
|
return await fn();
|
||||||
model: 'gpt-5.4',
|
|
||||||
service_tier: 'flex',
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
});
|
|
||||||
return response!.choices![0]!.message!.content;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Agent endpoint dummy error:', err instanceof Error ? err.message : err);
|
if (isJsonParseError(err) && attempt < retries) {
|
||||||
return 'Agent is in dummy mode or encountered an error.';
|
lastErr = err;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,80 @@
|
|||||||
import { openai } from '../agent.js';
|
import { openai, defaultModel, serviceOptions, supportsWebSearch, parseWithRetry } from '../agent.js';
|
||||||
import type { InterpreterOutput, RankingOutput, CuratorOutput } from '../types/agents.js';
|
import type { InterpreterOutput, RankingOutput, CuratorOutput, MediaType } from '../types/agents.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodTextFormat } from 'openai/helpers/zod';
|
||||||
|
|
||||||
|
const CuratorSchema = z.object({
|
||||||
|
series: z.array(z.object({
|
||||||
|
title: z.string(),
|
||||||
|
explanation: z.string(),
|
||||||
|
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)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 20;
|
||||||
|
|
||||||
export async function runCurator(
|
export async function runCurator(
|
||||||
ranking: RankingOutput,
|
ranking: RankingOutput,
|
||||||
interpreter: InterpreterOutput,
|
interpreter: InterpreterOutput,
|
||||||
|
mediaType: MediaType = 'tv_show',
|
||||||
|
useWebSearch = false,
|
||||||
): Promise<CuratorOutput[]> {
|
): Promise<CuratorOutput[]> {
|
||||||
const allShows = [
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
|
|
||||||
|
const allSeries = [
|
||||||
|
...(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 })),
|
||||||
...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 showList = allShows
|
const canSearch = useWebSearch && supportsWebSearch;
|
||||||
.map((s) => `- "${s.title}" (${s.category})`)
|
const preferenceSummary = `User preferences summary:
|
||||||
.join('\n');
|
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)'}`;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const 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.' : ''}
|
||||||
model: 'gpt-5.4-mini',
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
||||||
- Be honest — explain why "Questionable" or "Will Not Like" shows got that rating`,
|
- 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`;
|
||||||
role: 'user',
|
|
||||||
content: `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)}
|
|
||||||
|
|
||||||
Shows to describe:
|
const chunks: typeof allSeries[] = [];
|
||||||
|
for (let i = 0; i < allSeries.length; i += CHUNK_SIZE) {
|
||||||
|
chunks.push(allSeries.slice(i, i + CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CuratorOutput[] = [];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const showList = chunk.map((s) => `- "${s.title}" (${s.category})`).join('\n');
|
||||||
|
const response = await parseWithRetry(() => openai.responses.parse({
|
||||||
|
model: defaultModel,
|
||||||
|
temperature: 0.5,
|
||||||
|
...serviceOptions,
|
||||||
|
...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}),
|
||||||
|
text: { format: zodTextFormat(CuratorSchema, "series") },
|
||||||
|
instructions,
|
||||||
|
input: `${preferenceSummary}
|
||||||
|
|
||||||
|
${mediaLabel}s to describe:
|
||||||
${showList}`,
|
${showList}`,
|
||||||
},
|
}));
|
||||||
],
|
results.push(...(response.output_parsed?.series ?? []));
|
||||||
});
|
}
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content ?? '{"shows":[]}';
|
return results;
|
||||||
const result = JSON.parse(content) as { shows: CuratorOutput[] };
|
|
||||||
return result.shows ?? [];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,54 @@
|
|||||||
import { openai } from '../agent.js';
|
import { openai, defaultModel, serviceOptions, parseWithRetry } from '../agent.js';
|
||||||
import type { InterpreterOutput } from '../types/agents.js';
|
import type { InterpreterOutput, MediaType } 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()),
|
||||||
|
requirements: z.array(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
feedback_context?: string;
|
feedback_context?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runInterpreter(input: InterpreterInput): Promise<InterpreterOutput> {
|
export async function runInterpreter(input: InterpreterInput): Promise<InterpreterOutput> {
|
||||||
|
const mediaLabel = input.media_type === 'movie' ? 'movie' : 'TV show';
|
||||||
const feedbackSection = input.feedback_context
|
const feedbackSection = input.feedback_context
|
||||||
? `\n\nUser Feedback Context (incorporate into preferences):\n${input.feedback_context}`
|
? `\n\nUser Feedback Context (incorporate into preferences):\n${input.feedback_context}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await parseWithRetry(() => openai.responses.parse({
|
||||||
model: 'gpt-5.4-mini',
|
model: defaultModel,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
service_tier: 'flex',
|
...serviceOptions,
|
||||||
response_format: { type: 'json_object' },
|
text: { format: zodTextFormat(InterpreterSchema, "preferences") },
|
||||||
messages: [
|
instructions: `You are a ${mediaLabel} preference interpreter. Transform raw user input into structured, normalized preferences.
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `You are a TV show preference interpreter. Transform raw user input into structured, normalized preferences.
|
|
||||||
|
|
||||||
Your output MUST be valid JSON matching this schema:
|
|
||||||
{
|
|
||||||
"liked": string[], // shows the user likes
|
|
||||||
"disliked": string[], // shows the user dislikes
|
|
||||||
"themes": string[], // normalized themes (e.g. "spy" -> "espionage")
|
|
||||||
"character_preferences": string[], // character types they prefer
|
|
||||||
"tone": string[], // tone descriptors (e.g. "serious", "grounded", "dark")
|
|
||||||
"avoid": string[] // things to explicitly avoid
|
|
||||||
}
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Extract implicit preferences from the main prompt
|
- Extract implicit preferences from the main prompt
|
||||||
- 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}
|
||||||
role: 'user',
|
Liked ${mediaLabel}s: ${input.liked_series || '(none)'}
|
||||||
content: `Main prompt: ${input.main_prompt}
|
Disliked ${mediaLabel}s: ${input.disliked_series || '(none)'}
|
||||||
Liked shows: ${input.liked_shows || '(none)'}
|
|
||||||
Disliked shows: ${input.disliked_shows || '(none)'}
|
|
||||||
Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`,
|
Themes and requirements: ${input.themes || '(none)'}${feedbackSection}`,
|
||||||
},
|
}));
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content ?? '{}';
|
return (response.output_parsed as InterpreterOutput) ?? {
|
||||||
return JSON.parse(content) as InterpreterOutput;
|
liked: [], disliked: [], themes: [], character_preferences: [], tone: [], avoid: [], requirements: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { openai } from '../agent.js';
|
import { openai, defaultModel, serviceOptions, parseWithRetry } from '../agent.js';
|
||||||
import type { InterpreterOutput, RetrievalOutput, RankingOutput } from '../types/agents.js';
|
import type { InterpreterOutput, RetrievalOutput, RankingOutput, MediaType } from '../types/agents.js';
|
||||||
|
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()),
|
||||||
|
will_not_like: z.array(z.string())
|
||||||
|
});
|
||||||
|
|
||||||
export async function runRanking(
|
export async function runRanking(
|
||||||
interpreter: InterpreterOutput,
|
interpreter: InterpreterOutput,
|
||||||
retrieval: RetrievalOutput,
|
retrieval: RetrievalOutput,
|
||||||
|
mediaType: MediaType = 'tv_show',
|
||||||
|
hardRequirements = false,
|
||||||
): Promise<RankingOutput> {
|
): Promise<RankingOutput> {
|
||||||
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
|
|
||||||
// Phase 1: Pre-filter — remove avoidance violations
|
// Phase 1: Pre-filter — remove avoidance violations
|
||||||
const avoidList = interpreter.avoid.map((a) => a.toLowerCase());
|
const avoidList = interpreter.avoid.map((a) => a.toLowerCase());
|
||||||
const filtered = retrieval.candidates.filter((c) => {
|
const filtered = retrieval.candidates.filter((c) => {
|
||||||
@@ -19,7 +33,8 @@ export async function runRanking(
|
|||||||
chunks.push(filtered.slice(i, i + CHUNK_SIZE));
|
chunks.push(filtered.slice(i, i + CHUNK_SIZE));
|
||||||
}
|
}
|
||||||
|
|
||||||
const allBuckets: RankingOutput = {
|
const allTags: RankingOutput = {
|
||||||
|
full_match: [],
|
||||||
definitely_like: [],
|
definitely_like: [],
|
||||||
might_like: [],
|
might_like: [],
|
||||||
questionable: [],
|
questionable: [],
|
||||||
@@ -29,55 +44,41 @@ export async function runRanking(
|
|||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const chunkTitles = chunk.map((c) => `- ${c.title}: ${c.reason}`).join('\n');
|
const chunkTitles = chunk.map((c) => `- ${c.title}: ${c.reason}`).join('\n');
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await parseWithRetry(() => openai.responses.parse({
|
||||||
model: 'gpt-5.4',
|
model: defaultModel,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
service_tier: 'flex',
|
...serviceOptions,
|
||||||
response_format: { type: 'json_object' },
|
text: { format: zodTextFormat(RankingSchema, "ranking") },
|
||||||
messages: [
|
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.
|
||||||
{
|
|
||||||
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.
|
|
||||||
|
|
||||||
Buckets:
|
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
|
||||||
|
|
||||||
Your output MUST be valid JSON:
|
Every ${mediaLabel} in the input must appear in exactly one tag. Use the title exactly as given.${hardRequirements ? '\n\nHARD REQUIREMENTS MODE: Any candidate that does not satisfy every stated requirement must be placed in "will_not_like", regardless of other qualities.' : ''}`,
|
||||||
{
|
input: `User preferences:
|
||||||
"definitely_like": string[],
|
Liked ${mediaLabel}s: ${interpreter.liked.join(', ') || '(none)'}
|
||||||
"might_like": string[],
|
Themes: ${interpreter.themes.join(', ') || '(none)'}
|
||||||
"questionable": string[],
|
Character preferences: ${interpreter.character_preferences.join(', ') || '(none)'}
|
||||||
"will_not_like": string[]
|
Tone: ${interpreter.tone.join(', ') || '(none)'}
|
||||||
}
|
Avoid: ${interpreter.avoid.join(', ') || '(none)'}
|
||||||
|
Requirements: ${interpreter.requirements.join(', ') || '(none)'}
|
||||||
|
|
||||||
Every show in the input must appear in exactly one bucket. Use the title exactly as given.`,
|
Rank these ${mediaLabel}s:
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: `User preferences:
|
|
||||||
Liked shows: ${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)}
|
|
||||||
|
|
||||||
Rank these shows:
|
|
||||||
${chunkTitles}`,
|
${chunkTitles}`,
|
||||||
},
|
}));
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content ?? '{}';
|
const chunkResult = (response.output_parsed as Partial<RankingOutput>) ?? {};
|
||||||
const chunkResult = JSON.parse(content) as Partial<RankingOutput>;
|
|
||||||
|
|
||||||
allBuckets.definitely_like.push(...(chunkResult.definitely_like ?? []));
|
allTags.full_match.push(...(chunkResult.full_match ?? []));
|
||||||
allBuckets.might_like.push(...(chunkResult.might_like ?? []));
|
allTags.definitely_like.push(...(chunkResult.definitely_like ?? []));
|
||||||
allBuckets.questionable.push(...(chunkResult.questionable ?? []));
|
allTags.might_like.push(...(chunkResult.might_like ?? []));
|
||||||
allBuckets.will_not_like.push(...(chunkResult.will_not_like ?? []));
|
allTags.questionable.push(...(chunkResult.questionable ?? []));
|
||||||
|
allTags.will_not_like.push(...(chunkResult.will_not_like ?? []));
|
||||||
}
|
}
|
||||||
|
|
||||||
return allBuckets;
|
return allTags;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,54 @@
|
|||||||
import { openai } from '../agent.js';
|
import { openai, defaultModel, serviceOptions, supportsWebSearch, parseWithRetry } from '../agent.js';
|
||||||
import type { InterpreterOutput, RetrievalOutput } from '../types/agents.js';
|
import type { InterpreterOutput, RetrievalOutput, MediaType } from '../types/agents.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodTextFormat } from 'openai/helpers/zod';
|
||||||
|
|
||||||
export async function runRetrieval(input: InterpreterOutput, brainstormCount = 100): Promise<RetrievalOutput> {
|
const RetrievalSchema = z.object({
|
||||||
const response = await openai.chat.completions.create({
|
candidates: z.array(z.object({
|
||||||
model: 'gpt-5.4',
|
title: z.string(),
|
||||||
|
reason: z.string()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function runRetrieval(
|
||||||
|
input: InterpreterOutput,
|
||||||
|
brainstormCount = 100,
|
||||||
|
mediaType: MediaType = 'tv_show',
|
||||||
|
useWebSearch = false,
|
||||||
|
hardRequirements = false,
|
||||||
|
previousFullMatches: string[] = [],
|
||||||
|
): Promise<RetrievalOutput> {
|
||||||
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
|
const mediaLabelPlural = mediaType === 'movie' ? 'movies' : 'TV series';
|
||||||
|
|
||||||
|
const canSearch = useWebSearch && supportsWebSearch;
|
||||||
|
const response = await parseWithRetry(() => openai.responses.parse({
|
||||||
|
model: defaultModel,
|
||||||
temperature: 0.9,
|
temperature: 0.9,
|
||||||
service_tier: 'flex',
|
...serviceOptions,
|
||||||
response_format: { type: 'json_object' },
|
...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}),
|
||||||
messages: [
|
text: { format: zodTextFormat(RetrievalSchema, "candidates") },
|
||||||
{
|
instructions: `You are a ${mediaLabel} candidate generator. Your goal is to brainstorm a LARGE, DIVERSE pool of ${brainstormCount} ${mediaLabel} candidates that match the user's structured preferences.${useWebSearch ? '\n\nUse web search to find recent and accurate titles, including newer releases.' : ''}
|
||||||
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 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Include both well-known and obscure shows
|
- Include both well-known and obscure ${mediaLabelPlural}
|
||||||
- Prioritize RECALL over precision — it's better to include too many than too few
|
- Prioritize RECALL over precision — it's better to include too many than too few
|
||||||
- Each "reason" should briefly explain why the show matches the preferences
|
- Each "reason" should briefly explain why the ${mediaLabel} matches the preferences
|
||||||
- Avoid duplicates
|
- Avoid duplicates
|
||||||
- Include shows from different decades, countries, and networks
|
- Include ${mediaLabelPlural} from different decades, countries${mediaType === 'tv_show' ? ', and networks' : ', and directors'}
|
||||||
- Aim for ${brainstormCount} candidates minimum`,
|
- The "title" field must contain ONLY the exact title name — no years, descriptions, network names, episode counts, or parenthetical notes. ✗ Bad: "Breaking Bad (2008–2013, AMC)" ✓ Good: "Breaking Bad"
|
||||||
},
|
- Aim for ${brainstormCount} candidates minimum${previousFullMatches.length > 0 ? '\n- Do NOT suggest titles already in the Previous Full Matches list — generate NEW candidates inspired by what made those successful' : ''}${hardRequirements ? '\n\nIMPORTANT: Strictly follow ALL requirements. Exclude any candidate that does not meet every stated requirement.' : ''}`,
|
||||||
{
|
input: `Structured preferences:
|
||||||
role: 'user',
|
Liked ${mediaLabelPlural}: ${input.liked.join(', ') || '(none)'}
|
||||||
content: `Structured preferences:
|
Disliked ${mediaLabelPlural}: ${input.disliked.join(', ') || '(none)'}
|
||||||
Liked shows: ${JSON.stringify(input.liked)}
|
Themes: ${input.themes.join(', ') || '(none)'}
|
||||||
Disliked shows: ${JSON.stringify(input.disliked)}
|
Character preferences: ${input.character_preferences.join(', ') || '(none)'}
|
||||||
Themes: ${JSON.stringify(input.themes)}
|
Tone: ${input.tone.join(', ') || '(none)'}
|
||||||
Character preferences: ${JSON.stringify(input.character_preferences)}
|
Avoid: ${input.avoid.join(', ') || '(none)'}
|
||||||
Tone: ${JSON.stringify(input.tone)}
|
Requirements: ${input.requirements.join(', ') || '(none)'}${previousFullMatches.length > 0 ? `\n\nPrevious Full Match titles (DO NOT repeat these; use them as inspiration for NEW candidates with similar qualities): ${previousFullMatches.join(', ')}` : ''}
|
||||||
Avoid: ${JSON.stringify(input.avoid)}
|
|
||||||
|
|
||||||
Generate a large, diverse pool of TV show candidates.`,
|
Generate a large, diverse pool of ${mediaLabel} candidates.`,
|
||||||
},
|
}));
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content ?? '{"candidates":[]}';
|
return (response.output_parsed as RetrievalOutput) ?? { candidates: [] };
|
||||||
return JSON.parse(content) as RetrievalOutput;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { openai, defaultModel, serviceOptions, supportsWebSearch, parseWithRetry } from '../agent.js';
|
||||||
|
import type { InterpreterOutput, RetrievalCandidate, MediaType } from '../types/agents.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodTextFormat } from 'openai/helpers/zod';
|
||||||
|
|
||||||
|
const RetrievalBatchSchema = z.object({
|
||||||
|
candidates: z.array(z.object({
|
||||||
|
title: z.string(),
|
||||||
|
type: z.enum(['movie', 'tv']),
|
||||||
|
year: z.string(),
|
||||||
|
reason: z.string()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RetrievalBatchOutput {
|
||||||
|
candidates: Array<{
|
||||||
|
title: string;
|
||||||
|
type: 'movie' | 'tv';
|
||||||
|
year: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RetrievalCandidateSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
reason: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RetrievalOutput {
|
||||||
|
candidates: RetrievalCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemPrompt(
|
||||||
|
interpreterOutput: InterpreterOutput,
|
||||||
|
mediaType: MediaType,
|
||||||
|
useWebSearch: boolean,
|
||||||
|
hardRequirements: boolean,
|
||||||
|
seenTitles: string[]
|
||||||
|
): string {
|
||||||
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
|
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.
|
||||||
|
|
||||||
|
USER TASTE PROFILE:
|
||||||
|
- Liked ${mediaLabelPlural}: ${interpreterOutput.liked.join(', ') || '(none)'}
|
||||||
|
- Disliked ${mediaLabelPlural}: ${interpreterOutput.disliked.join(', ') || '(none)'}
|
||||||
|
- Themes: ${interpreterOutput.themes.join(', ') || '(none)'}
|
||||||
|
- Tone: ${interpreterOutput.tone.join(', ') || '(none)'}
|
||||||
|
- Requirements: ${interpreterOutput.requirements.join(', ') || '(none)'}
|
||||||
|
- Avoid: ${interpreterOutput.avoid.join(', ') || '(none)'}
|
||||||
|
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
Output exactly 10 recommendations in the following JSON format. No additional text.
|
||||||
|
|
||||||
|
[
|
||||||
|
{ "title": "Title Name", "type": "movie"|"tv", "year": "2024", "reason": "1-sentence why it matches" },
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
REQUIREMENTS:
|
||||||
|
- Output exactly 10 titles, no more, no less
|
||||||
|
- Each title must include: title, type (movie/tv), year, and a concise reason
|
||||||
|
- Prioritize variety across themes and decades
|
||||||
|
- Do not include titles the user already mentioned as liked/disliked
|
||||||
|
${seenTitles.length > 0 ? `- Do NOT repeat the following titles that have already been suggested: ${seenTitles.join(', ')}` : ''}
|
||||||
|
${useWebSearch ? '\n- Use web search to find recent and accurate titles, including newer releases' : ''}
|
||||||
|
${hardRequirements ? '\n- Strictly follow ALL requirements. Exclude any candidate that does not meet every stated requirement.' : ''}
|
||||||
|
- If asked for "more 10" or "more recommendations", provide 10 new recommendations following the same format`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRetrievalBatch(
|
||||||
|
interpreterOutput: InterpreterOutput,
|
||||||
|
mediaType: MediaType,
|
||||||
|
useWebSearch: boolean,
|
||||||
|
hardRequirements: boolean,
|
||||||
|
seenTitles: string[],
|
||||||
|
previousResponseId: string | null
|
||||||
|
): Promise<{ candidates: RetrievalCandidate[]; responseId: string }> {
|
||||||
|
const canSearch = useWebSearch && supportsWebSearch;
|
||||||
|
const systemPrompt = buildSystemPrompt(interpreterOutput, mediaType, useWebSearch, hardRequirements, seenTitles);
|
||||||
|
|
||||||
|
let input: string;
|
||||||
|
|
||||||
|
if (previousResponseId === null) {
|
||||||
|
input = 'Please recommend 10 titles that match my taste profile. Output exactly 10 recommendations in the specified JSON format.';
|
||||||
|
} else {
|
||||||
|
input = 'Give me 10 more recommendations following the same JSON format as before.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await parseWithRetry(() => openai.responses.parse({
|
||||||
|
model: defaultModel,
|
||||||
|
...serviceOptions,
|
||||||
|
...(canSearch ? { tools: [{ type: 'web_search' as const }] } : {}),
|
||||||
|
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
|
||||||
|
text: { format: zodTextFormat(RetrievalBatchSchema, "candidates") },
|
||||||
|
instructions: systemPrompt,
|
||||||
|
input: input,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const parsed = response.output_parsed as RetrievalBatchOutput | undefined;
|
||||||
|
const responseId = response.id;
|
||||||
|
|
||||||
|
if (!parsed || !parsed.candidates) {
|
||||||
|
return { candidates: [], responseId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: RetrievalCandidate[] = parsed.candidates.map((c) => ({
|
||||||
|
title: c.title,
|
||||||
|
reason: c.reason,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { candidates, responseId };
|
||||||
|
}
|
||||||
@@ -1,28 +1,22 @@
|
|||||||
import { openai } from '../agent.js';
|
import { openai, miniModel, serviceOptions } from '../agent.js';
|
||||||
import type { InterpreterOutput } from '../types/agents.js';
|
import type { InterpreterOutput, MediaType } from '../types/agents.js';
|
||||||
|
|
||||||
export async function generateTitle(interpreter: InterpreterOutput): Promise<string> {
|
export async function generateTitle(interpreter: InterpreterOutput, mediaType: MediaType = 'tv_show'): Promise<string> {
|
||||||
const response = await openai.chat.completions.create({
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
model: 'gpt-4o-mini',
|
|
||||||
|
const response = await openai.responses.create({
|
||||||
|
model: miniModel,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
service_tier: 'flex',
|
...serviceOptions,
|
||||||
messages: [
|
instructions: `Generate a concise 5-8 word title for a ${mediaLabel} recommendation session.
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `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.
|
Capture the essence of the user's taste — genre, tone, key themes.
|
||||||
Respond with ONLY the title. No quotes, no trailing punctuation.
|
Respond with ONLY the title. No quotes, no trailing punctuation.
|
||||||
Examples: "Dark Crime Dramas With Moral Ambiguity", "Cozy British Mysteries With Quirky Detectives"`,
|
Examples: "Dark Crime Dramas With Moral Ambiguity", "Cozy British Mysteries With Quirky Detectives"`,
|
||||||
},
|
input: `Liked: ${JSON.stringify(interpreter.liked)}
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: `Liked: ${JSON.stringify(interpreter.liked)}
|
|
||||||
Themes: ${JSON.stringify(interpreter.themes)}
|
Themes: ${JSON.stringify(interpreter.themes)}
|
||||||
Tone: ${JSON.stringify(interpreter.tone)}
|
Tone: ${JSON.stringify(interpreter.tone)}
|
||||||
Character preferences: ${JSON.stringify(interpreter.character_preferences)}`,
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { openai, defaultModel, serviceOptions, supportsWebSearch, parseWithRetry } from '../agent.js';
|
||||||
|
import type { RetrievalCandidate, ValidatorOutput, MediaType } from '../types/agents.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodTextFormat } from 'openai/helpers/zod';
|
||||||
|
|
||||||
|
const ValidatorSchema = z.object({
|
||||||
|
candidates: z.array(z.object({
|
||||||
|
title: z.string(),
|
||||||
|
reason: z.string(),
|
||||||
|
isTrash: z.boolean(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 30;
|
||||||
|
|
||||||
|
function splitIntoChunks<T>(items: T[], size: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
chunks.push(items.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runValidatorChunk(
|
||||||
|
candidates: RetrievalCandidate[],
|
||||||
|
mediaLabel: string,
|
||||||
|
): Promise<ValidatorOutput> {
|
||||||
|
const list = candidates.map((c) => `- ${c.title}: ${c.reason}`).join('\n');
|
||||||
|
|
||||||
|
const response = await parseWithRetry(() => openai.responses.parse({
|
||||||
|
model: defaultModel,
|
||||||
|
temperature: 0.1,
|
||||||
|
...serviceOptions,
|
||||||
|
...(supportsWebSearch ? { tools: [{ type: 'web_search' as const }] } : {}),
|
||||||
|
text: { format: zodTextFormat(ValidatorSchema, 'validation') },
|
||||||
|
instructions: `You are a ${mediaLabel} metadata validator. For each candidate in the list, use web search to verify:
|
||||||
|
1. The title actually exists as a real, produced ${mediaLabel} (not a made-up or hallucinated title)
|
||||||
|
2. Correct the "reason" field with accurate metadata (actual genres, tone, year) if it contains errors
|
||||||
|
|
||||||
|
Set isTrash: true for entries that:
|
||||||
|
- Do not exist as a real ${mediaLabel}
|
||||||
|
- Are clearly hallucinated or fictional titles
|
||||||
|
- Are so incorrect that no real match can be identified
|
||||||
|
|
||||||
|
Set isTrash: false for real, verifiable ${mediaLabel}s, even if minor metadata corrections are needed.
|
||||||
|
Return every candidate — do not drop any entries from the output.`,
|
||||||
|
input: `Validate these ${mediaLabel} candidates:\n${list}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (response.output_parsed as ValidatorOutput) ?? { candidates: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runValidator(
|
||||||
|
candidates: RetrievalCandidate[],
|
||||||
|
mediaType: MediaType = 'tv_show',
|
||||||
|
): Promise<ValidatorOutput> {
|
||||||
|
const mediaLabel = mediaType === 'movie' ? 'movie' : 'TV show';
|
||||||
|
const chunks = splitIntoChunks(candidates, CHUNK_SIZE);
|
||||||
|
|
||||||
|
const chunkResults = await Promise.all(
|
||||||
|
chunks.map((chunk) => runValidatorChunk(chunk, mediaLabel))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidates: chunkResults.flatMap((r) => r.candidates),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { pgTable, uuid, text, jsonb, timestamp, integer, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, uuid, text, jsonb, timestamp, integer, uniqueIndex, boolean } from 'drizzle-orm/pg-core';
|
||||||
import type { CuratorOutput } from '../types/agents.js';
|
import type { CuratorOutput } from '../types/agents.js';
|
||||||
|
|
||||||
export const recommendations = pgTable('recommendations', {
|
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'),
|
||||||
|
use_web_search: boolean('use_web_search').notNull().default(false),
|
||||||
|
use_validator: boolean('use_validator').notNull().default(false),
|
||||||
|
hard_requirements: boolean('hard_requirements').notNull().default(false),
|
||||||
|
self_expansive: boolean('self_expansive').notNull().default(false),
|
||||||
|
expansive_passes: integer('expansive_passes').notNull().default(1),
|
||||||
|
expansive_mode: text('expansive_mode').notNull().default('soft'),
|
||||||
recommendations: jsonb('recommendations').$type<CuratorOutput[]>(),
|
recommendations: jsonb('recommendations').$type<CuratorOutput[]>(),
|
||||||
status: text('status').notNull().default('pending'),
|
status: text('status').notNull().default('pending'),
|
||||||
created_at: timestamp('created_at').defaultNow().notNull(),
|
created_at: timestamp('created_at').defaultNow().notNull(),
|
||||||
@@ -18,10 +25,10 @@ export const feedback = pgTable(
|
|||||||
'feedback',
|
'feedback',
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
tv_show_name: text('tv_show_name').notNull(),
|
item_name: text('item_name').notNull(),
|
||||||
stars: integer('stars').notNull(),
|
stars: integer('stars').notNull(),
|
||||||
feedback: text('feedback').notNull().default(''),
|
feedback: text('feedback').notNull().default(''),
|
||||||
created_at: timestamp('created_at').defaultNow().notNull(),
|
created_at: timestamp('created_at').defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex('feedback_tv_show_name_idx').on(table.tv_show_name)],
|
(table) => [uniqueIndex('feedback_item_name_idx').on(table.item_name)],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
dotenv.config({ path: ['.env.local', '.env'] });
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('DATABASE_URL is not set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using max: 1 connection since it's only for migration
|
||||||
|
const migrationClient = postgres(connectionString, { max: 1 });
|
||||||
|
const db = drizzle(migrationClient);
|
||||||
|
|
||||||
|
const runMigrations = async () => {
|
||||||
|
console.log('Running database migrations...');
|
||||||
|
try {
|
||||||
|
const folder = path.join(__dirname, '../drizzle');
|
||||||
|
// print all migrations
|
||||||
|
const migrations = await fs.readdir(folder);
|
||||||
|
console.log('Migrations:', JSON.stringify(migrations));
|
||||||
|
|
||||||
|
await migrate(db, { migrationsFolder: folder });
|
||||||
|
console.log('Migrations completed successfully.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error running migrations:', err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await migrationClient.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runMigrations();
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { SSEEvent } from './types/agents.js';
|
||||||
|
|
||||||
|
type Listener = (event: SSEEvent) => void;
|
||||||
|
|
||||||
|
interface PipelineSession {
|
||||||
|
history: SSEEvent[];
|
||||||
|
listeners: Set<Listener>;
|
||||||
|
terminal: boolean;
|
||||||
|
cleanupTimer: NodeJS.Timeout | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, PipelineSession>();
|
||||||
|
const CLEANUP_DELAY_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function getOrCreateSession(recId: string): PipelineSession {
|
||||||
|
const existing = sessions.get(recId);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.cleanupTimer) {
|
||||||
|
clearTimeout(existing.cleanupTimer);
|
||||||
|
existing.cleanupTimer = null;
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: PipelineSession = {
|
||||||
|
history: [],
|
||||||
|
listeners: new Set(),
|
||||||
|
terminal: false,
|
||||||
|
cleanupTimer: null,
|
||||||
|
};
|
||||||
|
sessions.set(recId, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCleanup(recId: string, session: PipelineSession): void {
|
||||||
|
if (session.cleanupTimer) {
|
||||||
|
clearTimeout(session.cleanupTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.cleanupTimer = setTimeout(() => {
|
||||||
|
const current = sessions.get(recId);
|
||||||
|
if (current === session && current.listeners.size === 0) {
|
||||||
|
sessions.delete(recId);
|
||||||
|
}
|
||||||
|
}, CLEANUP_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPipelineSession(recId: string): void {
|
||||||
|
const existing = sessions.get(recId);
|
||||||
|
if (existing?.cleanupTimer) {
|
||||||
|
clearTimeout(existing.cleanupTimer);
|
||||||
|
}
|
||||||
|
sessions.delete(recId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPipelineEvent(recId: string, event: SSEEvent): void {
|
||||||
|
const session = getOrCreateSession(recId);
|
||||||
|
session.history.push(event);
|
||||||
|
|
||||||
|
for (const listener of session.listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.stage === 'complete' || event.status === 'error') {
|
||||||
|
session.terminal = true;
|
||||||
|
if (session.listeners.size === 0) {
|
||||||
|
scheduleCleanup(recId, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPipelineHistory(recId: string): SSEEvent[] {
|
||||||
|
return sessions.get(recId)?.history ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPipelineSession(recId: string): boolean {
|
||||||
|
return sessions.has(recId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToPipelineEvents(recId: string, listener: Listener): () => void {
|
||||||
|
const session = getOrCreateSession(recId);
|
||||||
|
session.listeners.add(listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const current = sessions.get(recId);
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
current.listeners.delete(listener);
|
||||||
|
if (current.terminal && current.listeners.size === 0) {
|
||||||
|
scheduleCleanup(recId, current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db.js';
|
||||||
|
import { recommendations } from '../db/schema.js';
|
||||||
|
import { runInterpreter } from '../agents/interpreter.js';
|
||||||
|
import { runRetrievalBatch } from '../agents/retrievalContinuous.js';
|
||||||
|
import { runValidator } from '../agents/validator.js';
|
||||||
|
import { runRanking } from '../agents/ranking.js';
|
||||||
|
import { runCurator } from '../agents/curator.js';
|
||||||
|
import type { CuratorOutput, InterpreterOutput, MediaType, RankingOutput, RetrievalCandidate, SSEEvent } from '../types/agents.js';
|
||||||
|
import { generateTitle } from '../agents/titleGenerator.js';
|
||||||
|
|
||||||
|
function log(msg: string, data?: unknown) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
if (data !== undefined) {
|
||||||
|
console.log(`[continuous-pipeline] [${ts}] ${msg}`, data);
|
||||||
|
} else {
|
||||||
|
console.log(`[continuous-pipeline] [${ts}] ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateCandidates(candidates: RetrievalCandidate[], seenTitles: Set<string>): RetrievalCandidate[] {
|
||||||
|
return candidates.filter((c) => {
|
||||||
|
const key = c.title.toLowerCase();
|
||||||
|
if (seenTitles.has(key)) return false;
|
||||||
|
seenTitles.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitIntoBuckets<T>(items: T[], n: number): T[][] {
|
||||||
|
const size = Math.ceil(items.length / n);
|
||||||
|
return Array.from({ length: n }, (_, i) => items.slice(i * size, (i + 1) * size))
|
||||||
|
.filter((b) => b.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCuratorOutputs(a: CuratorOutput[], b: CuratorOutput[]): CuratorOutput[] {
|
||||||
|
const seen = new Set(a.map((x) => x.title.toLowerCase()));
|
||||||
|
return [...a, ...b.filter((x) => !seen.has(x.title.toLowerCase()))];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContinuousPipelineInput {
|
||||||
|
likedSeries: string;
|
||||||
|
dislikedSeries?: string;
|
||||||
|
themes?: string;
|
||||||
|
requirements?: string;
|
||||||
|
avoid?: string;
|
||||||
|
totalCount: number;
|
||||||
|
useWebSearch: boolean;
|
||||||
|
validateResults: boolean;
|
||||||
|
mediaType: MediaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runContinuousPipeline(
|
||||||
|
recId: string,
|
||||||
|
input: ContinuousPipelineInput,
|
||||||
|
sseWrite: (event: SSEEvent) => void,
|
||||||
|
): Promise<CuratorOutput[]> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const {
|
||||||
|
likedSeries,
|
||||||
|
dislikedSeries = '',
|
||||||
|
themes = '',
|
||||||
|
requirements = '',
|
||||||
|
avoid = '',
|
||||||
|
totalCount,
|
||||||
|
useWebSearch,
|
||||||
|
validateResults,
|
||||||
|
mediaType,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const totalBatches = Math.ceil(totalCount / 10);
|
||||||
|
const allSeenTitles = new Set<string>();
|
||||||
|
let previousResponseId: string | null = null;
|
||||||
|
let interpreterOutput: InterpreterOutput | null = null;
|
||||||
|
const accumulatedCandidates: RetrievalCandidate[] = [];
|
||||||
|
|
||||||
|
log(`Starting continuous pipeline: ${totalCount} titles (${totalBatches} batches), mediaType=${mediaType}, webSearch=${useWebSearch}, validate=${validateResults}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Interpreter (runs once) ---
|
||||||
|
log('Interpreter: start');
|
||||||
|
sseWrite({ stage: 'interpreter', status: 'start' });
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
interpreterOutput = await runInterpreter({
|
||||||
|
main_prompt: themes || 'recommend series based on user preferences',
|
||||||
|
liked_series: likedSeries,
|
||||||
|
disliked_series: dislikedSeries,
|
||||||
|
themes: themes,
|
||||||
|
media_type: mediaType,
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`Interpreter: done (${Date.now() - t0}ms)`, {
|
||||||
|
liked: interpreterOutput.liked,
|
||||||
|
themes: interpreterOutput.themes,
|
||||||
|
});
|
||||||
|
sseWrite({ stage: 'interpreter', status: 'done', data: interpreterOutput });
|
||||||
|
|
||||||
|
// --- Retrieval (batched, chained) ---
|
||||||
|
log(`Retrieval: start (${totalBatches} batches)`);
|
||||||
|
sseWrite({ stage: 'retrieval', status: 'start' });
|
||||||
|
|
||||||
|
const hardRequirements = requirements.length > 0;
|
||||||
|
|
||||||
|
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
|
||||||
|
log(`Retrieval batch ${batchIndex + 1}/${totalBatches}: start`);
|
||||||
|
sseWrite({
|
||||||
|
stage: 'retrieval',
|
||||||
|
status: 'start',
|
||||||
|
data: {
|
||||||
|
batch: batchIndex + 1,
|
||||||
|
totalBatches,
|
||||||
|
totalCandidates: accumulatedCandidates.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const seenTitlesArray = Array.from(allSeenTitles);
|
||||||
|
const result = await runRetrievalBatch(
|
||||||
|
interpreterOutput,
|
||||||
|
mediaType,
|
||||||
|
useWebSearch,
|
||||||
|
hardRequirements,
|
||||||
|
seenTitlesArray,
|
||||||
|
previousResponseId
|
||||||
|
);
|
||||||
|
|
||||||
|
previousResponseId = result.responseId;
|
||||||
|
|
||||||
|
// Deduplicate against previously seen titles
|
||||||
|
const deduped = deduplicateCandidates(result.candidates, allSeenTitles);
|
||||||
|
accumulatedCandidates.push(...deduped);
|
||||||
|
|
||||||
|
log(`Retrieval batch ${batchIndex + 1}/${totalBatches}: done, ${deduped.length} new candidates (total: ${accumulatedCandidates.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Retrieval: complete, ${accumulatedCandidates.length} total candidates`);
|
||||||
|
sseWrite({
|
||||||
|
stage: 'retrieval',
|
||||||
|
status: 'done',
|
||||||
|
data: {
|
||||||
|
totalBatches,
|
||||||
|
totalCandidates: accumulatedCandidates.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Validator (optional, per batch already done during retrieval) ---
|
||||||
|
// Note: In continuous mode, we could run validator after each batch
|
||||||
|
// For now, we'll run it once after all batches if validateResults is true
|
||||||
|
let candidatesForRanking = accumulatedCandidates;
|
||||||
|
|
||||||
|
if (validateResults && candidatesForRanking.length > 0) {
|
||||||
|
log(`Validator: start (${candidatesForRanking.length} candidates)`);
|
||||||
|
sseWrite({ stage: 'validator', status: 'start' });
|
||||||
|
|
||||||
|
const tV = Date.now();
|
||||||
|
const validatorOutput = await runValidator(candidatesForRanking, mediaType);
|
||||||
|
const verified = validatorOutput.candidates.filter((c) => !c.isTrash);
|
||||||
|
const trashCount = validatorOutput.candidates.length - verified.length;
|
||||||
|
|
||||||
|
candidatesForRanking = verified.map(({ title, reason }) => ({ title, reason }));
|
||||||
|
allSeenTitles.clear();
|
||||||
|
candidatesForRanking.forEach((c) => allSeenTitles.add(c.title.toLowerCase()));
|
||||||
|
|
||||||
|
log(`Validator: done (${Date.now() - tV}ms) — removed ${trashCount} trash entries`);
|
||||||
|
sseWrite({ stage: 'validator', status: 'done', data: { removed: trashCount, remaining: candidatesForRanking.length } });
|
||||||
|
} else {
|
||||||
|
sseWrite({ stage: 'validator', status: 'done', data: { skipped: !validateResults } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ranking (bucketed) ---
|
||||||
|
log(`Ranking: start (${candidatesForRanking.length} candidates)`);
|
||||||
|
sseWrite({ stage: 'ranking', status: 'start' });
|
||||||
|
const t2 = Date.now();
|
||||||
|
|
||||||
|
const rankBucketCount = Math.ceil(candidatesForRanking.length / 15) || 1;
|
||||||
|
const candidateBuckets = splitIntoBuckets(candidatesForRanking, rankBucketCount);
|
||||||
|
|
||||||
|
const rankingBuckets = await Promise.all(
|
||||||
|
candidateBuckets.map((bucket) =>
|
||||||
|
runRanking(interpreterOutput!, { candidates: bucket }, mediaType, hardRequirements)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const dedupTitles = (titles: string[]) => [...new Map(titles.map((t) => [t.toLowerCase(), t])).values()];
|
||||||
|
const rankingOutput: RankingOutput = {
|
||||||
|
full_match: dedupTitles(rankingBuckets.flatMap((r) => r.full_match)),
|
||||||
|
definitely_like: dedupTitles(rankingBuckets.flatMap((r) => r.definitely_like)),
|
||||||
|
might_like: dedupTitles(rankingBuckets.flatMap((r) => r.might_like)),
|
||||||
|
questionable: dedupTitles(rankingBuckets.flatMap((r) => r.questionable)),
|
||||||
|
will_not_like: dedupTitles(rankingBuckets.flatMap((r) => r.will_not_like)),
|
||||||
|
};
|
||||||
|
|
||||||
|
log(`Ranking: done (${Date.now() - t2}ms)`, {
|
||||||
|
full_match: rankingOutput.full_match.length,
|
||||||
|
definitely_like: rankingOutput.definitely_like.length,
|
||||||
|
might_like: rankingOutput.might_like.length,
|
||||||
|
});
|
||||||
|
sseWrite({ stage: 'ranking', status: 'done', data: rankingOutput });
|
||||||
|
|
||||||
|
// print all ranked titles for debuggings
|
||||||
|
log('Ranked titles:', {
|
||||||
|
full_match: rankingOutput.full_match,
|
||||||
|
definitely_like: rankingOutput.definitely_like,
|
||||||
|
might_like: rankingOutput.might_like,
|
||||||
|
questionable: rankingOutput.questionable,
|
||||||
|
will_not_like: rankingOutput.will_not_like,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Curator (bucketed) ---
|
||||||
|
log(`Curator: start`);
|
||||||
|
sseWrite({ stage: 'curator', status: 'start' });
|
||||||
|
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 })),
|
||||||
|
...rankingOutput.will_not_like.map((t) => ({ title: t, category: 'will_not_like' as const })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const curatorBucketCount = Math.ceil(categorizedItems.length / 15) || 1;
|
||||||
|
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),
|
||||||
|
will_not_like: bucket.filter((i) => i.category === 'will_not_like').map((i) => i.title),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const curatorBucketOutputs = await Promise.all(
|
||||||
|
curatorBucketRankings.map((ranking) =>
|
||||||
|
runCurator(ranking, interpreterOutput!, mediaType, useWebSearch)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const curatorOutput = curatorBucketOutputs.reduce((acc, bucket) => mergeCuratorOutputs(acc, bucket), [] as CuratorOutput[]);
|
||||||
|
log(`Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} items curated`);
|
||||||
|
sseWrite({ stage: 'curator', status: 'done', data: curatorOutput });
|
||||||
|
|
||||||
|
// Generate AI title
|
||||||
|
let aiTitle = 'Continuous Recommendations';
|
||||||
|
try {
|
||||||
|
log('Title generation: start');
|
||||||
|
aiTitle = await generateTitle(interpreterOutput, mediaType);
|
||||||
|
log(`Title generation: done — "${aiTitle}"`);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Title generation failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by category order
|
||||||
|
const CATEGORY_ORDER: Record<string, number> = {
|
||||||
|
'Full Match': 0,
|
||||||
|
'Definitely Like': 1,
|
||||||
|
'Might Like': 2,
|
||||||
|
'Questionable': 3,
|
||||||
|
'Will Not Like': 4,
|
||||||
|
};
|
||||||
|
curatorOutput.sort((a, b) => (CATEGORY_ORDER[a.category] ?? 99) - (CATEGORY_ORDER[b.category] ?? 99));
|
||||||
|
|
||||||
|
// Save to database (update existing record)
|
||||||
|
log('Saving results to DB');
|
||||||
|
await db
|
||||||
|
.update(recommendations)
|
||||||
|
.set({
|
||||||
|
title: aiTitle,
|
||||||
|
main_prompt: themes || 'Continuous recommendations',
|
||||||
|
liked_series: likedSeries,
|
||||||
|
disliked_series: dislikedSeries,
|
||||||
|
themes: themes,
|
||||||
|
brainstorm_count: totalCount,
|
||||||
|
media_type: mediaType,
|
||||||
|
use_web_search: useWebSearch,
|
||||||
|
use_validator: validateResults,
|
||||||
|
status: 'done',
|
||||||
|
recommendations: curatorOutput,
|
||||||
|
})
|
||||||
|
.where(eq(recommendations.id, recId));
|
||||||
|
|
||||||
|
sseWrite({ stage: 'complete', status: 'done', data: { id: recId, title: aiTitle, batchCount: totalBatches } });
|
||||||
|
|
||||||
|
log(`Pipeline complete (total: ${Date.now() - startTime}ms), saved as ${recId}`);
|
||||||
|
return curatorOutput;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
log(`Pipeline error: ${message}`);
|
||||||
|
|
||||||
|
// Update status to error
|
||||||
|
await db
|
||||||
|
.update(recommendations)
|
||||||
|
.set({ status: 'error' })
|
||||||
|
.where(eq(recommendations.id, recId));
|
||||||
|
|
||||||
|
sseWrite({ stage: 'curator', status: 'error', data: { message } });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,47 @@ import { db } from '../db.js';
|
|||||||
import { recommendations } from '../db/schema.js';
|
import { recommendations } from '../db/schema.js';
|
||||||
import { runInterpreter } from '../agents/interpreter.js';
|
import { runInterpreter } from '../agents/interpreter.js';
|
||||||
import { runRetrieval } from '../agents/retrieval.js';
|
import { runRetrieval } from '../agents/retrieval.js';
|
||||||
|
import { runValidator } from '../agents/validator.js';
|
||||||
import { runRanking } from '../agents/ranking.js';
|
import { runRanking } from '../agents/ranking.js';
|
||||||
import { runCurator } from '../agents/curator.js';
|
import { runCurator } from '../agents/curator.js';
|
||||||
import type { CuratorOutput, SSEEvent } from '../types/agents.js';
|
import type { CuratorOutput, InterpreterOutput, MediaType, RankingOutput, RetrievalCandidate, SSEEvent } from '../types/agents.js';
|
||||||
import { generateTitle } from '../agents/titleGenerator.js';
|
import { generateTitle } from '../agents/titleGenerator.js';
|
||||||
|
|
||||||
/* -- Agent pipeline --
|
/* -- Agent pipeline --
|
||||||
[1] Interpreter -> gets user input, transforms into structured data
|
[1] Interpreter -> gets user input, transforms into structured data
|
||||||
[2] Retrieval -> gets shows from OpenAI (high temperature)
|
[2] Retrieval -> gets candidates from OpenAI (high temperature)
|
||||||
[3] Ranking -> ranks shows based on user input
|
[2.5] Validator (optional) -> verifies candidates exist, removes trash
|
||||||
[4] Curator -> curates shows based on user input
|
[3] Ranking -> ranks candidates based on user input
|
||||||
|
[4] Curator -> curates candidates based on user input
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type RecommendationRecord = typeof recommendations.$inferSelect;
|
type RecommendationRecord = typeof recommendations.$inferSelect;
|
||||||
|
|
||||||
|
function getBucketCount(count: number): number {
|
||||||
|
return Math.ceil(count / 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateCandidates(candidates: RetrievalCandidate[], seenTitles?: Set<string>): RetrievalCandidate[] {
|
||||||
|
const seen = seenTitles ?? new Set<string>();
|
||||||
|
return candidates.filter((c) => {
|
||||||
|
const key = c.title.toLowerCase();
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitIntoBuckets<T>(items: T[], n: number): T[][] {
|
||||||
|
const size = Math.ceil(items.length / n);
|
||||||
|
return Array.from({ length: n }, (_, i) => items.slice(i * size, (i + 1) * size))
|
||||||
|
.filter((b) => b.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCuratorOutputs(a: CuratorOutput[], b: CuratorOutput[]): CuratorOutput[] {
|
||||||
|
const seen = new Set(a.map((x) => x.title.toLowerCase()));
|
||||||
|
return [...a, ...b.filter((x) => !seen.has(x.title.toLowerCase()))];
|
||||||
|
}
|
||||||
|
|
||||||
function log(recId: string, msg: string, data?: unknown) {
|
function log(recId: string, msg: string, data?: unknown) {
|
||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
if (data !== undefined) {
|
if (data !== undefined) {
|
||||||
@@ -26,6 +53,124 @@ function log(recId: string, msg: string, data?: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubPipelineCtx {
|
||||||
|
recId: string;
|
||||||
|
interpreterOutput: InterpreterOutput;
|
||||||
|
mediaType: MediaType;
|
||||||
|
useWebSearch: boolean;
|
||||||
|
useValidator: boolean;
|
||||||
|
useHardRequirements: boolean;
|
||||||
|
brainstormCount: number;
|
||||||
|
previousFullMatches: string[];
|
||||||
|
allSeenTitles: Set<string>;
|
||||||
|
stagePrefix: string;
|
||||||
|
sseWrite: (event: SSEEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSubPipeline(ctx: SubPipelineCtx): Promise<CuratorOutput[]> {
|
||||||
|
const {
|
||||||
|
recId, interpreterOutput, mediaType, useWebSearch, useValidator,
|
||||||
|
useHardRequirements, brainstormCount, previousFullMatches,
|
||||||
|
allSeenTitles, stagePrefix, sseWrite,
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
|
const p = (stage: string) => (stagePrefix + stage) as SSEEvent['stage'];
|
||||||
|
|
||||||
|
// --- Retrieval (bucketed) ---
|
||||||
|
log(recId, `${stagePrefix}Retrieval: start`);
|
||||||
|
sseWrite({ stage: p('retrieval'), status: 'start' });
|
||||||
|
const t1 = Date.now();
|
||||||
|
const retrievalBucketCount = getBucketCount(brainstormCount);
|
||||||
|
const perBucketCount = Math.ceil(brainstormCount / retrievalBucketCount);
|
||||||
|
const retrievalBuckets = await Promise.all(
|
||||||
|
Array.from({ length: retrievalBucketCount }, () =>
|
||||||
|
runRetrieval(interpreterOutput, perBucketCount, mediaType, useWebSearch, useHardRequirements, previousFullMatches)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const allCandidates = retrievalBuckets.flatMap((r) => r.candidates);
|
||||||
|
const dedupedCandidates = deduplicateCandidates(allCandidates, allSeenTitles);
|
||||||
|
log(recId, `${stagePrefix}Retrieval: done (${Date.now() - t1}ms) — ${dedupedCandidates.length} candidates (${retrievalBucketCount} buckets, ${allCandidates.length} before dedup)`, {
|
||||||
|
titles: dedupedCandidates.map((c) => c.title),
|
||||||
|
});
|
||||||
|
sseWrite({ stage: p('retrieval'), status: 'done', data: { candidates: dedupedCandidates } });
|
||||||
|
|
||||||
|
// --- Validator (optional) ---
|
||||||
|
let candidatesForRanking = dedupedCandidates;
|
||||||
|
if (useValidator) {
|
||||||
|
log(recId, `${stagePrefix}Validator: start`);
|
||||||
|
sseWrite({ stage: p('validator'), status: 'start' });
|
||||||
|
const tV = Date.now();
|
||||||
|
const validatorOutput = await runValidator(dedupedCandidates, mediaType);
|
||||||
|
const verified = validatorOutput.candidates.filter((c) => !c.isTrash);
|
||||||
|
const trashCount = validatorOutput.candidates.length - verified.length;
|
||||||
|
candidatesForRanking = verified.map(({ title, reason }) => ({ title, reason }));
|
||||||
|
log(recId, `${stagePrefix}Validator: done (${Date.now() - tV}ms) — removed ${trashCount} trash entries`);
|
||||||
|
sseWrite({ stage: p('validator'), status: 'done', data: { removed: trashCount } });
|
||||||
|
} else {
|
||||||
|
sseWrite({ stage: p('validator'), status: 'done', data: { skipped: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ranking (bucketed) ---
|
||||||
|
log(recId, `${stagePrefix}Ranking: start`);
|
||||||
|
sseWrite({ stage: p('ranking'), status: 'start' });
|
||||||
|
const t2 = Date.now();
|
||||||
|
const rankBucketCount = getBucketCount(candidatesForRanking.length);
|
||||||
|
const candidateBuckets = splitIntoBuckets(candidatesForRanking, rankBucketCount);
|
||||||
|
const rankingBuckets = await Promise.all(
|
||||||
|
candidateBuckets.map((bucket) =>
|
||||||
|
runRanking(interpreterOutput, { candidates: bucket }, mediaType, useHardRequirements)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const dedupTitles = (titles: string[]) => [...new Map(titles.map((t) => [t.toLowerCase(), t])).values()];
|
||||||
|
const rankingOutput: RankingOutput = {
|
||||||
|
full_match: dedupTitles(rankingBuckets.flatMap((r) => r.full_match)),
|
||||||
|
definitely_like: dedupTitles(rankingBuckets.flatMap((r) => r.definitely_like)),
|
||||||
|
might_like: dedupTitles(rankingBuckets.flatMap((r) => r.might_like)),
|
||||||
|
questionable: dedupTitles(rankingBuckets.flatMap((r) => r.questionable)),
|
||||||
|
will_not_like: dedupTitles(rankingBuckets.flatMap((r) => r.will_not_like)),
|
||||||
|
};
|
||||||
|
log(recId, `${stagePrefix}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,
|
||||||
|
will_not_like: rankingOutput.will_not_like.length,
|
||||||
|
});
|
||||||
|
sseWrite({ stage: p('ranking'), status: 'done', data: rankingOutput });
|
||||||
|
|
||||||
|
// --- Curator (bucketed) ---
|
||||||
|
log(recId, `${stagePrefix}Curator: start`);
|
||||||
|
sseWrite({ stage: p('curator'), status: 'start' });
|
||||||
|
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 })),
|
||||||
|
...rankingOutput.will_not_like.map((t) => ({ title: t, category: 'will_not_like' as const })),
|
||||||
|
];
|
||||||
|
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),
|
||||||
|
will_not_like: bucket.filter((i) => i.category === 'will_not_like').map((i) => i.title),
|
||||||
|
}));
|
||||||
|
const curatorBucketOutputs = await Promise.all(
|
||||||
|
curatorBucketRankings.map((ranking) =>
|
||||||
|
runCurator(ranking, interpreterOutput, mediaType, useWebSearch)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const curatorOutput = curatorBucketOutputs.reduce((acc, bucket) => mergeCuratorOutputs(acc, bucket), [] as CuratorOutput[]);
|
||||||
|
log(recId, `${stagePrefix}Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} items curated (${curatorBucketCount} buckets)`);
|
||||||
|
sseWrite({ stage: p('curator'), status: 'done', data: curatorOutput });
|
||||||
|
|
||||||
|
return curatorOutput;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runPipeline(
|
export async function runPipeline(
|
||||||
rec: RecommendationRecord,
|
rec: RecommendationRecord,
|
||||||
sseWrite: (event: SSEEvent) => void,
|
sseWrite: (event: SSEEvent) => void,
|
||||||
@@ -33,8 +178,13 @@ export async function runPipeline(
|
|||||||
): Promise<CuratorOutput[]> {
|
): Promise<CuratorOutput[]> {
|
||||||
let currentStage: SSEEvent['stage'] = 'interpreter';
|
let currentStage: SSEEvent['stage'] = 'interpreter';
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const mediaType = (rec.media_type ?? 'tv_show') as MediaType;
|
||||||
|
const useWebSearch = rec.use_web_search ?? false;
|
||||||
|
const useValidator = rec.use_validator ?? false;
|
||||||
|
const useHardRequirements = rec.hard_requirements ?? false;
|
||||||
|
const selfExpansive = rec.self_expansive ?? false;
|
||||||
|
|
||||||
log(rec.id, `Starting pipeline for "${rec.title}"${feedbackContext ? ' (with feedback context)' : ''}`);
|
log(rec.id, `Starting pipeline for "${rec.title}" [${mediaType}${useWebSearch ? ', web_search' : ''}${useValidator ? ', validator' : ''}${useHardRequirements ? ', hard_req' : ''}${selfExpansive ? `, expansive×${rec.expansive_passes}(${rec.expansive_mode})` : ''}]${feedbackContext ? ' (with feedback context)' : ''}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set status to running
|
// Set status to running
|
||||||
@@ -51,9 +201,10 @@ 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,
|
||||||
...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}),
|
...(feedbackContext !== undefined ? { feedback_context: feedbackContext } : {}),
|
||||||
});
|
});
|
||||||
log(rec.id, `Interpreter: done (${Date.now() - t0}ms)`, {
|
log(rec.id, `Interpreter: done (${Date.now() - t0}ms)`, {
|
||||||
@@ -65,61 +216,101 @@ export async function runPipeline(
|
|||||||
});
|
});
|
||||||
sseWrite({ stage: 'interpreter', status: 'done', data: interpreterOutput });
|
sseWrite({ stage: 'interpreter', status: 'done', data: interpreterOutput });
|
||||||
|
|
||||||
// --- Retrieval ---
|
// --- Pass 1: Retrieval → [Validator?] → Ranking → Curator ---
|
||||||
currentStage = 'retrieval';
|
currentStage = 'retrieval';
|
||||||
log(rec.id, 'Retrieval: start');
|
const allSeenTitles = new Set<string>();
|
||||||
sseWrite({ stage: 'retrieval', status: 'start' });
|
const pass1Output = await runSubPipeline({
|
||||||
const t1 = Date.now();
|
recId: rec.id,
|
||||||
const retrievalOutput = await runRetrieval(interpreterOutput, rec.brainstorm_count);
|
interpreterOutput,
|
||||||
log(rec.id, `Retrieval: done (${Date.now() - t1}ms) — ${retrievalOutput.candidates.length} candidates`, {
|
mediaType,
|
||||||
titles: retrievalOutput.candidates.map((c) => c.title),
|
useWebSearch,
|
||||||
|
useValidator,
|
||||||
|
useHardRequirements,
|
||||||
|
brainstormCount: rec.brainstorm_count,
|
||||||
|
previousFullMatches: [],
|
||||||
|
allSeenTitles,
|
||||||
|
stagePrefix: '',
|
||||||
|
sseWrite: (event) => {
|
||||||
|
currentStage = event.stage;
|
||||||
|
sseWrite(event);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
sseWrite({ stage: 'retrieval', status: 'done', data: retrievalOutput });
|
|
||||||
|
|
||||||
// --- Ranking ---
|
let mergedOutput = pass1Output;
|
||||||
currentStage = 'ranking';
|
|
||||||
log(rec.id, 'Ranking: start');
|
// --- Self Expansive: extra passes ---
|
||||||
sseWrite({ stage: 'ranking', status: 'start' });
|
if (selfExpansive && rec.expansive_passes > 0) {
|
||||||
const t2 = Date.now();
|
const allFullMatches = pass1Output
|
||||||
const rankingOutput = await runRanking(interpreterOutput, retrievalOutput);
|
.filter((c) => c.category === 'Full Match')
|
||||||
log(rec.id, `Ranking: done (${Date.now() - t2}ms)`, {
|
.map((c) => c.title);
|
||||||
definitely_like: rankingOutput.definitely_like.length,
|
|
||||||
might_like: rankingOutput.might_like.length,
|
for (let i = 0; i < rec.expansive_passes; i++) {
|
||||||
questionable: rankingOutput.questionable.length,
|
const passNum = i + 2;
|
||||||
will_not_like: rankingOutput.will_not_like.length,
|
const passCount = rec.expansive_mode === 'extreme' ? rec.brainstorm_count : 60;
|
||||||
|
const passPrefix = `pass${passNum}:` as const;
|
||||||
|
|
||||||
|
log(rec.id, `Self Expansive Pass ${passNum}: start (${passCount} candidates, ${allFullMatches.length} full matches as context)`);
|
||||||
|
currentStage = `${passPrefix}retrieval` as SSEEvent['stage'];
|
||||||
|
|
||||||
|
const passOutput = await runSubPipeline({
|
||||||
|
recId: rec.id,
|
||||||
|
interpreterOutput,
|
||||||
|
mediaType,
|
||||||
|
useWebSearch,
|
||||||
|
useValidator,
|
||||||
|
useHardRequirements,
|
||||||
|
brainstormCount: passCount,
|
||||||
|
previousFullMatches: [...allFullMatches],
|
||||||
|
allSeenTitles,
|
||||||
|
stagePrefix: passPrefix,
|
||||||
|
sseWrite: (event) => {
|
||||||
|
currentStage = event.stage;
|
||||||
|
sseWrite(event);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
sseWrite({ stage: 'ranking', status: 'done', data: rankingOutput });
|
|
||||||
|
|
||||||
// --- Curator ---
|
mergedOutput = mergeCuratorOutputs(mergedOutput, passOutput);
|
||||||
currentStage = 'curator';
|
|
||||||
log(rec.id, 'Curator: start');
|
const newFullMatches = passOutput
|
||||||
sseWrite({ stage: 'curator', status: 'start' });
|
.filter((c) => c.category === 'Full Match')
|
||||||
const t3 = Date.now();
|
.map((c) => c.title);
|
||||||
const curatorOutput = await runCurator(rankingOutput, interpreterOutput);
|
allFullMatches.push(...newFullMatches);
|
||||||
log(rec.id, `Curator: done (${Date.now() - t3}ms) — ${curatorOutput.length} shows curated`);
|
|
||||||
sseWrite({ stage: 'curator', status: 'done', data: curatorOutput });
|
log(rec.id, `Self Expansive Pass ${passNum}: done — ${passOutput.length} new items, ${mergedOutput.length} total`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate AI title
|
// Generate AI title
|
||||||
let aiTitle: string = rec.title;
|
let aiTitle: string = rec.title;
|
||||||
try {
|
try {
|
||||||
log(rec.id, 'Title generation: start');
|
log(rec.id, 'Title generation: start');
|
||||||
aiTitle = await generateTitle(interpreterOutput);
|
aiTitle = await generateTitle(interpreterOutput, mediaType);
|
||||||
log(rec.id, `Title generation: done — "${aiTitle}"`);
|
log(rec.id, `Title generation: done — "${aiTitle}"`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(rec.id, `Title generation failed, keeping initial title: ${String(err)}`);
|
log(rec.id, `Title generation failed, keeping initial title: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by category order before saving
|
||||||
|
const CATEGORY_ORDER: Record<string, number> = {
|
||||||
|
'Full Match': 0,
|
||||||
|
'Definitely Like': 1,
|
||||||
|
'Might Like': 2,
|
||||||
|
'Questionable': 3,
|
||||||
|
'Will Not Like': 4,
|
||||||
|
};
|
||||||
|
mergedOutput.sort((a, b) => (CATEGORY_ORDER[a.category] ?? 99) - (CATEGORY_ORDER[b.category] ?? 99));
|
||||||
|
|
||||||
// Save results to DB
|
// Save results to DB
|
||||||
log(rec.id, 'Saving results to DB');
|
log(rec.id, 'Saving results to DB');
|
||||||
await db
|
await db
|
||||||
.update(recommendations)
|
.update(recommendations)
|
||||||
.set({ recommendations: curatorOutput, status: 'done', title: aiTitle })
|
.set({ recommendations: mergedOutput, status: 'done', title: aiTitle })
|
||||||
.where(eq(recommendations.id, rec.id));
|
.where(eq(recommendations.id, rec.id));
|
||||||
|
|
||||||
sseWrite({ stage: 'complete', status: 'done' });
|
sseWrite({ stage: 'complete', status: 'done', data: { title: aiTitle } });
|
||||||
|
|
||||||
log(rec.id, `Pipeline complete (total: ${Date.now() - startTime}ms)`);
|
log(rec.id, `Pipeline complete (total: ${Date.now() - startTime}ms)`);
|
||||||
return curatorOutput;
|
return mergedOutput;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
log(rec.id, `Pipeline error at stage "${currentStage}": ${message}`);
|
log(rec.id, `Pipeline error at stage "${currentStage}": ${message}`);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { db } from '../db.js';
|
import { db } from '../db.js';
|
||||||
import { feedback } from '../db/schema.js';
|
import { feedback } from '../db/schema.js';
|
||||||
|
|
||||||
export default async function feedbackRoute(fastify: FastifyInstance) {
|
export default async function feedbackRoute(fastify: FastifyInstance) {
|
||||||
// POST /feedback — upsert by tv_show_name
|
// POST /feedback — upsert by item_name
|
||||||
fastify.post('/feedback', async (request, reply) => {
|
fastify.post('/feedback', async (request, reply) => {
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
tv_show_name: string;
|
item_name: string;
|
||||||
stars: number;
|
stars: number;
|
||||||
feedback?: string;
|
feedback?: string;
|
||||||
};
|
};
|
||||||
@@ -15,12 +14,12 @@ export default async function feedbackRoute(fastify: FastifyInstance) {
|
|||||||
await db
|
await db
|
||||||
.insert(feedback)
|
.insert(feedback)
|
||||||
.values({
|
.values({
|
||||||
tv_show_name: body.tv_show_name,
|
item_name: body.item_name,
|
||||||
stars: body.stars,
|
stars: body.stars,
|
||||||
feedback: body.feedback ?? '',
|
feedback: body.feedback ?? '',
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: feedback.tv_show_name,
|
target: feedback.item_name,
|
||||||
set: {
|
set: {
|
||||||
stars: body.stars,
|
stars: body.stars,
|
||||||
feedback: body.feedback ?? '',
|
feedback: body.feedback ?? '',
|
||||||
|
|||||||
@@ -3,17 +3,43 @@ import { eq, desc } from 'drizzle-orm';
|
|||||||
import { db } from '../db.js';
|
import { db } from '../db.js';
|
||||||
import { recommendations, feedback } from '../db/schema.js';
|
import { recommendations, feedback } from '../db/schema.js';
|
||||||
import { runPipeline } from '../pipelines/recommendation.js';
|
import { runPipeline } from '../pipelines/recommendation.js';
|
||||||
import type { SSEEvent } from '../types/agents.js';
|
import { runContinuousPipeline } from '../pipelines/continuous.js';
|
||||||
|
import type { MediaType, SSEEvent } from '../types/agents.js';
|
||||||
|
import { supportsWebSearch } from '../agent.js';
|
||||||
|
import {
|
||||||
|
getPipelineHistory,
|
||||||
|
hasPipelineSession,
|
||||||
|
publishPipelineEvent,
|
||||||
|
resetPipelineSession,
|
||||||
|
subscribeToPipelineEvents,
|
||||||
|
} from '../pipelineStreams.js';
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function isUuid(id: string): boolean {
|
||||||
|
return UUID_RE.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSSE(raw: typeof import('node:http').ServerResponse.prototype, event: SSEEvent): void {
|
||||||
|
raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function recommendationsRoute(fastify: FastifyInstance) {
|
export default async function recommendationsRoute(fastify: FastifyInstance) {
|
||||||
// POST /recommendations — create record, return { id }
|
// POST /recommendations — create record, return { id }
|
||||||
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;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
use_validator?: boolean;
|
||||||
|
hard_requirements?: boolean;
|
||||||
|
self_expansive?: boolean;
|
||||||
|
expansive_passes?: number;
|
||||||
|
expansive_mode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = (body.main_prompt ?? '')
|
const title = (body.main_prompt ?? '')
|
||||||
@@ -24,16 +50,31 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
const rawCount = Number(body.brainstorm_count ?? 100);
|
const rawCount = Number(body.brainstorm_count ?? 100);
|
||||||
const brainstorm_count = Number.isFinite(rawCount) ? Math.min(200, Math.max(50, rawCount)) : 100;
|
const brainstorm_count = Number.isFinite(rawCount) ? Math.min(200, Math.max(50, rawCount)) : 100;
|
||||||
|
const media_type: MediaType = body.media_type === 'movie' ? 'movie' : 'tv_show';
|
||||||
|
const use_web_search = body.use_web_search === true;
|
||||||
|
const use_validator = body.use_validator === true && supportsWebSearch;
|
||||||
|
const hard_requirements = body.hard_requirements === true;
|
||||||
|
const self_expansive = body.self_expansive === true;
|
||||||
|
const rawPasses = Number(body.expansive_passes ?? 2);
|
||||||
|
const expansive_passes = Number.isFinite(rawPasses) ? Math.min(5, Math.max(1, rawPasses)) : 2;
|
||||||
|
const expansive_mode = body.expansive_mode === 'extreme' ? 'extreme' : 'soft';
|
||||||
|
|
||||||
const [rec] = await db
|
const [rec] = await db
|
||||||
.insert(recommendations)
|
.insert(recommendations)
|
||||||
.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,
|
||||||
|
use_web_search,
|
||||||
|
use_validator,
|
||||||
|
hard_requirements,
|
||||||
|
self_expansive,
|
||||||
|
expansive_passes,
|
||||||
|
expansive_mode,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
})
|
})
|
||||||
.returning({ id: recommendations.id });
|
.returning({ id: recommendations.id });
|
||||||
@@ -41,6 +82,94 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
return reply.code(201).send({ id: rec?.id });
|
return reply.code(201).send({ id: rec?.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /recommendations/continuous — create record and run continuous pipeline with SSE
|
||||||
|
fastify.post('/recommendations/continuous', async (request, reply) => {
|
||||||
|
const body = request.body as {
|
||||||
|
liked_series: string;
|
||||||
|
disliked_series?: string;
|
||||||
|
themes?: string;
|
||||||
|
requirements?: string;
|
||||||
|
avoid?: string;
|
||||||
|
total_count: number;
|
||||||
|
media_type?: string;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
validate_results?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaType: MediaType = body.media_type === 'movie' ? 'movie' : 'tv_show';
|
||||||
|
const useWebSearch = body.use_web_search === true;
|
||||||
|
const validateResults = body.validate_results === true && supportsWebSearch;
|
||||||
|
const totalCount = Number.isFinite(body.total_count) ? Math.min(100, Math.max(10, body.total_count)) : 30;
|
||||||
|
|
||||||
|
// Create record first with pending status
|
||||||
|
const title = body.themes?.trim().split(/\s+/).slice(0, 5).join(' ') || 'Continuous Recommendations';
|
||||||
|
const [rec] = await db
|
||||||
|
.insert(recommendations)
|
||||||
|
.values({
|
||||||
|
title,
|
||||||
|
main_prompt: body.themes ?? 'Continuous recommendations',
|
||||||
|
liked_series: body.liked_series,
|
||||||
|
disliked_series: body.disliked_series ?? '',
|
||||||
|
themes: body.themes ?? '',
|
||||||
|
brainstorm_count: totalCount,
|
||||||
|
media_type: mediaType,
|
||||||
|
use_web_search: useWebSearch,
|
||||||
|
use_validator: validateResults,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
.returning({ id: recommendations.id });
|
||||||
|
|
||||||
|
if (!rec) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to create recommendation record' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recId = rec.id;
|
||||||
|
resetPipelineSession(recId);
|
||||||
|
|
||||||
|
// Set SSE headers and hijack
|
||||||
|
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||||
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
|
reply.raw.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
reply.raw.flushHeaders();
|
||||||
|
reply.hijack();
|
||||||
|
|
||||||
|
// Immediately send the record ID so frontend can navigate to it
|
||||||
|
reply.raw.write(`data: ${JSON.stringify({ type: 'created', id: recId })}\n\n`);
|
||||||
|
|
||||||
|
const sseWrite = (event: SSEEvent) => {
|
||||||
|
publishPipelineEvent(recId, event);
|
||||||
|
try {
|
||||||
|
writeSSE(reply.raw, event);
|
||||||
|
} catch {
|
||||||
|
// Client disconnected
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update status to running
|
||||||
|
await db
|
||||||
|
.update(recommendations)
|
||||||
|
.set({ status: 'running' })
|
||||||
|
.where(eq(recommendations.id, recId));
|
||||||
|
|
||||||
|
// Run the continuous pipeline (it will now update the existing record)
|
||||||
|
await runContinuousPipeline(recId, {
|
||||||
|
likedSeries: body.liked_series,
|
||||||
|
dislikedSeries: body.disliked_series ?? '',
|
||||||
|
themes: body.themes ?? '',
|
||||||
|
requirements: body.requirements ?? '',
|
||||||
|
avoid: body.avoid ?? '',
|
||||||
|
totalCount,
|
||||||
|
useWebSearch,
|
||||||
|
validateResults,
|
||||||
|
mediaType,
|
||||||
|
}, sseWrite);
|
||||||
|
} finally {
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /recommendations — list all
|
// GET /recommendations — list all
|
||||||
fastify.get('/recommendations', async (_request, reply) => {
|
fastify.get('/recommendations', async (_request, reply) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -48,6 +177,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
id: recommendations.id,
|
id: recommendations.id,
|
||||||
title: recommendations.title,
|
title: recommendations.title,
|
||||||
status: recommendations.status,
|
status: recommendations.status,
|
||||||
|
media_type: recommendations.media_type,
|
||||||
created_at: recommendations.created_at,
|
created_at: recommendations.created_at,
|
||||||
})
|
})
|
||||||
.from(recommendations)
|
.from(recommendations)
|
||||||
@@ -58,6 +188,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
// GET /recommendations/:id — full record
|
// GET /recommendations/:id — full record
|
||||||
fastify.get('/recommendations/:id', async (request, reply) => {
|
fastify.get('/recommendations/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' });
|
||||||
const [rec] = await db
|
const [rec] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(recommendations)
|
.from(recommendations)
|
||||||
@@ -68,9 +199,9 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /recommendations/:id/stream — SSE pipeline stream
|
// GET /recommendations/:id/stream — SSE pipeline stream
|
||||||
// Always fetches all current feedback and injects if present (supports rerank flow)
|
|
||||||
fastify.get('/recommendations/:id/stream', async (request, reply) => {
|
fastify.get('/recommendations/:id/stream', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' });
|
||||||
const [rec] = await db
|
const [rec] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(recommendations)
|
.from(recommendations)
|
||||||
@@ -78,19 +209,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
if (!rec) return reply.code(404).send({ error: 'Not found' });
|
if (!rec) return reply.code(404).send({ error: 'Not found' });
|
||||||
|
|
||||||
// Load all feedback to potentially inject as context
|
// Set SSE headers and hijack before any branching
|
||||||
const feedbackRows = await db.select().from(feedback);
|
|
||||||
const feedbackContext =
|
|
||||||
feedbackRows.length > 0
|
|
||||||
? feedbackRows
|
|
||||||
.map(
|
|
||||||
(f) =>
|
|
||||||
`Show: "${f.tv_show_name}" — Rating: ${f.stars}/3 stars${f.feedback ? ` — Comment: ${f.feedback}` : ''}`,
|
|
||||||
)
|
|
||||||
.join('\n')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Set SSE headers and hijack
|
|
||||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||||
reply.raw.setHeader('Connection', 'keep-alive');
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
@@ -98,20 +217,130 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
reply.raw.flushHeaders();
|
reply.raw.flushHeaders();
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
|
|
||||||
|
// Resilient write — swallows errors so a disconnected client never crashes
|
||||||
|
// an in-flight pipeline that is still running server-side.
|
||||||
const sseWrite = (event: SSEEvent) => {
|
const sseWrite = (event: SSEEvent) => {
|
||||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
try {
|
||||||
|
writeSSE(reply.raw, event);
|
||||||
|
} catch {
|
||||||
|
// Client disconnected — pipeline continues, writes are silently dropped
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runPipeline(rec, sseWrite, feedbackContext);
|
// Already finished — send a synthetic completion event and close immediately.
|
||||||
|
if (rec.status === 'done') {
|
||||||
|
sseWrite({ stage: 'complete', status: 'done', data: { title: rec.title } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already errored — send a synthetic error event and close immediately.
|
||||||
|
if (rec.status === 'error') {
|
||||||
|
sseWrite({ stage: 'curator', status: 'error', data: { message: 'Pipeline failed' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already running — the pipeline is executing on a previous connection.
|
||||||
|
// Poll the DB until it reaches a terminal state, then report the result.
|
||||||
|
// This prevents starting a duplicate pipeline run on page reload.
|
||||||
|
if (rec.status === 'running') {
|
||||||
|
if (hasPipelineSession(id)) {
|
||||||
|
const history = getPipelineHistory(id);
|
||||||
|
for (const event of history) {
|
||||||
|
sseWrite(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEvent = history[history.length - 1];
|
||||||
|
if (lastEvent && (lastEvent.stage === 'complete' || lastEvent.status === 'error')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const unsubscribe = subscribeToPipelineEvents(id, (event) => {
|
||||||
|
sseWrite(event);
|
||||||
|
if (event.stage === 'complete' || event.status === 'error') {
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
request.raw.on('close', () => {
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000;
|
||||||
|
const TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes hard ceiling
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < TIMEOUT_MS) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
const [current] = await db
|
||||||
|
.select({ status: recommendations.status, title: recommendations.title })
|
||||||
|
.from(recommendations)
|
||||||
|
.where(eq(recommendations.id, id));
|
||||||
|
if (!current || current.status === 'done') {
|
||||||
|
sseWrite({ stage: 'complete', status: 'done', data: { title: current?.title ?? rec.title } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current.status === 'error') {
|
||||||
|
sseWrite({ stage: 'curator', status: 'error', data: { message: 'Pipeline failed' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Still running — keep polling
|
||||||
|
}
|
||||||
|
// Timed out waiting — report as error
|
||||||
|
sseWrite({ stage: 'curator', status: 'error', data: { message: 'Pipeline timed out' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// status === 'pending' — start the pipeline normally.
|
||||||
|
resetPipelineSession(id);
|
||||||
|
|
||||||
|
// Load all feedback to potentially inject as context
|
||||||
|
const feedbackRows = await db.select().from(feedback);
|
||||||
|
const mediaLabel = rec.media_type === 'movie' ? 'Movie' : 'Show';
|
||||||
|
const feedbackContext =
|
||||||
|
feedbackRows.length > 0
|
||||||
|
? feedbackRows
|
||||||
|
.map(
|
||||||
|
(f) =>
|
||||||
|
`${mediaLabel}: "${f.item_name}" — Rating: ${f.stars}/3 stars${f.feedback ? ` — Comment: ${f.feedback}` : ''}`,
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await runPipeline(rec, (event) => {
|
||||||
|
publishPipelineEvent(id, event);
|
||||||
|
sseWrite(event);
|
||||||
|
}, feedbackContext);
|
||||||
} finally {
|
} finally {
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DELETE /recommendations/:id — delete a recommendation
|
||||||
|
fastify.delete('/recommendations/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' });
|
||||||
|
const [rec] = await db
|
||||||
|
.select({ id: recommendations.id })
|
||||||
|
.from(recommendations)
|
||||||
|
.where(eq(recommendations.id, id));
|
||||||
|
|
||||||
|
if (!rec) return reply.code(404).send({ error: 'Not found' });
|
||||||
|
|
||||||
|
await db.delete(recommendations).where(eq(recommendations.id, id));
|
||||||
|
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /recommendations/:id/rerank — reset status so client can re-open SSE stream
|
// POST /recommendations/:id/rerank — reset status so client can re-open SSE stream
|
||||||
fastify.post('/recommendations/:id/rerank', async (request, reply) => {
|
fastify.post('/recommendations/:id/rerank', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
if (!isUuid(id)) return reply.code(404).send({ error: 'Not found' });
|
||||||
const [rec] = await db
|
const [rec] = await db
|
||||||
.select({ id: recommendations.id })
|
.select({ id: recommendations.id })
|
||||||
.from(recommendations)
|
.from(recommendations)
|
||||||
@@ -123,6 +352,7 @@ export default async function recommendationsRoute(fastify: FastifyInstance) {
|
|||||||
.update(recommendations)
|
.update(recommendations)
|
||||||
.set({ status: 'pending' })
|
.set({ status: 'pending' })
|
||||||
.where(eq(recommendations.id, id));
|
.where(eq(recommendations.id, id));
|
||||||
|
resetPipelineSession(id);
|
||||||
|
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type MediaType = 'tv_show' | 'movie';
|
||||||
|
|
||||||
export interface InterpreterOutput {
|
export interface InterpreterOutput {
|
||||||
liked: string[];
|
liked: string[];
|
||||||
disliked: string[];
|
disliked: string[];
|
||||||
@@ -5,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 {
|
||||||
@@ -16,22 +19,47 @@ export interface RetrievalOutput {
|
|||||||
candidates: RetrievalCandidate[];
|
candidates: RetrievalCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ValidatorCandidate {
|
||||||
|
title: string;
|
||||||
|
reason: string;
|
||||||
|
isTrash: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidatorOutput {
|
||||||
|
candidates: ValidatorCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
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'
|
||||||
|
| 'validator'
|
||||||
|
| 'ranking'
|
||||||
|
| 'curator'
|
||||||
|
| 'complete'
|
||||||
|
| `pass${number}:retrieval`
|
||||||
|
| `pass${number}:validator`
|
||||||
|
| `pass${number}:ranking`
|
||||||
|
| `pass${number}:curator`;
|
||||||
|
|
||||||
export type SSEStatus = 'start' | 'done' | 'error';
|
export type SSEStatus = 'start' | 'done' | 'error';
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface SSEEvent {
|
||||||
@@ -39,3 +67,28 @@ export interface SSEEvent {
|
|||||||
status: SSEStatus;
|
status: SSEStatus;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContinuousSession {
|
||||||
|
sessionId: string;
|
||||||
|
mediaType: MediaType;
|
||||||
|
interpreterOutput: InterpreterOutput;
|
||||||
|
accumulatedCandidates: RetrievalCandidate[];
|
||||||
|
previousResponseId: string | null;
|
||||||
|
batchCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
useWebSearch: boolean;
|
||||||
|
validateResults: boolean;
|
||||||
|
allSeenTitles: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContinuousStartRequest {
|
||||||
|
mediaType: 'tv_show' | 'movie';
|
||||||
|
likedSeries: string;
|
||||||
|
dislikedSeries?: string;
|
||||||
|
themes?: string;
|
||||||
|
requirements?: string;
|
||||||
|
avoid?: string;
|
||||||
|
totalCount: number;
|
||||||
|
useWebSearch: boolean;
|
||||||
|
validateResults: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>Recommender</title>
|
<title>Recommender</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Recommendation, RecommendationSummary, FeedbackEntry } from '../types/index.js';
|
import type { MediaType, Recommendation, RecommendationSummary, FeedbackEntry } from '../types/index.js';
|
||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
...(options?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -16,10 +18,17 @@ 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;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
use_validator?: boolean;
|
||||||
|
hard_requirements?: boolean;
|
||||||
|
self_expansive?: boolean;
|
||||||
|
expansive_passes?: number;
|
||||||
|
expansive_mode?: 'soft' | 'extreme';
|
||||||
}): Promise<{ id: string }> {
|
}): Promise<{ id: string }> {
|
||||||
return request('/recommendations', {
|
return request('/recommendations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -40,7 +49,7 @@ export function rerankRecommendation(id: string): Promise<{ ok: boolean }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function submitFeedback(body: {
|
export function submitFeedback(body: {
|
||||||
tv_show_name: string;
|
item_name: string;
|
||||||
stars: number;
|
stars: number;
|
||||||
feedback?: string;
|
feedback?: string;
|
||||||
}): Promise<{ ok: boolean }> {
|
}): Promise<{ ok: boolean }> {
|
||||||
@@ -53,3 +62,85 @@ export function submitFeedback(body: {
|
|||||||
export function getFeedback(): Promise<FeedbackEntry[]> {
|
export function getFeedback(): Promise<FeedbackEntry[]> {
|
||||||
return request('/feedback');
|
return request('/feedback');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteRecommendation(id: string): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/recommendations/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContinuousRecommendation(body: {
|
||||||
|
liked_series: string;
|
||||||
|
disliked_series?: string;
|
||||||
|
themes?: string;
|
||||||
|
requirements?: string;
|
||||||
|
avoid?: string;
|
||||||
|
total_count: number;
|
||||||
|
media_type: MediaType;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
validate_results?: boolean;
|
||||||
|
}): Promise<{ id: string }> {
|
||||||
|
return (async () => {
|
||||||
|
const res = await fetch(`${BASE}/recommendations/continuous`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body) {
|
||||||
|
throw new Error('Missing response stream for continuous recommendation');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
buffer += decoder.decode(value, { stream: !done });
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = buffer.indexOf('\n\n');
|
||||||
|
while (boundary !== -1) {
|
||||||
|
const rawEvent = buffer.slice(0, boundary).trim();
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
|
||||||
|
if (rawEvent) {
|
||||||
|
const dataLine = rawEvent
|
||||||
|
.split('\n')
|
||||||
|
.find((line) => line.startsWith('data:'));
|
||||||
|
|
||||||
|
if (dataLine) {
|
||||||
|
const payload = dataLine.slice(5).trim();
|
||||||
|
const event = JSON.parse(payload) as { type?: string; id?: string };
|
||||||
|
|
||||||
|
if (event.type === 'created' && event.id) {
|
||||||
|
await reader.cancel();
|
||||||
|
return { id: event.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boundary = buffer.indexOf('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Continuous recommendation stream ended before returning an id');
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,20 +3,30 @@
|
|||||||
.cards-grid {
|
.cards-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 32px;
|
margin-bottom: var(--sp-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-surface);
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 100%),
|
||||||
|
rgba(28, 25, 42, 0.7);
|
||||||
|
backdrop-filter: blur(14px) saturate(150%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-md);
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
transition: border-color 0.15s;
|
box-shadow:
|
||||||
|
0 8px 28px rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
border-color: var(--bg-surface-3);
|
border-color: rgba(232, 168, 124, 0.28);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 36px rgba(0, 0, 0, 0.24),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -31,30 +41,53 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 100px;
|
border-radius: var(--radius-pill);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.08em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-green {
|
.badge-green {
|
||||||
background: rgba(34, 197, 94, 0.15);
|
background: rgba(34, 197, 94, 0.14);
|
||||||
color: #4ade80;
|
color: #4ade80;
|
||||||
|
box-shadow: 0 0 18px rgba(34, 197, 94, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-blue {
|
.badge-blue {
|
||||||
background: rgba(59, 130, 246, 0.15);
|
background: rgba(59, 130, 246, 0.15);
|
||||||
color: #60a5fa;
|
color: #60a5fa;
|
||||||
|
box-shadow: 0 0 18px rgba(59, 130, 246, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-yellow {
|
.badge-yellow {
|
||||||
background: rgba(234, 179, 8, 0.15);
|
background: rgba(234, 179, 8, 0.15);
|
||||||
color: #facc15;
|
color: #facc15;
|
||||||
|
box-shadow: 0 0 18px rgba(234, 179, 8, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-red {
|
.badge-red {
|
||||||
background: rgba(239, 68, 68, 0.15);
|
background: rgba(239, 68, 68, 0.15);
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
|
box-shadow: 0 0 18px rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-magenta {
|
||||||
|
background: rgba(217, 70, 239, 0.15);
|
||||||
|
color: #e879f9;
|
||||||
|
box-shadow: 0 0 18px rgba(217, 70, 239, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-verified {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -64,10 +97,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-explanation {
|
.card-explanation {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(245, 240, 232, 0.08);
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: inline-block;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pros-cons-table {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 12px;
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
@@ -125,11 +192,13 @@
|
|||||||
padding: 16px 0 32px;
|
padding: 16px 0 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-rerank {
|
.btn-rerank {
|
||||||
padding: 12px 28px;
|
padding: 12px 28px;
|
||||||
background: var(--bg-surface-2);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -140,9 +209,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-rerank:hover:not(:disabled) {
|
.btn-rerank:hover:not(:disabled) {
|
||||||
background: var(--bg-surface-3);
|
background: rgba(255, 255, 255, 0.09);
|
||||||
border-color: var(--accent);
|
border-color: rgba(232, 168, 124, 0.24);
|
||||||
color: var(--accent);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-rerank:disabled {
|
.btn-rerank:disabled {
|
||||||
|
|||||||
@@ -1,105 +1,580 @@
|
|||||||
/* ── Modal ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
padding: 24px;
|
||||||
backdrop-filter: blur(2px);
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(232, 168, 124, 0.12), transparent 32%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(245, 240, 232, 0.08), transparent 28%),
|
||||||
|
rgba(10, 8, 15, 0.78);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--bg-surface);
|
width: min(900px, 100%);
|
||||||
border: 1px solid var(--border);
|
max-height: min(92vh, 980px);
|
||||||
border-radius: 12px;
|
|
||||||
width: 650px;
|
|
||||||
max-width: 96vw;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 28px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 18%),
|
||||||
|
linear-gradient(145deg, rgba(28, 25, 42, 0.96), rgba(22, 19, 34, 0.97));
|
||||||
|
box-shadow:
|
||||||
|
0 32px 90px rgba(0, 0, 0, 0.45),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-hero,
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 20px 20px 0;
|
gap: 24px;
|
||||||
|
padding: 28px 30px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-hero h2,
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
font-size: 18px;
|
font-size: clamp(1.8rem, 3vw, 2.35rem);
|
||||||
font-weight: 700;
|
line-height: 1.05;
|
||||||
}
|
letter-spacing: -0.03em;
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 22px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close:hover {
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-hero-copy {
|
||||||
|
max-width: 560px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: rgba(245, 240, 232, 0.7);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(232, 168, 124, 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close,
|
||||||
|
.modal-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover:not(:disabled),
|
||||||
|
.modal-back:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(232, 168, 124, 0.34);
|
||||||
|
background: rgba(232, 168, 124, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:disabled,
|
||||||
|
.modal-back:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-type-select,
|
||||||
.modal-form {
|
.modal-form {
|
||||||
padding: 20px;
|
padding: 28px 30px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-type-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 100%),
|
||||||
|
rgba(28, 25, 42, 0.58);
|
||||||
|
backdrop-filter: blur(16px) saturate(160%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-header h3,
|
||||||
|
.settings-card-header h3 {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-header p,
|
||||||
|
.settings-card-header p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-step {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, rgba(232, 168, 124, 0.22), rgba(245, 240, 232, 0.14));
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-type-cards,
|
||||||
|
.mode-cards,
|
||||||
|
.modal-form-grid {
|
||||||
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-type-cards,
|
||||||
|
.mode-cards {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card,
|
||||||
|
.mode-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 20px 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 100%),
|
||||||
|
rgba(36, 33, 56, 0.68);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card:hover,
|
||||||
|
.mode-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(232, 168, 124, 0.36);
|
||||||
|
box-shadow: 0 18px 32px rgba(2, 8, 23, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card--selected,
|
||||||
|
.mode-card--active {
|
||||||
|
border-color: rgba(232, 168, 124, 0.55);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(232, 168, 124, 0.12), rgba(245, 240, 232, 0.04)),
|
||||||
|
rgba(28, 25, 42, 0.92);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(232, 168, 124, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card-icon {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 2px 0 0;
|
||||||
|
transform: translateY(1px);
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card-label,
|
||||||
|
.mode-card-label {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.04rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card-desc,
|
||||||
|
.mode-card-desc {
|
||||||
|
color: rgba(245, 240, 232, 0.66);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(232, 168, 124, 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-type-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-selection-summary,
|
||||||
|
.modal-summary-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-pill--accent {
|
||||||
|
background: rgba(232, 168, 124, 0.12);
|
||||||
|
border-color: rgba(232, 168, 124, 0.22);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-caption {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-continue {
|
||||||
|
padding-inline: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
flex: 1;
|
}
|
||||||
|
|
||||||
|
.form-group--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
font-size: 13px;
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-size: 0.92rem;
|
||||||
color: var(--text-muted);
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input,
|
.form-input,
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
background: var(--bg-surface-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
font-family: inherit;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(18, 16, 26, 0.85);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input[type="range"] {
|
.form-input::placeholder,
|
||||||
padding: 0;
|
.form-textarea::placeholder {
|
||||||
cursor: pointer;
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus,
|
.form-input:focus,
|
||||||
.form-textarea:focus {
|
.form-textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: rgba(232, 168, 124, 0.52);
|
||||||
|
box-shadow: 0 0 0 4px rgba(232, 168, 124, 0.12);
|
||||||
|
background: rgba(18, 16, 26, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input[type='range'] {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 80px;
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea--hero {
|
||||||
|
min-height: 138px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(232, 168, 124, 0.08), transparent 28%),
|
||||||
|
rgba(28, 25, 42, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-card {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(18, 16, 26, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-card--compact {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-copy {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-value {
|
||||||
|
min-width: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(232, 168, 124, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-card {
|
||||||
|
padding: 15px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
background: rgba(28, 25, 42, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-card--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-desc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 52px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.on {
|
||||||
|
background: rgba(232, 168, 124, 0.3);
|
||||||
|
border-color: rgba(232, 168, 124, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.on .toggle-knob {
|
||||||
|
transform: translateX(22px);
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 8px rgba(232, 168, 124, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansive-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(232, 168, 124, 0.14);
|
||||||
|
background: rgba(232, 168, 124, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control-btn {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(18, 16, 26, 0.66);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control-btn--active {
|
||||||
|
background: rgba(232, 168, 124, 0.12);
|
||||||
|
border-color: rgba(232, 168, 124, 0.34);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding-top: 4px;
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-hero,
|
||||||
|
.modal-header,
|
||||||
|
.modal-type-select,
|
||||||
|
.modal-form {
|
||||||
|
padding-inline: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.modal-backdrop {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-hero,
|
||||||
|
.modal-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-type-cards,
|
||||||
|
.mode-cards,
|
||||||
|
.modal-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-type-footer,
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label,
|
||||||
|
.slider-card-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,160 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import type { MediaType } from '../types/index.js';
|
||||||
import './Modal.css';
|
import './Modal.css';
|
||||||
|
|
||||||
|
type GenerationMode = 'brainstorm' | 'continuous';
|
||||||
|
|
||||||
interface NewRecommendationModalProps {
|
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;
|
||||||
|
avoid?: string;
|
||||||
brainstorm_count?: number;
|
brainstorm_count?: number;
|
||||||
|
media_type: MediaType;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
use_validator?: boolean;
|
||||||
|
hard_requirements?: boolean;
|
||||||
|
self_expansive?: boolean;
|
||||||
|
expansive_passes?: number;
|
||||||
|
expansive_mode?: 'soft' | 'extreme';
|
||||||
|
generation_mode?: GenerationMode;
|
||||||
|
total_count?: number;
|
||||||
|
validate_results?: boolean;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MEDIA_OPTIONS: Array<{
|
||||||
|
type: MediaType;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
type: 'tv_show',
|
||||||
|
icon: '📺',
|
||||||
|
label: 'TV series',
|
||||||
|
description: 'Serialized stories, limited series, and long-form comfort watches.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'movie',
|
||||||
|
icon: '🎬',
|
||||||
|
label: 'Movies',
|
||||||
|
description: 'Feature films, prestige cinema, and one-night picks.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODE_OPTIONS: Array<{
|
||||||
|
mode: GenerationMode;
|
||||||
|
label: string;
|
||||||
|
badge: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
mode: 'brainstorm',
|
||||||
|
label: 'Brainstorm',
|
||||||
|
badge: 'Best for variety',
|
||||||
|
description: 'Explore a broad pool of options, then rank and curate the strongest fits.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'continuous',
|
||||||
|
label: 'Continuous',
|
||||||
|
badge: 'Best for deep search',
|
||||||
|
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 [mediaType, setMediaType] = useState<MediaType>('tv_show');
|
||||||
|
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 [avoid, setAvoid] = useState('');
|
||||||
const [brainstormCount, setBrainstormCount] = useState(100);
|
const [brainstormCount, setBrainstormCount] = useState(100);
|
||||||
|
const [totalCount, setTotalCount] = useState(30);
|
||||||
|
const [useWebSearch, setUseWebSearch] = useState(false);
|
||||||
|
const [useValidator, setUseValidator] = useState(false);
|
||||||
|
const [useHardRequirements, setUseHardRequirements] = useState(false);
|
||||||
|
const [selfExpansive, setSelfExpansive] = useState(false);
|
||||||
|
const [expansivePasses, setExpansivePasses] = useState(2);
|
||||||
|
const [expansiveMode, setExpansiveMode] = useState<'soft' | 'extreme'>('soft');
|
||||||
|
const [validateResults, setValidateResults] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && !loading) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [loading, onClose]);
|
||||||
|
|
||||||
|
const mediaLabel = mediaType === 'movie' ? 'Movie' : 'TV Show';
|
||||||
|
const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'series';
|
||||||
|
|
||||||
|
const handleSelectType = (type: MediaType) => {
|
||||||
|
setMediaType(type);
|
||||||
|
setStep('mode');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebSearchToggle = () => {
|
||||||
|
const next = !useWebSearch;
|
||||||
|
setUseWebSearch(next);
|
||||||
|
if (!next) {
|
||||||
|
setUseValidator(false);
|
||||||
|
setValidateResults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: Event) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!mainPrompt.trim()) return;
|
if (generationMode === 'brainstorm' && !mainPrompt.trim()) return;
|
||||||
|
if (!likedSeries.trim()) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
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,
|
||||||
|
use_web_search: useWebSearch,
|
||||||
|
use_validator: useValidator,
|
||||||
|
hard_requirements: useHardRequirements,
|
||||||
|
self_expansive: selfExpansive,
|
||||||
|
expansive_passes: selfExpansive ? expansivePasses : 1,
|
||||||
|
expansive_mode: expansiveMode,
|
||||||
|
generation_mode: 'brainstorm',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await onSubmit({
|
||||||
|
main_prompt: '',
|
||||||
|
liked_series: likedSeries.trim(),
|
||||||
|
disliked_series: dislikedSeries.trim(),
|
||||||
|
themes: themes.trim(),
|
||||||
|
requirements: requirements.trim(),
|
||||||
|
avoid: avoid.trim(),
|
||||||
|
media_type: mediaType,
|
||||||
|
use_web_search: useWebSearch,
|
||||||
|
validate_results: validateResults,
|
||||||
|
generation_mode: 'continuous',
|
||||||
|
total_count: totalCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -39,71 +162,253 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBackdropClick = (e: MouseEvent) => {
|
const handleBackdropClick = (e: MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).classList.contains('modal-backdrop')) {
|
if ((e.target as HTMLElement).classList.contains('modal-backdrop') && !loading) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedMode = MODE_OPTIONS.find((option) => option.mode === generationMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="modal-backdrop" onClick={handleBackdropClick}>
|
<div class="modal-backdrop" onClick={handleBackdropClick}>
|
||||||
<div class="modal">
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="recommendation-modal-title">
|
||||||
|
{step === 'type' ? (
|
||||||
|
<>
|
||||||
|
<div class="modal-hero">
|
||||||
|
<div>
|
||||||
|
<div class="modal-eyebrow">Create Recommendation</div>
|
||||||
|
<h2 id="recommendation-modal-title">Shape the kind of discovery session you want.</h2>
|
||||||
|
<p class="modal-hero-copy">
|
||||||
|
Pick a format, choose how deep the search should go, and we'll take it from there.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onClick={onClose} aria-label="Close" disabled={loading}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-type-select">
|
||||||
|
<section class="modal-section">
|
||||||
|
<div class="modal-section-header">
|
||||||
|
<span class="modal-section-step">1</span>
|
||||||
|
<div>
|
||||||
|
<h3>Choose your format</h3>
|
||||||
|
<p>Start with the kind of thing you want to watch next.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-type-cards">
|
||||||
|
{MEDIA_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.type}
|
||||||
|
type="button"
|
||||||
|
class={`type-card${mediaType === option.type ? ' type-card--selected' : ''}`}
|
||||||
|
onClick={() => handleSelectType(option.type)}
|
||||||
|
>
|
||||||
|
<span class="type-card-title-row">
|
||||||
|
<span class="type-card-icon">{option.icon}</span>
|
||||||
|
<span class="type-card-label">{option.label}</span>
|
||||||
|
</span>
|
||||||
|
<span class="type-card-desc">{option.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : step === 'mode' ? (
|
||||||
|
<>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>New Recommendation</h2>
|
<div class="modal-header-left">
|
||||||
<button class="modal-close" onClick={onClose} aria-label="Close">×</button>
|
<button class="modal-back" onClick={() => setStep('type')} aria-label="Back" disabled={loading}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div class="modal-eyebrow">Step 2 of 3</div>
|
||||||
|
<h2 id="recommendation-modal-title">Choose the pipeline style.</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onClick={onClose} aria-label="Close" disabled={loading}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-type-select">
|
||||||
|
<section class="modal-section">
|
||||||
|
<div class="modal-section-header">
|
||||||
|
<span class="modal-section-step">2</span>
|
||||||
|
<div>
|
||||||
|
<h3>Pick the search mode</h3>
|
||||||
|
<p>Go broad for variety or deeper for a more persistent search.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-cards">
|
||||||
|
{MODE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
type="button"
|
||||||
|
class={`mode-card${generationMode === option.mode ? ' mode-card--active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setGenerationMode(option.mode);
|
||||||
|
setStep('form');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="mode-card-badge">{option.badge}</span>
|
||||||
|
<span class="mode-card-label">{option.label}</span>
|
||||||
|
<span class="mode-card-desc">{option.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="modal-type-footer">
|
||||||
|
<div class="modal-selection-summary">
|
||||||
|
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV series'}</span>
|
||||||
|
<span class="summary-pill">{selectedMode?.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-header-left">
|
||||||
|
<button class="modal-back" onClick={() => setStep('mode')} aria-label="Back" disabled={loading}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div class="modal-eyebrow">Step 3 of 3</div>
|
||||||
|
<h2 id="recommendation-modal-title">Tell us what a great match looks like.</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onClick={onClose} aria-label="Close" disabled={loading}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="modal-form" onSubmit={handleSubmit}>
|
<form class="modal-form" onSubmit={handleSubmit}>
|
||||||
<div class="form-group">
|
<div class="modal-summary-strip">
|
||||||
|
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV series'}</span>
|
||||||
|
<span class="summary-pill summary-pill--accent">{selectedMode?.label}</span>
|
||||||
|
<span class="summary-caption">
|
||||||
|
{generationMode === 'brainstorm'
|
||||||
|
? 'Fast wide search with ranking and curation.'
|
||||||
|
: 'Longer chained search that keeps exploring in batches.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generationMode === 'brainstorm' && (
|
||||||
|
<div class="form-group form-group--full">
|
||||||
<label for="main-prompt">What are you looking for?</label>
|
<label for="main-prompt">What are you looking for?</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="main-prompt"
|
id="main-prompt"
|
||||||
class="form-textarea"
|
class="form-textarea form-textarea--hero"
|
||||||
placeholder="Describe what you want to watch. Be as specific as you like — mood, themes, setting, what you've enjoyed before..."
|
placeholder="Describe the mood, themes, setting, pacing, or vibe you want right now."
|
||||||
value={mainPrompt}
|
value={mainPrompt}
|
||||||
onInput={(e) => setMainPrompt((e.target as HTMLTextAreaElement).value)}
|
onInput={(e) => setMainPrompt((e.target as HTMLTextAreaElement).value)}
|
||||||
rows={5}
|
rows={5}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="modal-form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="liked-shows">Shows 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="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
|
||||||
/>
|
/>
|
||||||
|
<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">Shows 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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class={`form-group${generationMode === 'continuous' ? '' : ' form-group--full'}`}>
|
||||||
<label for="themes">Themes and requirements</label>
|
<label for="themes">Themes, moods, and preferences</label>
|
||||||
<input
|
<input
|
||||||
id="themes"
|
id="themes"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
placeholder="e.g. dramatic setting, historical, sci-fi, etc."
|
placeholder="e.g. tense, grounded sci-fi, emotional, political intrigue"
|
||||||
value={themes}
|
value={themes}
|
||||||
onInput={(e) => setThemes((e.target as HTMLInputElement).value)}
|
onInput={(e) => setThemes((e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{generationMode === 'continuous' && (
|
||||||
|
<>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="brainstorm-count">Shows to brainstorm ({brainstormCount})</label>
|
<label for="requirements">Hard requirements</label>
|
||||||
|
<input
|
||||||
|
id="requirements"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="e.g. 2020+, under 2 hours, streaming on Netflix"
|
||||||
|
value={requirements}
|
||||||
|
onInput={(e) => setRequirements((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="avoid">Avoid</label>
|
||||||
|
<input
|
||||||
|
id="avoid"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="e.g. gore, bleak endings, broad comedy"
|
||||||
|
value={avoid}
|
||||||
|
onInput={(e) => setAvoid((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h3>Tuning</h3>
|
||||||
|
<p>Adjust depth, validation, and how aggressively the search expands.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generationMode === 'continuous' ? (
|
||||||
|
<div class="slider-card">
|
||||||
|
<div class="slider-card-header">
|
||||||
|
<div>
|
||||||
|
<span class="slider-label">Total recommendations</span>
|
||||||
|
<span class="slider-copy">Generated in batches of 10 through the continuous pipeline.</span>
|
||||||
|
</div>
|
||||||
|
<span class="slider-value">{totalCount}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="total-count"
|
||||||
|
type="range"
|
||||||
|
class="form-input"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
value={totalCount}
|
||||||
|
onInput={(e) => setTotalCount(Number((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="slider-card">
|
||||||
|
<div class="slider-card-header">
|
||||||
|
<div>
|
||||||
|
<span class="slider-label">Brainstorm pool size</span>
|
||||||
|
<span class="slider-copy">Higher values search wider before ranking and curation.</span>
|
||||||
|
</div>
|
||||||
|
<span class="slider-value">{brainstormCount}</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
id="brainstorm-count"
|
id="brainstorm-count"
|
||||||
type="range"
|
type="range"
|
||||||
@@ -115,16 +420,154 @@ export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationM
|
|||||||
onInput={(e) => setBrainstormCount(Number((e.target as HTMLInputElement).value))}
|
onInput={(e) => setBrainstormCount(Number((e.target as HTMLInputElement).value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="toggle-stack">
|
||||||
|
<div class="toggle-card">
|
||||||
|
<label class="toggle-label" for="web-search">
|
||||||
|
<div class="toggle-text">
|
||||||
|
<span class="toggle-title">Use Web Search</span>
|
||||||
|
<span class="toggle-desc">Pull in live web context for fresher, more grounded {mediaPluralLabel}.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="web-search"
|
||||||
|
type="button"
|
||||||
|
class={`toggle-switch${useWebSearch ? ' on' : ''}`}
|
||||||
|
aria-pressed={useWebSearch}
|
||||||
|
onClick={handleWebSearchToggle}
|
||||||
|
>
|
||||||
|
<div class="toggle-knob" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generationMode === 'brainstorm' ? (
|
||||||
|
<>
|
||||||
|
<div class={`toggle-card${!useWebSearch ? ' toggle-card--disabled' : ''}`}>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<div class="toggle-text">
|
||||||
|
<span class="toggle-title">Validator Agent</span>
|
||||||
|
<span class="toggle-desc">
|
||||||
|
Verify brainstormed candidates against real metadata before ranking.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`toggle-switch${useValidator ? ' on' : ''}${!useWebSearch ? ' toggle-switch-disabled' : ''}`}
|
||||||
|
aria-pressed={useValidator}
|
||||||
|
onClick={() => useWebSearch && setUseValidator((value) => !value)}
|
||||||
|
>
|
||||||
|
<div class="toggle-knob" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-card">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<div class="toggle-text">
|
||||||
|
<span class="toggle-title">Hard Requirements</span>
|
||||||
|
<span class="toggle-desc">Bias ranking toward strict fit instead of softer exploratory matches.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`toggle-switch${useHardRequirements ? ' on' : ''}`}
|
||||||
|
aria-pressed={useHardRequirements}
|
||||||
|
onClick={() => setUseHardRequirements((value) => !value)}
|
||||||
|
>
|
||||||
|
<div class="toggle-knob" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-card">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<div class="toggle-text">
|
||||||
|
<span class="toggle-title">Self Expansive Search</span>
|
||||||
|
<span class="toggle-desc">Run extra passes using earlier full matches as context.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`toggle-switch${selfExpansive ? ' on' : ''}`}
|
||||||
|
aria-pressed={selfExpansive}
|
||||||
|
onClick={() => setSelfExpansive((value) => !value)}
|
||||||
|
>
|
||||||
|
<div class="toggle-knob" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div class={`toggle-card${!useWebSearch ? ' toggle-card--disabled' : ''}`}>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<div class="toggle-text">
|
||||||
|
<span class="toggle-title">Validate Results</span>
|
||||||
|
<span class="toggle-desc">Verify continuous batches against real metadata before final ranking.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`toggle-switch${validateResults ? ' on' : ''}${!useWebSearch ? ' toggle-switch-disabled' : ''}`}
|
||||||
|
aria-pressed={validateResults}
|
||||||
|
onClick={() => useWebSearch && setValidateResults((value) => !value)}
|
||||||
|
>
|
||||||
|
<div class="toggle-knob" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generationMode === 'brainstorm' && selfExpansive && (
|
||||||
|
<div class="expansive-options">
|
||||||
|
<div class="slider-card slider-card--compact">
|
||||||
|
<div class="slider-card-header">
|
||||||
|
<div>
|
||||||
|
<span class="slider-label">Extra passes</span>
|
||||||
|
<span class="slider-copy">How many additional exploration rounds to run.</span>
|
||||||
|
</div>
|
||||||
|
<span class="slider-value">{expansivePasses}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="form-input"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
value={expansivePasses}
|
||||||
|
onInput={(e) => setExpansivePasses(Number((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="segmented-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`segmented-control-btn${expansiveMode === 'soft' ? ' segmented-control-btn--active' : ''}`}
|
||||||
|
onClick={() => setExpansiveMode('soft')}
|
||||||
|
>
|
||||||
|
Soft
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`segmented-control-btn${expansiveMode === 'extreme' ? ' segmented-control-btn--active' : ''}`}
|
||||||
|
onClick={() => setExpansiveMode('extreme')}
|
||||||
|
>
|
||||||
|
Extreme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn-secondary" onClick={onClose} disabled={loading}>
|
<button type="button" class="btn-secondary" onClick={onClose} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn-primary" disabled={loading || !mainPrompt.trim()}>
|
<button type="submit" class="btn-primary" disabled={loading}>
|
||||||
{loading ? 'Starting…' : 'Get Recommendations'}
|
{loading ? 'Creating…' : generationMode === 'brainstorm' ? 'Start Recommendation' : 'Start Continuous Search'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/* ── Pipeline Progress ──────────────────────────────────── */
|
/* ── Pipeline Progress ──────────────────────────────────── */
|
||||||
|
|
||||||
.pipeline-progress {
|
.pipeline-progress {
|
||||||
padding: 40px 0;
|
padding: var(--sp-8) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pipeline-title {
|
.pipeline-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-bottom: 24px;
|
margin-bottom: var(--sp-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pipeline-steps {
|
.pipeline-steps {
|
||||||
@@ -23,15 +23,21 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--bg-surface);
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 100%),
|
||||||
|
var(--bg-surface);
|
||||||
|
backdrop-filter: blur(14px) saturate(150%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-md);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pipeline-step--running {
|
.pipeline-step--running {
|
||||||
border-color: var(--accent);
|
border-color: rgba(232, 168, 124, 0.3);
|
||||||
background: var(--accent-dim);
|
background:
|
||||||
|
linear-gradient(180deg, rgba(232, 168, 124, 0.08), transparent 100%),
|
||||||
|
rgba(28, 25, 42, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pipeline-step--done {
|
.pipeline-step--done {
|
||||||
@@ -84,3 +90,30 @@
|
|||||||
.pipeline-step-label {
|
.pipeline-step-label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pipeline-retry {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step--skipped {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-skipped {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-pass-group--extra {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-pass-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import './PipelineProgress.css';
|
import './PipelineProgress.css';
|
||||||
import type { StageMap, StageStatus } from '../types/index.js';
|
import type { StageMap, StageStatus } from '../types/index.js';
|
||||||
|
|
||||||
const STAGES: { key: keyof StageMap; label: string }[] = [
|
export interface StageEntry {
|
||||||
{ key: 'interpreter', label: 'Interpreting Preferences' },
|
key: string;
|
||||||
{ key: 'retrieval', label: 'Generating Candidates' },
|
label: string;
|
||||||
{ key: 'ranking', label: 'Ranking Shows' },
|
}
|
||||||
{ key: 'curator', label: 'Curating Results' },
|
|
||||||
];
|
export interface StageGroup {
|
||||||
|
label: string;
|
||||||
|
stages: StageEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
interface PipelineProgressProps {
|
interface PipelineProgressProps {
|
||||||
|
stageGroups: StageGroup[];
|
||||||
stages: StageMap;
|
stages: StageMap;
|
||||||
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StageIcon({ status }: { status: StageStatus }) {
|
function StageIcon({ status }: { status: StageStatus }) {
|
||||||
@@ -20,23 +25,40 @@ function StageIcon({ status }: { status: StageStatus }) {
|
|||||||
return <span class="stage-icon stage-error">✗</span>;
|
return <span class="stage-icon stage-error">✗</span>;
|
||||||
case 'running':
|
case 'running':
|
||||||
return <span class="stage-icon stage-running spinner">⟳</span>;
|
return <span class="stage-icon stage-running spinner">⟳</span>;
|
||||||
|
case 'skipped':
|
||||||
|
return <span class="stage-icon stage-skipped">—</span>;
|
||||||
default:
|
default:
|
||||||
return <span class="stage-icon stage-pending">○</span>;
|
return <span class="stage-icon stage-pending">○</span>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineProgress({ stages }: PipelineProgressProps) {
|
export function PipelineProgress({ stageGroups, stages, onRetry }: PipelineProgressProps) {
|
||||||
|
const hasError = Object.values(stages).some((s) => s === 'error');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="pipeline-progress">
|
<div class="pipeline-progress">
|
||||||
<h3 class="pipeline-title">Generating Recommendations…</h3>
|
<h3 class="pipeline-title">{hasError ? 'Pipeline Failed' : 'Generating Recommendations…'}</h3>
|
||||||
|
{stageGroups.map((group, gi) => (
|
||||||
|
<div key={gi} class={`pipeline-pass-group${gi > 0 ? ' pipeline-pass-group--extra' : ''}`}>
|
||||||
|
{group.label && <div class="pipeline-pass-label">{group.label}</div>}
|
||||||
<ul class="pipeline-steps">
|
<ul class="pipeline-steps">
|
||||||
{STAGES.map(({ key, label }) => (
|
{group.stages.map(({ key, label }) => {
|
||||||
<li key={key} class={`pipeline-step pipeline-step--${stages[key]}`}>
|
const status: StageStatus = stages[key] ?? 'pending';
|
||||||
<StageIcon status={stages[key]} />
|
return (
|
||||||
|
<li key={key} class={`pipeline-step pipeline-step--${status}`}>
|
||||||
|
<StageIcon status={status} />
|
||||||
<span class="pipeline-step-label">{label}</span>
|
<span class="pipeline-step-label">{label}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
{hasError && onRetry && (
|
||||||
|
<div class="pipeline-retry">
|
||||||
|
<button class="btn-primary" onClick={onRetry}>Re-run Pipeline</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,22 @@ import type { CuratorOutput, CuratorCategory } from '../types/index.js';
|
|||||||
|
|
||||||
interface RecommendationCardProps {
|
interface RecommendationCardProps {
|
||||||
show: CuratorOutput;
|
show: CuratorOutput;
|
||||||
|
verified?: boolean;
|
||||||
existingFeedback?: { stars: number; feedback: string };
|
existingFeedback?: { stars: number; feedback: string };
|
||||||
onFeedback: (tv_show_name: string, stars: number, feedback: string) => Promise<void>;
|
onFeedback: (item_name: string, stars: number, feedback: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
||||||
'Will Not Like': 'badge-red',
|
'Will Not Like': 'badge-red',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RecommendationCard({ show, existingFeedback, onFeedback }: RecommendationCardProps) {
|
export function RecommendationCard({ show, verified, existingFeedback, onFeedback }: RecommendationCardProps) {
|
||||||
const [selectedStars, setSelectedStars] = useState(existingFeedback?.stars ?? 0);
|
const [selectedStars, setSelectedStars] = useState(existingFeedback?.stars ?? 0);
|
||||||
|
const [hoverStar, setHoverStar] = useState(0);
|
||||||
const [comment, setComment] = useState(existingFeedback?.feedback ?? '');
|
const [comment, setComment] = useState(existingFeedback?.feedback ?? '');
|
||||||
const [showComment, setShowComment] = useState(false);
|
const [showComment, setShowComment] = useState(false);
|
||||||
const [submitted, setSubmitted] = useState(!!existingFeedback);
|
const [submitted, setSubmitted] = useState(!!existingFeedback);
|
||||||
@@ -47,18 +50,39 @@ export function RecommendationCard({ show, existingFeedback, onFeedback }: Recom
|
|||||||
</div>
|
</div>
|
||||||
<p class="card-explanation">{show.explanation}</p>
|
<p class="card-explanation">{show.explanation}</p>
|
||||||
|
|
||||||
|
<div class="card-badges">
|
||||||
|
{show.genre && <span class="badge genre-badge">{show.genre}</span>}
|
||||||
|
{verified && <span class="badge badge-verified">Verified</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(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) => {
|
||||||
|
const effective = hoverStar || selectedStars;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={star}
|
key={star}
|
||||||
class={`star-btn${selectedStars >= star ? ' star-active' : ''}`}
|
class={`star-btn${effective >= star ? ' star-active' : ''}`}
|
||||||
onClick={() => handleStarClick(star)}
|
onClick={() => handleStarClick(star)}
|
||||||
|
onMouseEnter={() => setHoverStar(star)}
|
||||||
|
onMouseLeave={() => setHoverStar(0)}
|
||||||
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
||||||
>
|
>
|
||||||
{selectedStars >= star ? '★' : '☆'}
|
{effective >= star ? '★' : '☆'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{submitted && <span class="feedback-saved">Saved</span>}
|
{submitted && <span class="feedback-saved">Saved</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
background: var(--bg-surface);
|
background:
|
||||||
border-right: 1px solid var(--border);
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 100%),
|
||||||
|
rgba(28, 25, 42, 0.82);
|
||||||
|
backdrop-filter: blur(20px) saturate(160%);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 20px 16px 12px;
|
padding: var(--sp-5) var(--sp-4) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -20,7 +24,7 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.03em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
@@ -32,28 +36,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-new {
|
.btn-new {
|
||||||
margin: 12px;
|
margin: var(--sp-3);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #1a141f;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
box-shadow: 0 8px 24px rgba(232, 168, 124, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-new:hover {
|
.btn-new:hover {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 12px 30px rgba(232, 168, 124, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-label {
|
.sidebar-section-label {
|
||||||
padding: 8px 16px 4px;
|
padding: 8px 16px 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
@@ -76,21 +83,27 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: background 0.1s, color 0.1s;
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item:hover {
|
.sidebar-item:hover {
|
||||||
background: var(--bg-surface-2);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
transform: translateX(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item.selected {
|
.sidebar-item.selected {
|
||||||
background: var(--accent-dim);
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 100%),
|
||||||
|
rgba(232, 168, 124, 0.12);
|
||||||
|
border-color: rgba(232, 168, 124, 0.22);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,4 +134,55 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-type-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-type-tv_show {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-type-movie {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover .sidebar-item-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-delete:hover {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 42vh;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
|
import { route } from 'preact-router';
|
||||||
import type { RecommendationSummary } from '../types/index.js';
|
import type { RecommendationSummary } from '../types/index.js';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -30,10 +31,27 @@ export function Sidebar({ list, selectedId, onSelect, onNewClick }: SidebarProps
|
|||||||
return (
|
return (
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<a href="/" class="sidebar-title">Recommender</a>
|
<a
|
||||||
|
href="/"
|
||||||
|
class="sidebar-title"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
route('/');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Recommender
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-new" onClick={onNewClick}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-new"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNewClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
+ New Recommendation
|
+ New Recommendation
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -53,6 +71,9 @@ export function Sidebar({ list, selectedId, onSelect, onNewClick }: SidebarProps
|
|||||||
{statusIcon(item.status)}
|
{statusIcon(item.status)}
|
||||||
</span>
|
</span>
|
||||||
<span class="sidebar-item-title">{item.title}</span>
|
<span class="sidebar-item-title">{item.title}</span>
|
||||||
|
<span class={`sidebar-type-badge sidebar-type-${item.media_type}`}>
|
||||||
|
{item.media_type === 'movie' ? 'Film' : 'TV'}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
import type { RecommendationSummary, FeedbackEntry } from '../types/index.js';
|
import type { MediaType, RecommendationSummary, FeedbackEntry } from '../types/index.js';
|
||||||
import {
|
import {
|
||||||
listRecommendations,
|
listRecommendations,
|
||||||
createRecommendation,
|
createRecommendation,
|
||||||
|
createContinuousRecommendation,
|
||||||
rerankRecommendation,
|
rerankRecommendation,
|
||||||
submitFeedback,
|
submitFeedback,
|
||||||
getFeedback,
|
getFeedback,
|
||||||
|
deleteRecommendation,
|
||||||
} from '../api/client.js';
|
} from '../api/client.js';
|
||||||
|
|
||||||
|
type GenerationMode = 'brainstorm' | 'continuous';
|
||||||
|
|
||||||
|
interface CreateBody {
|
||||||
|
main_prompt: string;
|
||||||
|
liked_series: string;
|
||||||
|
disliked_series: string;
|
||||||
|
themes: string;
|
||||||
|
requirements?: string;
|
||||||
|
avoid?: string;
|
||||||
|
brainstorm_count?: number;
|
||||||
|
media_type: MediaType;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
use_validator?: boolean;
|
||||||
|
hard_requirements?: boolean;
|
||||||
|
self_expansive?: boolean;
|
||||||
|
expansive_passes?: number;
|
||||||
|
expansive_mode?: 'soft' | 'extreme';
|
||||||
|
generation_mode?: GenerationMode;
|
||||||
|
total_count?: number;
|
||||||
|
validate_results?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function useRecommendations() {
|
export function useRecommendations() {
|
||||||
const [list, setList] = useState<RecommendationSummary[]>([]);
|
const [list, setList] = useState<RecommendationSummary[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -29,14 +53,27 @@ export function useRecommendations() {
|
|||||||
}, [refreshList, refreshFeedback]);
|
}, [refreshList, refreshFeedback]);
|
||||||
|
|
||||||
const createNew = useCallback(
|
const createNew = useCallback(
|
||||||
async (body: {
|
async (body: CreateBody) => {
|
||||||
main_prompt: string;
|
let id = '';
|
||||||
liked_shows: string;
|
|
||||||
disliked_shows: string;
|
if (body.generation_mode === 'continuous') {
|
||||||
themes: string;
|
const result = await createContinuousRecommendation({
|
||||||
brainstorm_count?: number;
|
liked_series: body.liked_series,
|
||||||
}) => {
|
disliked_series: body.disliked_series,
|
||||||
const { id } = await createRecommendation(body);
|
themes: body.themes,
|
||||||
|
requirements: body.requirements ?? '',
|
||||||
|
avoid: body.avoid ?? '',
|
||||||
|
total_count: body.total_count ?? 30,
|
||||||
|
media_type: body.media_type,
|
||||||
|
use_web_search: body.use_web_search,
|
||||||
|
validate_results: body.validate_results,
|
||||||
|
});
|
||||||
|
id = result.id;
|
||||||
|
} else {
|
||||||
|
const result = await createRecommendation(body);
|
||||||
|
id = result.id;
|
||||||
|
}
|
||||||
|
|
||||||
await refreshList();
|
await refreshList();
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
return id;
|
return id;
|
||||||
@@ -47,7 +84,6 @@ export function useRecommendations() {
|
|||||||
const rerank = useCallback(
|
const rerank = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await rerankRecommendation(id);
|
await rerankRecommendation(id);
|
||||||
// Update local list to show pending status
|
|
||||||
setList((prev) =>
|
setList((prev) =>
|
||||||
prev.map((r) => (r.id === id ? { ...r, status: 'pending' as const } : r)),
|
prev.map((r) => (r.id === id ? { ...r, status: 'pending' as const } : r)),
|
||||||
);
|
);
|
||||||
@@ -56,7 +92,7 @@ export function useRecommendations() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmitFeedback = useCallback(
|
const handleSubmitFeedback = useCallback(
|
||||||
async (body: { tv_show_name: string; stars: number; feedback?: string }) => {
|
async (body: { item_name: string; stars: number; feedback?: string }) => {
|
||||||
await submitFeedback(body);
|
await submitFeedback(body);
|
||||||
await refreshFeedback();
|
await refreshFeedback();
|
||||||
},
|
},
|
||||||
@@ -70,6 +106,15 @@ export function useRecommendations() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateTitle = useCallback((id: string, title: string) => {
|
||||||
|
setList((prev) => prev.map((r) => (r.id === id ? { ...r, title } : r)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteRec = useCallback(async (id: string) => {
|
||||||
|
await deleteRecommendation(id);
|
||||||
|
setList((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -79,6 +124,8 @@ export function useRecommendations() {
|
|||||||
rerank,
|
rerank,
|
||||||
submitFeedback: handleSubmitFeedback,
|
submitFeedback: handleSubmitFeedback,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
|
updateTitle,
|
||||||
refreshList,
|
refreshList,
|
||||||
|
deleteRec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import type { SSEEvent } from '../types/index.js';
|
|||||||
export function useSSE(
|
export function useSSE(
|
||||||
url: string | null,
|
url: string | null,
|
||||||
onEvent: (event: SSEEvent) => void,
|
onEvent: (event: SSEEvent) => void,
|
||||||
|
onClose?: () => void,
|
||||||
): { close: () => void } {
|
): { close: () => void } {
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
const onEventRef = useRef(onEvent);
|
const onEventRef = useRef(onEvent);
|
||||||
onEventRef.current = onEvent;
|
onEventRef.current = onEvent;
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -32,6 +35,7 @@ export function useSSE(
|
|||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
es.close();
|
es.close();
|
||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
|
onCloseRef.current?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
+245
-22
@@ -7,29 +7,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1117;
|
--bg: #13111a;
|
||||||
--bg-surface: #1a1d27;
|
--bg-surface: rgba(28, 25, 42, 0.72);
|
||||||
--bg-surface-2: #222535;
|
--bg-surface-2: rgba(36, 33, 56, 0.65);
|
||||||
--bg-surface-3: #2a2e42;
|
--bg-surface-3: rgba(48, 45, 72, 0.6);
|
||||||
--border: #2e3248;
|
--border: rgba(245, 240, 232, 0.08);
|
||||||
--text: #e2e4ed;
|
--border-strong: rgba(245, 240, 232, 0.16);
|
||||||
--text-muted: #8a8fa8;
|
--text: #f5f0e8;
|
||||||
--text-dim: #5a5f77;
|
--text-muted: #a09890;
|
||||||
--accent: #6366f1;
|
--text-dim: #686070;
|
||||||
--accent-hover: #4f52d9;
|
--accent: #e8a87c;
|
||||||
--accent-dim: rgba(99, 102, 241, 0.15);
|
--accent-hover: #d4956a;
|
||||||
|
--accent-dim: rgba(232, 168, 124, 0.12);
|
||||||
--green: #22c55e;
|
--green: #22c55e;
|
||||||
--blue: #3b82f6;
|
--blue: #3b82f6;
|
||||||
--yellow: #eab308;
|
--yellow: #eab308;
|
||||||
--red: #ef4444;
|
--red: #ef4444;
|
||||||
--radius: 8px;
|
--magenta: #d946ef;
|
||||||
--radius-sm: 4px;
|
--radius-xs: 4px;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
--sp-1: 4px;
|
||||||
|
--sp-2: 8px;
|
||||||
|
--sp-3: 12px;
|
||||||
|
--sp-4: 16px;
|
||||||
|
--sp-5: 20px;
|
||||||
|
--sp-6: 24px;
|
||||||
|
--sp-8: 32px;
|
||||||
|
--sp-10: 40px;
|
||||||
|
--sp-12: 48px;
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 260px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Sora', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -40,6 +58,43 @@ body,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 15% 10%, rgba(232, 168, 124, 0.07), transparent 55%),
|
||||||
|
radial-gradient(ellipse 70% 50% at 85% 90%, rgba(245, 240, 232, 0.05), transparent 50%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 40%),
|
||||||
|
radial-gradient(circle at top left, rgba(255, 214, 170, 0.05), transparent 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 168, 124, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Layout ────────────────────────────────────────────── */
|
/* ── Layout ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
@@ -51,7 +106,7 @@ body,
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 32px;
|
padding: var(--sp-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared content areas ───────────────────────────────── */
|
/* ── Shared content areas ───────────────────────────────── */
|
||||||
@@ -62,7 +117,7 @@ body,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -73,26 +128,56 @@ body,
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.surface-glass {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 100%),
|
||||||
|
var(--bg-surface);
|
||||||
|
backdrop-filter: blur(18px) saturate(160%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 40px rgba(0, 0, 0, 0.28),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Buttons ────────────────────────────────────────────── */
|
/* ── Buttons ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #1a141f;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
transform 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(232, 168, 124, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 8px rgba(232, 168, 124, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,16 +188,154 @@ body,
|
|||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: var(--bg-surface-2);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
border-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--bg-surface-3);
|
background: rgba(255, 255, 255, 0.09);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
border-color 0.15s ease,
|
||||||
|
color 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
color: var(--text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
input[type='search'],
|
||||||
|
textarea {
|
||||||
|
background: rgba(18, 16, 26, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text']:focus,
|
||||||
|
input[type='search']:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: rgba(232, 168, 124, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 168, 124, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 100px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 168, 124, 0.2);
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb:hover {
|
||||||
|
box-shadow: 0 0 0 5px rgba(232, 168, 124, 0.3), 0 0 12px rgba(232, 168, 124, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.main-content {
|
||||||
|
padding: var(--sp-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: var(--sp-5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,30 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-bg {
|
.landing-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -6px;
|
inset: -10px;
|
||||||
/* overflow slightly to hide blur edges */
|
|
||||||
background: url('/wallpaper.png') center / cover no-repeat;
|
background: url('/wallpaper.png') center / cover no-repeat;
|
||||||
filter: blur(3px) brightness(0.35);
|
filter: blur(5px) brightness(0.28) saturate(0.85);
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.landing-layout::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(10, 8, 15, 0.34) 0%, rgba(10, 8, 15, 0.08) 32%, rgba(10, 8, 15, 0.28) 100%);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.landing-layout .sidebar {
|
.landing-layout .sidebar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: rgba(26, 29, 39, 0.85);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-main {
|
.landing-main {
|
||||||
@@ -33,41 +41,67 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 40px;
|
padding: var(--sp-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-title {
|
.landing-title {
|
||||||
font-size: 5rem;
|
font-size: clamp(3.8rem, 9vw, 5.6rem);
|
||||||
font-weight: 800;
|
font-weight: 300;
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
letter-spacing: -2px;
|
letter-spacing: -0.03em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
text-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-tagline {
|
.landing-tagline {
|
||||||
font-size: 1.4rem;
|
max-width: 620px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
font-size: clamp(1rem, 2vw, 1.3rem);
|
||||||
|
color: rgba(245, 240, 232, 0.7);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-gradient {
|
.btn-gradient {
|
||||||
padding: 16px 42px;
|
padding: 14px 30px;
|
||||||
font-size: 1.20rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: #1a141f;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 244, 232, 0.14);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: linear-gradient(135deg, rgba(113, 88, 226, 0.8), rgba(61, 59, 243, 0.8));
|
background:
|
||||||
backdrop-filter: blur(6px);
|
linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 100%),
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
linear-gradient(135deg, rgba(232, 168, 124, 0.96), rgba(214, 149, 106, 0.94));
|
||||||
transition: all 0.6s cubic-bezier(0.25, 0.8, 0.25, 1);
|
backdrop-filter: blur(10px);
|
||||||
letter-spacing: 0.02em;
|
box-shadow:
|
||||||
|
0 10px 30px rgba(232, 168, 124, 0.22),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
background 0.2s ease;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-gradient:hover {
|
.btn-gradient:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
color: #fff;
|
box-shadow:
|
||||||
background: linear-gradient(135deg, rgba(113, 88, 226, 0.95), rgba(41, 121, 255, 0.95));
|
0 16px 38px rgba(232, 168, 124, 0.28),
|
||||||
box-shadow: 0 8px 24px rgba(113, 88, 226, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.2);
|
inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.14), transparent 100%),
|
||||||
|
linear-gradient(135deg, rgba(232, 168, 124, 1), rgba(214, 149, 106, 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.landing-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-main {
|
||||||
|
min-height: 55vh;
|
||||||
|
padding: var(--sp-8) var(--sp-5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,13 +13,27 @@ 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;
|
||||||
|
avoid?: string;
|
||||||
brainstorm_count?: number;
|
brainstorm_count?: number;
|
||||||
|
media_type: import('../types/index.js').MediaType;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
use_validator?: boolean;
|
||||||
|
hard_requirements?: boolean;
|
||||||
|
self_expansive?: boolean;
|
||||||
|
expansive_passes?: number;
|
||||||
|
expansive_mode?: 'soft' | 'extreme';
|
||||||
|
generation_mode?: 'brainstorm' | 'continuous';
|
||||||
|
total_count?: number;
|
||||||
|
validate_results?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const id = await createNew(body);
|
const id = await createNew(body);
|
||||||
|
if (id) {
|
||||||
route(`/recom/${id}`);
|
route(`/recom/${id}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +47,7 @@ export function Home() {
|
|||||||
/>
|
/>
|
||||||
<main class="landing-main">
|
<main class="landing-main">
|
||||||
<h1 class="landing-title">Recommender</h1>
|
<h1 class="landing-title">Recommender</h1>
|
||||||
<p class="landing-tagline">Discover your next favorite show, powered by AI.</p>
|
<p class="landing-tagline">Discover your next favorite show or movie, powered by AI.</p>
|
||||||
<button class="btn-gradient" onClick={() => setShowModal(true)}>
|
<button class="btn-gradient" onClick={() => setShowModal(true)}>
|
||||||
Get Started →
|
Get Started →
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,6 +16,131 @@
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Request Info Panel ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.rec-info-panel {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 100%),
|
||||||
|
var(--bg-surface);
|
||||||
|
backdrop-filter: blur(18px) saturate(160%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 34px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-chevron {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-body {
|
||||||
|
padding: 0 16px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(232, 168, 124, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-delete-row {
|
||||||
|
padding-top: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared sizing for the two action buttons in the info panel */
|
||||||
|
.btn-danger,
|
||||||
|
.rec-info-delete-row .btn-rerun {
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-run button — same shape as Delete, neutral tone */
|
||||||
|
.rec-info-delete-row .btn-rerun {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-delete-row .btn-rerun:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.error-state {
|
.error-state {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -28,3 +153,23 @@
|
|||||||
.error-state p {
|
.error-state p {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.content-area {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-label {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info-delete-row {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,46 +2,92 @@ import { useState, useCallback, useEffect } from 'preact/hooks';
|
|||||||
import './Recom.css';
|
import './Recom.css';
|
||||||
import { route } from 'preact-router';
|
import { route } from 'preact-router';
|
||||||
import { PipelineProgress } from '../components/PipelineProgress.js';
|
import { PipelineProgress } from '../components/PipelineProgress.js';
|
||||||
|
import type { StageGroup } from '../components/PipelineProgress.js';
|
||||||
import { RecommendationCard } from '../components/RecommendationCard.js';
|
import { RecommendationCard } from '../components/RecommendationCard.js';
|
||||||
import { Sidebar } from '../components/Sidebar.js';
|
import { Sidebar } from '../components/Sidebar.js';
|
||||||
import { NewRecommendationModal } from '../components/NewRecommendationModal.js';
|
import { NewRecommendationModal } from '../components/NewRecommendationModal.js';
|
||||||
import { useRecommendationsContext } from '../context/RecommendationsContext.js';
|
import { useRecommendationsContext } from '../context/RecommendationsContext.js';
|
||||||
import { useSSE } from '../hooks/useSSE.js';
|
import { useSSE } from '../hooks/useSSE.js';
|
||||||
import { getRecommendation } from '../api/client.js';
|
import { getRecommendation } from '../api/client.js';
|
||||||
import type { Recommendation, SSEEvent, StageMap, PipelineStage } from '../types/index.js';
|
import type { Recommendation, SSEEvent, StageMap, StageStatus } from '../types/index.js';
|
||||||
|
|
||||||
interface RecomProps {
|
interface RecomProps {
|
||||||
id: string;
|
id: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STAGES: StageMap = {
|
function buildDefaultStages(rec: Recommendation | null): StageMap {
|
||||||
|
const map: StageMap = {
|
||||||
interpreter: 'pending',
|
interpreter: 'pending',
|
||||||
retrieval: 'pending',
|
retrieval: 'pending',
|
||||||
|
validator: 'pending',
|
||||||
ranking: 'pending',
|
ranking: 'pending',
|
||||||
curator: 'pending',
|
curator: 'pending',
|
||||||
};
|
};
|
||||||
|
if (rec?.self_expansive && rec.expansive_passes > 0) {
|
||||||
|
for (let i = 0; i < rec.expansive_passes; i++) {
|
||||||
|
const p = i + 2;
|
||||||
|
map[`pass${p}:retrieval`] = 'pending';
|
||||||
|
if (rec.use_validator) map[`pass${p}:validator`] = 'pending';
|
||||||
|
map[`pass${p}:ranking`] = 'pending';
|
||||||
|
map[`pass${p}:curator`] = 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
const STAGE_ORDER: (keyof StageMap)[] = ['interpreter', 'retrieval', 'ranking', 'curator'];
|
function buildStageGroups(rec: Recommendation | null): StageGroup[] {
|
||||||
|
const baseStages = [
|
||||||
|
{ key: 'interpreter', label: 'Interpreting Preferences' },
|
||||||
|
{ key: 'retrieval', label: 'Generating Candidates' },
|
||||||
|
{ key: 'validator', label: 'Validating Candidates' },
|
||||||
|
{ key: 'ranking', label: 'Ranking Candidates' },
|
||||||
|
{ key: 'curator', label: 'Curating Results' },
|
||||||
|
];
|
||||||
|
const groups: StageGroup[] = [{ label: '', stages: baseStages }];
|
||||||
|
if (rec?.self_expansive && rec.expansive_passes > 0) {
|
||||||
|
for (let i = 0; i < rec.expansive_passes; i++) {
|
||||||
|
const p = i + 2;
|
||||||
|
groups.push({
|
||||||
|
label: `Pass ${p}`,
|
||||||
|
stages: [
|
||||||
|
{ key: `pass${p}:retrieval`, label: 'Generating Candidates' },
|
||||||
|
...(rec.use_validator ? [{ key: `pass${p}:validator`, label: 'Validating Candidates' }] : []),
|
||||||
|
{ key: `pass${p}:ranking`, label: 'Ranking Candidates' },
|
||||||
|
{ key: `pass${p}:curator`, label: 'Curating Results' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
export function Recom({ id }: RecomProps) {
|
export function Recom({ id }: RecomProps) {
|
||||||
const { list, feedback, submitFeedback, rerank, updateStatus, refreshList, createNew } = useRecommendationsContext();
|
const { list, feedback, submitFeedback, rerank, updateStatus, updateTitle, refreshList, createNew, deleteRec } = useRecommendationsContext();
|
||||||
|
|
||||||
const [rec, setRec] = useState<Recommendation | null>(null);
|
const [rec, setRec] = useState<Recommendation | null>(null);
|
||||||
const [stages, setStages] = useState<StageMap>(DEFAULT_STAGES);
|
const [stages, setStages] = useState<StageMap>(buildDefaultStages(null));
|
||||||
const [sseUrl, setSseUrl] = useState<string | null>(null);
|
// sseKey drives the SSE connection. null = inactive; a number = active.
|
||||||
|
// Using a timestamp nonce ensures the URL is always unique on (re)connect,
|
||||||
|
// so useSSE's useEffect always re-runs even if the base path hasn't changed.
|
||||||
|
const [sseKey, setSseKey] = useState<number | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [infoExpanded, setInfoExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Derive the actual URL from the key; query param is ignored by the server
|
||||||
|
// but makes the string unique so React/Preact state always treats it as changed.
|
||||||
|
const sseUrl = sseKey !== null ? `/api/recommendations/${id}/stream?_k=${sseKey}` : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRec(null);
|
setRec(null);
|
||||||
setStages(DEFAULT_STAGES);
|
setStages(buildDefaultStages(null));
|
||||||
setSseUrl(null);
|
setSseKey(null);
|
||||||
getRecommendation(id)
|
getRecommendation(id)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setRec(data);
|
setRec(data);
|
||||||
if (data.status === 'running' || data.status === 'pending') {
|
if (data.status === 'running' || data.status === 'pending') {
|
||||||
setStages(DEFAULT_STAGES);
|
setStages(buildDefaultStages(data));
|
||||||
setSseUrl(`/api/recommendations/${id}/stream`);
|
setSseKey(Date.now());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => route('/'));
|
.catch(() => route('/'));
|
||||||
@@ -50,56 +96,117 @@ export function Recom({ id }: RecomProps) {
|
|||||||
const handleSSEEvent = useCallback(
|
const handleSSEEvent = useCallback(
|
||||||
(event: SSEEvent) => {
|
(event: SSEEvent) => {
|
||||||
if (event.stage !== 'complete') {
|
if (event.stage !== 'complete') {
|
||||||
const stageKey = event.stage as keyof StageMap;
|
const stageKey = event.stage as string;
|
||||||
if (STAGE_ORDER.includes(stageKey)) {
|
setStages((prev) => {
|
||||||
setStages((prev) => ({
|
if (!(stageKey in prev)) return prev;
|
||||||
...prev,
|
const eventData = event.data as { skipped?: boolean } | undefined;
|
||||||
[stageKey]: event.status === 'start' ? 'running' : event.status === 'done' ? 'done' : 'error',
|
let newStatus: StageStatus;
|
||||||
}));
|
if (event.status === 'start') {
|
||||||
|
newStatus = 'running';
|
||||||
|
} else if (event.status === 'done') {
|
||||||
|
newStatus = eventData?.skipped ? 'skipped' : 'done';
|
||||||
|
} else {
|
||||||
|
newStatus = 'error';
|
||||||
}
|
}
|
||||||
|
return { ...prev, [stageKey]: newStatus };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.stage === 'complete' && event.status === 'done') {
|
if (event.stage === 'complete' && event.status === 'done') {
|
||||||
setSseUrl(null);
|
const incoming = event.data as { title?: string } | undefined;
|
||||||
|
if (incoming?.title) {
|
||||||
|
updateTitle(id, incoming.title);
|
||||||
|
setRec((prev) => (prev ? { ...prev, title: incoming.title! } : prev));
|
||||||
|
}
|
||||||
|
setSseKey(null);
|
||||||
updateStatus(id, 'done');
|
updateStatus(id, 'done');
|
||||||
void getRecommendation(id).then(setRec);
|
void getRecommendation(id)
|
||||||
|
.then(setRec)
|
||||||
|
.catch(() => {
|
||||||
|
// Fetch failed after completion — poll once more via handleSSEClose logic
|
||||||
|
updateStatus(id, 'done');
|
||||||
|
});
|
||||||
void refreshList();
|
void refreshList();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.status === 'error') {
|
if (event.status === 'error') {
|
||||||
setSseUrl(null);
|
setSseKey(null);
|
||||||
updateStatus(id, 'error');
|
updateStatus(id, 'error');
|
||||||
const stageKey = event.stage as PipelineStage;
|
setRec((prev) => (prev ? { ...prev, status: 'error' as const } : null));
|
||||||
|
const stageKey = event.stage as string;
|
||||||
if (stageKey !== 'complete') {
|
if (stageKey !== 'complete') {
|
||||||
setStages((prev) => ({ ...prev, [stageKey as keyof StageMap]: 'error' }));
|
setStages((prev) => (stageKey in prev ? { ...prev, [stageKey]: 'error' } : prev));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id, updateStatus, refreshList],
|
[id, updateStatus, updateTitle, refreshList],
|
||||||
);
|
);
|
||||||
|
|
||||||
useSSE(sseUrl, handleSSEEvent);
|
const handleSSEClose = useCallback(() => {
|
||||||
|
void getRecommendation(id).then((data) => {
|
||||||
|
if (data.status === 'done') {
|
||||||
|
setSseKey(null);
|
||||||
|
setRec(data);
|
||||||
|
const allDone = Object.fromEntries(
|
||||||
|
Object.keys(buildDefaultStages(data)).map((k) => [k, 'done' as StageStatus])
|
||||||
|
);
|
||||||
|
setStages(allDone);
|
||||||
|
updateStatus(id, 'done');
|
||||||
|
void refreshList();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
setSseKey(null);
|
||||||
|
setRec(data);
|
||||||
|
updateStatus(id, 'error');
|
||||||
|
} else {
|
||||||
|
// Pipeline still running — reconnect with a new unique key so that
|
||||||
|
// useSSE's useEffect always re-fires even if the base URL is the same.
|
||||||
|
setSseKey(Date.now());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [id, updateStatus, refreshList]);
|
||||||
|
|
||||||
|
useSSE(sseUrl, handleSSEEvent, handleSSEClose);
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
await rerank(id);
|
||||||
|
setStages(buildDefaultStages(rec));
|
||||||
|
setSseKey(Date.now());
|
||||||
|
setRec((prev) => (prev ? { ...prev, status: 'pending' as const } : null));
|
||||||
|
};
|
||||||
|
|
||||||
const handleRerank = async () => {
|
const handleRerank = async () => {
|
||||||
await rerank(id);
|
await rerank(id);
|
||||||
setStages(DEFAULT_STAGES);
|
setStages(buildDefaultStages(rec));
|
||||||
setSseUrl(`/api/recommendations/${id}/stream`);
|
setSseKey(Date.now());
|
||||||
setRec((prev) => (prev ? { ...prev, status: 'pending' } : null));
|
setRec((prev) => (prev ? { ...prev, status: 'pending' as const } : null));
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
use_web_search?: boolean;
|
||||||
|
use_validator?: boolean;
|
||||||
|
hard_requirements?: boolean;
|
||||||
|
self_expansive?: boolean;
|
||||||
|
expansive_passes?: number;
|
||||||
|
expansive_mode?: 'soft' | 'extreme';
|
||||||
|
generation_mode?: 'brainstorm' | 'continuous';
|
||||||
|
total_count?: number;
|
||||||
|
validate_results?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const newId = await createNew(body);
|
const newId = await createNew(body);
|
||||||
|
if (newId) {
|
||||||
route(`/recom/${newId}`);
|
route(`/recom/${newId}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isRunning = rec?.status === 'running' || rec?.status === 'pending' || !!sseUrl;
|
const isRunning = rec?.status === 'running' || rec?.status === 'pending' || sseKey !== null;
|
||||||
const feedbackMap = new Map(feedback.map((f) => [f.tv_show_name, f]));
|
const feedbackMap = new Map(feedback.map((f) => [f.item_name, f]));
|
||||||
|
const stageGroups = buildStageGroups(rec);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
@@ -112,23 +219,107 @@ export function Recom({ id }: RecomProps) {
|
|||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div class="recom-content">
|
<div class="recom-content">
|
||||||
|
{rec && (
|
||||||
|
<div class="content-area">
|
||||||
|
<div class="rec-info-panel">
|
||||||
|
<button class="rec-info-toggle" onClick={() => setInfoExpanded((e) => !e)}>
|
||||||
|
<span class="rec-info-title">{rec.title}</span>
|
||||||
|
<span class="rec-info-chevron">{infoExpanded ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{infoExpanded && (
|
||||||
|
<div class="rec-info-body">
|
||||||
|
{rec.main_prompt && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Description</span>
|
||||||
|
<span class="rec-info-value">{rec.main_prompt}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.liked_series && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Liked</span>
|
||||||
|
<span class="rec-info-value">{rec.liked_series}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.disliked_series && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Disliked</span>
|
||||||
|
<span class="rec-info-value">{rec.disliked_series}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.themes && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Themes</span>
|
||||||
|
<span class="rec-info-value">{rec.themes}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Media</span>
|
||||||
|
<span class="rec-info-value">{rec.media_type === 'tv_show' ? 'TV series' : 'Movies'}</span>
|
||||||
|
</div>
|
||||||
|
{rec.use_web_search && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Web Search</span>
|
||||||
|
<span class="rec-info-badge">enabled</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.use_validator && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Validator</span>
|
||||||
|
<span class="rec-info-badge">enabled</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.hard_requirements && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Hard Req.</span>
|
||||||
|
<span class="rec-info-badge">enabled</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.self_expansive && (
|
||||||
|
<div class="rec-info-row">
|
||||||
|
<span class="rec-info-label">Self Expansive</span>
|
||||||
|
<span class="rec-info-badge">{rec.expansive_passes} pass{rec.expansive_passes !== 1 ? 'es' : ''} · {rec.expansive_mode}</span>
|
||||||
|
</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={() => {
|
||||||
|
if (confirm('Delete this recommendation?')) {
|
||||||
|
void deleteRec(id).then(() => route('/'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<PipelineProgress stages={stages} />
|
<PipelineProgress stageGroups={stageGroups} stages={stages} onRetry={handleRetry} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isRunning && rec?.status === 'done' && rec.recommendations && (
|
{!isRunning && rec?.status === 'done' && rec.recommendations && (
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<h2 class="rec-title">{rec.title}</h2>
|
|
||||||
<div class="cards-grid">
|
<div class="cards-grid">
|
||||||
{rec.recommendations.map((show) => (
|
{rec.recommendations.map((show) => (
|
||||||
<RecommendationCard
|
<RecommendationCard
|
||||||
key={show.title}
|
key={show.title}
|
||||||
show={show}
|
show={show}
|
||||||
|
verified={rec.use_validator}
|
||||||
existingFeedback={feedbackMap.get(show.title)}
|
existingFeedback={feedbackMap.get(show.title)}
|
||||||
onFeedback={async (name, stars, comment) => {
|
onFeedback={async (name, stars, comment) => {
|
||||||
await submitFeedback({ tv_show_name: name, stars, feedback: comment });
|
await submitFeedback({ item_name: name, stars, feedback: comment });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -149,9 +340,9 @@ export function Recom({ id }: RecomProps) {
|
|||||||
{!isRunning && rec?.status === 'error' && (
|
{!isRunning && rec?.status === 'error' && (
|
||||||
<div class="content-area error-state">
|
<div class="content-area error-state">
|
||||||
<h2>Something went wrong</h2>
|
<h2>Something went wrong</h2>
|
||||||
<p>The pipeline encountered an error. You can try again by clicking Re-rank.</p>
|
<p>The pipeline encountered an error. You can try again by clicking the button below.</p>
|
||||||
<button class="btn-primary" onClick={handleRerank}>
|
<button class="btn-primary" onClick={handleRetry}>
|
||||||
Try Again
|
Re-run Pipeline
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
export type CuratorCategory = 'Definitely Like' | 'Might Like' | 'Questionable' | 'Will Not Like';
|
export type MediaType = 'tv_show' | 'movie';
|
||||||
|
|
||||||
|
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';
|
||||||
@@ -12,9 +17,16 @@ 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;
|
||||||
|
use_web_search: boolean;
|
||||||
|
use_validator: boolean;
|
||||||
|
hard_requirements: boolean;
|
||||||
|
self_expansive: boolean;
|
||||||
|
expansive_passes: number;
|
||||||
|
expansive_mode: 'soft' | 'extreme';
|
||||||
recommendations: CuratorOutput[] | null;
|
recommendations: CuratorOutput[] | null;
|
||||||
status: RecommendationStatus;
|
status: RecommendationStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -24,18 +36,30 @@ export interface RecommendationSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: RecommendationStatus;
|
status: RecommendationStatus;
|
||||||
|
media_type: MediaType;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackEntry {
|
export interface FeedbackEntry {
|
||||||
id: string;
|
id: string;
|
||||||
tv_show_name: string;
|
item_name: string;
|
||||||
stars: number;
|
stars: number;
|
||||||
feedback: string;
|
feedback: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PipelineStage = 'interpreter' | 'retrieval' | 'ranking' | 'curator' | 'complete';
|
export type PipelineStage =
|
||||||
|
| 'interpreter'
|
||||||
|
| 'retrieval'
|
||||||
|
| 'validator'
|
||||||
|
| 'ranking'
|
||||||
|
| 'curator'
|
||||||
|
| 'complete'
|
||||||
|
| `pass${number}:retrieval`
|
||||||
|
| `pass${number}:validator`
|
||||||
|
| `pass${number}:ranking`
|
||||||
|
| `pass${number}:curator`;
|
||||||
|
|
||||||
export type SSEStatus = 'start' | 'done' | 'error';
|
export type SSEStatus = 'start' | 'done' | 'error';
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface SSEEvent {
|
||||||
@@ -44,6 +68,6 @@ export interface SSEEvent {
|
|||||||
data?: unknown;
|
data?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StageStatus = 'pending' | 'running' | 'done' | 'error';
|
export type StageStatus = 'pending' | 'running' | 'done' | 'error' | 'skipped';
|
||||||
|
|
||||||
export type StageMap = Record<Exclude<PipelineStage, 'complete'>, StageStatus>;
|
export type StageMap = Record<string, StageStatus>;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user