This commit is contained in:
2026-03-25 19:46:14 -03:00
parent 9d5413a522
commit 26f61077b8
13 changed files with 382 additions and 187 deletions

View File

@@ -18,7 +18,7 @@ spec:
image: git.ivanch.me/ivanch/recommender:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
- containerPort: 80
env:
- name: OPENAI_API_KEY
valueFrom:
@@ -48,7 +48,7 @@ spec:
app: recommender
ports:
- port: 80
targetPort: 8080
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress

12
package-lock.json generated
View File

@@ -3822,6 +3822,15 @@
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-router": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/preact-router/-/preact-router-4.1.2.tgz",
"integrity": "sha512-uICUaUFYh+XQ+6vZtQn1q+X6rSqwq+zorWOCLWPF5FAsQh3EJ+RsDQ9Ee+fjk545YWQHfUxhrBAaemfxEnMOUg==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -4466,7 +4475,8 @@
"packages/frontend": {
"version": "0.0.0",
"dependencies": {
"preact": "^10.29.0"
"preact": "^10.29.0",
"preact-router": "^4.1.2"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.4",

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';
dotenv.config();
dotenv.config({ path: ['.env.local', '.env'] });
export default defineConfig({
schema: './src/db/schema.ts',

View File

@@ -1,6 +1,6 @@
import OpenAI from 'openai';
import * as dotenv from 'dotenv';
dotenv.config();
dotenv.config({ path: ['.env.local', '.env'] });
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,

View File

@@ -2,7 +2,7 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as dotenv from 'dotenv';
dotenv.config();
dotenv.config({ path: ['.env.local', '.env'] });
const connectionString = process.env.DATABASE_URL || 'postgres://user:password@iris.haven:5432/recommender';
export const client = postgres(connectionString);

View File

@@ -3,10 +3,10 @@ import * as dotenv from 'dotenv';
import recommendationsRoute from './routes/recommendations.js';
import feedbackRoute from './routes/feedback.js';
// Load .env first, then .env.local
// env vars set on the container take precedence over both files
dotenv.config();
dotenv.config({ path: '.env.local', override: true });
// Load .env.local first, then .env.
// dotenv 17+ supports array of paths, where the first path has the highest priority.
// Env vars set on the container (system) will take precedence over both.
dotenv.config({ path: ['.env.local', '.env'] });
const fastify = Fastify({ logger: true });

View File

@@ -7,6 +7,13 @@ import { runRanking } from '../agents/ranking.js';
import { runCurator } from '../agents/curator.js';
import type { CuratorOutput, SSEEvent } from '../types/agents.js';
/* -- Agent pipeline --
[1] Interpreter -> gets user input, transforms into structured data
[2] Retrieval -> gets shows from OpenAI (high temperature)
[3] Ranking -> ranks shows based on user input
[4] Curator -> curates shows based on user input
*/
type RecommendationRecord = typeof recommendations.$inferSelect;
function log(recId: string, msg: string, data?: unknown) {

View File

@@ -9,7 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.29.0"
"preact": "^10.29.0",
"preact-router": "^4.1.2"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -1,5 +1,12 @@
import { Router, Route } from 'preact-router';
import { Home } from './pages/Home.js';
import { Recom } from './pages/Recom.js';
export function App() {
return <Home />;
return (
<Router>
<Route path="/" component={Home} />
<Route path="/recom/:id" component={Recom} />
</Router>
);
}

View File

@@ -1,4 +1,6 @@
*, *::before, *::after {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@@ -30,7 +32,9 @@
-webkit-font-smoothing: antialiased;
}
html, body, #app {
html,
body,
#app {
height: 100%;
width: 100%;
overflow: hidden;
@@ -144,10 +148,21 @@ html, body, #app {
flex-shrink: 0;
}
.status-done { color: var(--green); }
.status-error { color: var(--red); }
.status-running { color: var(--accent); }
.status-pending { color: var(--text-dim); }
.status-done {
color: var(--green);
}
.status-error {
color: var(--red);
}
.status-running {
color: var(--accent);
}
.status-pending {
color: var(--text-dim);
}
.sidebar-item-title {
overflow: hidden;
@@ -251,10 +266,21 @@ html, body, #app {
text-align: center;
}
.stage-done { color: var(--green); }
.stage-error { color: var(--red); }
.stage-running { color: var(--accent); }
.stage-pending { color: var(--text-dim); }
.stage-done {
color: var(--green);
}
.stage-error {
color: var(--red);
}
.stage-running {
color: var(--accent);
}
.stage-pending {
color: var(--text-dim);
}
.spinner {
display: inline-block;
@@ -262,8 +288,13 @@ html, body, #app {
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.pipeline-step-label {
@@ -309,10 +340,25 @@ html, body, #app {
flex-shrink: 0;
}
.badge-green { background: rgba(34,197,94,0.15); color: #4ade80; }
.badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; }
.badge-yellow{ background: rgba(234,179,8,0.15); color: #facc15; }
.badge-red { background: rgba(239,68,68,0.15); color: #f87171; }
.badge-green {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.badge-blue {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
.badge-yellow {
background: rgba(234, 179, 8, 0.15);
color: #facc15;
}
.badge-red {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.card-title {
font-size: 16px;
@@ -556,3 +602,124 @@ html, body, #app {
gap: 10px;
padding-top: 4px;
}
/* ── Landing page ───────────────────────────────────────── */
.landing-layout {
display: flex;
height: 100vh;
position: relative;
overflow: hidden;
}
.landing-bg {
position: absolute;
inset: -6px;
/* overflow slightly to hide blur edges */
background: url('/wallpaper.png') center / cover no-repeat;
filter: blur(3px) brightness(0.35);
z-index: 0;
}
.landing-layout .sidebar {
position: relative;
z-index: 1;
background: rgba(26, 29, 39, 0.85);
backdrop-filter: blur(8px);
}
.landing-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
text-align: center;
position: relative;
z-index: 1;
padding: 40px;
}
.landing-title {
font-size: 5rem;
font-weight: 800;
color: #fff;
letter-spacing: -2px;
line-height: 1;
}
.landing-tagline {
font-size: 1.4rem;
color: rgba(255, 255, 255, 0.6);
font-weight: 400;
}
.btn-gradient {
padding: 18px 52px;
font-size: 1.15rem;
font-weight: 700;
color: #fff;
border: none;
border-radius: 14px;
cursor: pointer;
background: linear-gradient(135deg, #e040fb, #7c4dff, #2979ff, #00b0ff, #e040fb);
background-size: 300% 300%;
animation: gradient-flow 5s ease infinite;
box-shadow: 0 4px 32px rgba(99, 102, 241, 0.4);
transition: transform 0.15s, box-shadow 0.15s;
letter-spacing: 0.02em;
}
.btn-gradient:hover {
transform: translateY(-2px);
box-shadow: 0 8px 40px rgba(99, 102, 241, 0.55);
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* ── Recom page ─────────────────────────────────────────── */
.recom-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.recom-nav {
flex-shrink: 0;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.recom-back {
color: var(--text-muted);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.15s;
}
.recom-back:hover {
color: var(--text);
}
.recom-content {
flex: 1;
overflow-y: auto;
padding: 32px;
}

View File

@@ -1,97 +1,14 @@
import { useState, useCallback, useEffect } from 'preact/hooks';
import { useState } from 'preact/hooks';
import { route } from 'preact-router';
import { Sidebar } from '../components/Sidebar.js';
import { NewRecommendationModal } from '../components/NewRecommendationModal.js';
import { PipelineProgress } from '../components/PipelineProgress.js';
import { RecommendationCard } from '../components/RecommendationCard.js';
import { useRecommendations } from '../hooks/useRecommendations.js';
import { useSSE } from '../hooks/useSSE.js';
import { getRecommendation } from '../api/client.js';
import type { Recommendation, SSEEvent, StageMap, PipelineStage } from '../types/index.js';
const DEFAULT_STAGES: StageMap = {
interpreter: 'pending',
retrieval: 'pending',
ranking: 'pending',
curator: 'pending',
};
const STAGE_ORDER: (keyof StageMap)[] = ['interpreter', 'retrieval', 'ranking', 'curator'];
export function Home() {
const {
list,
selectedId,
feedback,
setSelectedId,
createNew,
rerank,
submitFeedback,
updateStatus,
refreshList,
} = useRecommendations();
const { list, createNew } = useRecommendations();
const [showModal, setShowModal] = useState(false);
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
const [stages, setStages] = useState<StageMap>(DEFAULT_STAGES);
const [sseUrl, setSseUrl] = useState<string | null>(null);
// Load full recommendation when selected
useEffect(() => {
if (!selectedId) {
setSelectedRec(null);
return;
}
void getRecommendation(selectedId).then((rec) => {
setSelectedRec(rec);
// If already running or pending, open SSE
if (rec.status === 'running' || rec.status === 'pending') {
setStages(DEFAULT_STAGES);
setSseUrl(`/api/recommendations/${selectedId}/stream`);
}
});
}, [selectedId]);
const handleSSEEvent = useCallback(
(event: SSEEvent) => {
if (!selectedId) return;
if (event.stage !== 'complete') {
const stageKey = event.stage as keyof StageMap;
if (STAGE_ORDER.includes(stageKey)) {
setStages((prev) => ({
...prev,
[stageKey]: event.status === 'start' ? 'running' : event.status === 'done' ? 'done' : 'error',
}));
}
}
if (event.stage === 'complete' && event.status === 'done') {
setSseUrl(null);
updateStatus(selectedId, 'done');
// Reload full recommendation to get results
void getRecommendation(selectedId).then(setSelectedRec);
void refreshList();
}
if (event.status === 'error') {
setSseUrl(null);
updateStatus(selectedId, 'error');
const stageKey = event.stage as PipelineStage;
if (stageKey !== 'complete') {
setStages((prev) => ({ ...prev, [stageKey as keyof StageMap]: 'error' }));
}
}
},
[selectedId, updateStatus, refreshList],
);
useSSE(sseUrl, handleSSEEvent);
const handleSelect = (id: string) => {
setSseUrl(null);
setStages(DEFAULT_STAGES);
setSelectedId(id);
};
const handleSelect = (id: string) => route(`/recom/${id}`);
const handleCreateNew = async (body: {
main_prompt: string;
@@ -100,87 +17,25 @@ export function Home() {
themes: string;
}) => {
const id = await createNew(body);
setStages(DEFAULT_STAGES);
setSseUrl(`/api/recommendations/${id}/stream`);
route(`/recom/${id}`);
};
const handleRerank = async () => {
if (!selectedId) return;
await rerank(selectedId);
setStages(DEFAULT_STAGES);
setSseUrl(`/api/recommendations/${selectedId}/stream`);
setSelectedRec((prev) => (prev ? { ...prev, status: 'pending' } : null));
};
const isRunning =
selectedRec?.status === 'running' || selectedRec?.status === 'pending' || !!sseUrl;
const feedbackMap = new Map(feedback.map((f) => [f.tv_show_name, f]));
return (
<div class="layout">
<div class="landing-layout">
<div class="landing-bg" />
<Sidebar
list={list}
selectedId={selectedId}
selectedId={null}
onSelect={handleSelect}
onNewClick={() => setShowModal(true)}
/>
<main class="main-content">
{!selectedId && (
<div class="empty-state">
<h2>TV Show Recommender</h2>
<p>Click <strong>+ New Recommendation</strong> to get started.</p>
</div>
)}
{selectedId && isRunning && (
<div class="content-area">
<PipelineProgress stages={stages} />
</div>
)}
{selectedId && !isRunning && selectedRec?.status === 'done' && selectedRec.recommendations && (
<div class="content-area">
<h2 class="rec-title">{selectedRec.title}</h2>
<div class="cards-grid">
{selectedRec.recommendations.map((show) => (
<RecommendationCard
key={show.title}
show={show}
existingFeedback={feedbackMap.get(show.title)}
onFeedback={async (name, stars, comment) => {
await submitFeedback({ tv_show_name: name, stars, feedback: comment });
}}
/>
))}
</div>
<div class="rerank-section">
<button
class="btn-rerank"
onClick={handleRerank}
disabled={feedback.length === 0}
title={feedback.length === 0 ? 'Rate at least one show to enable re-ranking' : 'Re-rank based on your feedback'}
>
Re-rank with Feedback {feedback.length > 0 ? `(${feedback.length} rated)` : ''}
</button>
</div>
</div>
)}
{selectedId && !isRunning && selectedRec?.status === 'error' && (
<div class="content-area error-state">
<h2>Something went wrong</h2>
<p>The pipeline encountered an error. You can try again by clicking Re-rank.</p>
<button class="btn-primary" onClick={handleRerank}>
Try Again
</button>
</div>
)}
<main class="landing-main">
<h1 class="landing-title">Recommender</h1>
<p class="landing-tagline">Discover your next favorite show, powered by AI.</p>
<button class="btn-gradient" onClick={() => setShowModal(true)}>
Get Started
</button>
</main>
{showModal && (
<NewRecommendationModal
onClose={() => setShowModal(false)}

View File

@@ -0,0 +1,148 @@
import { useState, useCallback, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { PipelineProgress } from '../components/PipelineProgress.js';
import { RecommendationCard } from '../components/RecommendationCard.js';
import { useRecommendations } from '../hooks/useRecommendations.js';
import { useSSE } from '../hooks/useSSE.js';
import { getRecommendation } from '../api/client.js';
import type { Recommendation, SSEEvent, StageMap, PipelineStage } from '../types/index.js';
interface RecomProps {
id: string;
path?: string;
}
const DEFAULT_STAGES: StageMap = {
interpreter: 'pending',
retrieval: 'pending',
ranking: 'pending',
curator: 'pending',
};
const STAGE_ORDER: (keyof StageMap)[] = ['interpreter', 'retrieval', 'ranking', 'curator'];
export function Recom({ id }: RecomProps) {
const { feedback, submitFeedback, rerank, updateStatus, refreshList } = useRecommendations();
const [rec, setRec] = useState<Recommendation | null>(null);
const [stages, setStages] = useState<StageMap>(DEFAULT_STAGES);
const [sseUrl, setSseUrl] = useState<string | null>(null);
useEffect(() => {
setRec(null);
setStages(DEFAULT_STAGES);
setSseUrl(null);
getRecommendation(id)
.then((data) => {
setRec(data);
if (data.status === 'running' || data.status === 'pending') {
setStages(DEFAULT_STAGES);
setSseUrl(`/api/recommendations/${id}/stream`);
}
})
.catch(() => route('/'));
}, [id]);
const handleSSEEvent = useCallback(
(event: SSEEvent) => {
if (event.stage !== 'complete') {
const stageKey = event.stage as keyof StageMap;
if (STAGE_ORDER.includes(stageKey)) {
setStages((prev) => ({
...prev,
[stageKey]: event.status === 'start' ? 'running' : event.status === 'done' ? 'done' : 'error',
}));
}
}
if (event.stage === 'complete' && event.status === 'done') {
setSseUrl(null);
updateStatus(id, 'done');
void getRecommendation(id).then(setRec);
void refreshList();
}
if (event.status === 'error') {
setSseUrl(null);
updateStatus(id, 'error');
const stageKey = event.stage as PipelineStage;
if (stageKey !== 'complete') {
setStages((prev) => ({ ...prev, [stageKey as keyof StageMap]: 'error' }));
}
}
},
[id, updateStatus, refreshList],
);
useSSE(sseUrl, handleSSEEvent);
const handleRerank = async () => {
await rerank(id);
setStages(DEFAULT_STAGES);
setSseUrl(`/api/recommendations/${id}/stream`);
setRec((prev) => (prev ? { ...prev, status: 'pending' } : null));
};
const isRunning = rec?.status === 'running' || rec?.status === 'pending' || !!sseUrl;
const feedbackMap = new Map(feedback.map((f) => [f.tv_show_name, f]));
return (
<div class="recom-page">
<nav class="recom-nav">
<a
class="recom-back"
href="/"
onClick={(e) => { e.preventDefault(); route('/'); }}
>
Recommender
</a>
</nav>
<div class="recom-content">
{isRunning && (
<div class="content-area">
<PipelineProgress stages={stages} />
</div>
)}
{!isRunning && rec?.status === 'done' && rec.recommendations && (
<div class="content-area">
<h2 class="rec-title">{rec.title}</h2>
<div class="cards-grid">
{rec.recommendations.map((show) => (
<RecommendationCard
key={show.title}
show={show}
existingFeedback={feedbackMap.get(show.title)}
onFeedback={async (name, stars, comment) => {
await submitFeedback({ tv_show_name: name, stars, feedback: comment });
}}
/>
))}
</div>
<div class="rerank-section">
<button
class="btn-rerank"
onClick={handleRerank}
disabled={feedback.length === 0}
title={feedback.length === 0 ? 'Rate at least one show to enable re-ranking' : 'Re-rank based on your feedback'}
>
Re-rank with Feedback {feedback.length > 0 ? `(${feedback.length} rated)` : ''}
</button>
</div>
</div>
)}
{!isRunning && rec?.status === 'error' && (
<div class="content-area error-state">
<h2>Something went wrong</h2>
<p>The pipeline encountered an error. You can try again by clicking Re-rank.</p>
<button class="btn-primary" onClick={handleRerank}>
Try Again
</button>
</div>
)}
</div>
</div>
);
}