From abfbc1e2daa7981e18d4956718809f6dac240b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 20:35:35 +0200 Subject: [PATCH] Add implementation plan for TypeScript/OOP rewrite --- .../2026-08-12-typescript-oop-rewrite.md | 3963 +++++++++++++++++ 1 file changed, 3963 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md diff --git a/docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md b/docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md new file mode 100644 index 0000000..75e8e3c --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md @@ -0,0 +1,3963 @@ +# Sugarr TypeScript/OOP Rewrite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a parallel Node/TypeScript implementation of Sugarr's movie-recommendation pipeline, with a reusable OOP "core" (ApiClient/Endpoint, LlmTool/SchemaLlmTool/TextLlmTool, LlmPipeline, Langfuse-cached prompts) and an "app" layer wiring Sugarr-specific clients/tools/pipelines/controllers, reaching functional parity with the existing Go app's HTTP API and cron pipeline. + +**Architecture:** `src/core/*` holds framework-agnostic, reusable classes (HTTP client base, LLM tool/pipeline base classes, Langfuse prompt caching, Fastify server scaffolding). `src/app/*` holds Sugarr-specific concrete classes (Jellyfin/TMDb/Radarr clients, the movie-recommender and movie-search LLM tools, the recommendation pipeline, Fastify controllers). The Go app in `cmd/`/`internal/` is untouched. + +**Tech Stack:** Node.js, TypeScript (CommonJS output), pnpm, Fastify, Zod, Pino, the `openai` SDK (against an OpenAI-compatible `LLM_BASE_URL`), the `langfuse` SDK, `node-cron`, Vitest. + +## Global Constraints + +- All new code lives under `src/` and `test/` at the repo root; never modify `cmd/` or `internal/` (the Go app). +- TypeScript compiles with `strict: true`; no implicit `any`. +- Every core class is framework-agnostic — nothing under `src/core/` may import from `src/app/`. +- HTTP routes and behavior must match the Go app exactly: `GET /health`, `GET /recommendations`, `POST /recommendations/search`, `POST /recommendations/trigger`, `POST /recommendations/request`, plus the new `POST /webhooks/langfuse/invalidate`. +- Bearer-token auth via `SECRET_KEY`/`API_SECRET_KEY` env var, `/health` always exempt, matching `httputil.AuthMiddleware`. +- Env var names are unchanged from the Go app (see `internal/utl/configs/config.go`) with four additions: `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL`, `LANGFUSE_PROMPT_CACHE_ENABLED`. +- Package manager is `pnpm`; test runner is Vitest (`test/**/*.test.ts`). + +--- + +## Task 1: Project scaffolding + core logging + +**Files:** +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `vitest.config.ts` +- Create: `.eslintrc.cjs` +- Create: `.prettierrc.json` +- Modify: `.gitignore` (add `node_modules/`, `dist/`, `coverage/`) +- Create: `src/core/logging/logger.ts` +- Test: `test/core/logging/logger.test.ts` + +**Interfaces:** +- Produces: `createLogger(name: string): Logger` and `type Logger` (re-exported `pino.Logger`) from `src/core/logging/logger.ts` — used by every later core/app module that logs. + +- [ ] **Step 1: Create `package.json`** + +```json +{ + "name": "sugarr", + "version": "0.1.0", + "private": true, + "main": "dist/app/index.js", + "scripts": { + "dev": "tsx watch src/app/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/app/index.js", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src test --ext .ts", + "format": "prettier --write ." + }, + "dependencies": { + "dotenv": "^16.4.5", + "fastify": "^5.0.0", + "langfuse": "^3.30.0", + "node-cron": "^3.0.3", + "openai": "^4.60.0", + "pino": "^9.4.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.7.4", + "@types/node-cron": "^3.0.11", + "@typescript-eslint/eslint-plugin": "^8.8.0", + "@typescript-eslint/parser": "^8.8.0", + "eslint": "^8.57.0", + "pino-pretty": "^11.2.2", + "prettier": "^3.3.3", + "tsx": "^4.19.1", + "typescript": "^5.6.2", + "vitest": "^2.1.1" + } +} +``` + +- [ ] **Step 2: Create `tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `vitest.config.ts`** + +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +}); +``` + +- [ ] **Step 4: Create `.eslintrc.cjs`** + +```js +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + env: { node: true, es2022: true }, + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, + ignorePatterns: ['dist', 'node_modules'], +}; +``` + +- [ ] **Step 5: Create `.prettierrc.json`** + +```json +{ + "singleQuote": true, + "semi": true, + "printWidth": 100, + "trailingComma": "es5" +} +``` + +- [ ] **Step 6: Add build artifacts to `.gitignore`** + +Append to the existing `.gitignore`: + +``` +node_modules/ +dist/ +coverage/ +``` + +- [ ] **Step 7: Install dependencies** + +Run: `pnpm install` +Expected: lockfile `pnpm-lock.yaml` created, no errors. + +- [ ] **Step 8: Write the failing test for the logger factory** + +Create `test/core/logging/logger.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { createLogger } from '../../../src/core/logging/logger'; + +describe('createLogger', () => { + it('creates a pino logger with the given name bound', () => { + const logger = createLogger('test-logger'); + expect(logger.bindings().name).toBe('test-logger'); + expect(typeof logger.info).toBe('function'); + expect(typeof logger.error).toBe('function'); + }); +}); +``` + +- [ ] **Step 9: Run test to verify it fails** + +Run: `pnpm test -- test/core/logging/logger.test.ts` +Expected: FAIL — cannot find module `src/core/logging/logger`. + +- [ ] **Step 10: Implement `src/core/logging/logger.ts`** + +```ts +import pino from 'pino'; + +export type Logger = pino.Logger; + +export function createLogger(name: string): Logger { + const isProduction = process.env.NODE_ENV === 'production'; + return pino({ + name, + level: process.env.LOG_LEVEL ?? 'info', + transport: isProduction ? undefined : { target: 'pino-pretty' }, + }); +} +``` + +- [ ] **Step 11: Run test to verify it passes** + +Run: `pnpm test -- test/core/logging/logger.test.ts` +Expected: PASS + +- [ ] **Step 12: Commit** + +```bash +git add package.json tsconfig.json vitest.config.ts .eslintrc.cjs .prettierrc.json .gitignore src/core/logging/logger.ts test/core/logging/logger.test.ts pnpm-lock.yaml +git commit -m "Scaffold TypeScript project and add core logger" +``` + +--- + +## Task 2: Core config — env loader + +**Files:** +- Create: `src/core/config/env.ts` +- Test: `test/core/config/env.test.ts` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `loadEnv(schema: ZodSchema, source?: NodeJS.ProcessEnv): T` from `src/core/config/env.ts` — used by `src/app/config/AppConfig.ts` (Task 11). + +- [ ] **Step 1: Write the failing test** + +Create `test/core/config/env.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { loadEnv } from '../../../src/core/config/env'; + +describe('loadEnv', () => { + const schema = z.object({ + FOO: z.string(), + COUNT: z.coerce.number().default(5), + }); + + it('parses matching env vars into a typed object', () => { + const result = loadEnv(schema, { FOO: 'bar', COUNT: '10' }); + expect(result).toEqual({ FOO: 'bar', COUNT: 10 }); + }); + + it('throws a descriptive error when required vars are missing', () => { + expect(() => loadEnv(schema, {})).toThrow(/Invalid environment configuration/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/core/config/env.test.ts` +Expected: FAIL — cannot find module `src/core/config/env`. + +- [ ] **Step 3: Implement `src/core/config/env.ts`** + +```ts +import type { ZodSchema } from 'zod'; + +export function loadEnv(schema: ZodSchema, source: NodeJS.ProcessEnv = process.env): T { + const result = schema.safeParse(source); + if (!result.success) { + throw new Error(`Invalid environment configuration: ${result.error.message}`); + } + return result.data; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/core/config/env.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/core/config/env.ts test/core/config/env.test.ts +git commit -m "Add core env loader" +``` + +--- + +## Task 3: Core HTTP — `Endpoint` and `ApiClient` + +**Files:** +- Create: `src/core/http/Endpoint.ts` +- Create: `src/core/http/ApiClient.ts` +- Test: `test/core/http/ApiClient.test.ts` + +**Interfaces:** +- Produces: `abstract class Endpoint` with `method: HttpMethod`, `acceptableStatuses: number[]`, `path(params)`, `query(params)`, `body(params)`, `parseResponse(json)`; `abstract class ApiClient` with `protected request(endpoint, params): Promise` and overridable `protected buildHeaders(): Record`; `class ApiError extends Error` with `status`, `body`. All from `src/core/http/`. Used by every app client (Tasks 12-14). + +- [ ] **Step 1: Write `src/core/http/Endpoint.ts`** (no test needed — pure type/abstract declarations, exercised via `ApiClient` tests) + +```ts +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export abstract class Endpoint { + abstract readonly method: HttpMethod; + readonly acceptableStatuses: number[] = []; + + abstract path(params: TParams): string; + + query(_params: TParams): Record | undefined { + return undefined; + } + + body(_params: TParams): unknown { + return undefined; + } + + abstract parseResponse(json: unknown): TResponse; +} +``` + +- [ ] **Step 2: Write the failing test for `ApiClient`** + +Create `test/core/http/ApiClient.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient, ApiError } from '../../../src/core/http/ApiClient'; +import { Endpoint, HttpMethod } from '../../../src/core/http/Endpoint'; + +interface GetParams { + id: number; +} + +class GetThingEndpoint extends Endpoint<{ name: string }, GetParams> { + readonly method: HttpMethod = 'GET'; + path(params: GetParams): string { + return `/things/${params.id}`; + } + query(): Record { + return { verbose: 'true' }; + } + parseResponse(json: unknown): { name: string } { + return json as { name: string }; + } +} + +class PostThingEndpoint extends Endpoint { + readonly method: HttpMethod = 'POST'; + readonly acceptableStatuses = [400]; + path(): string { + return '/things'; + } + body(params: { name: string }): unknown { + return params; + } + parseResponse(): void { + return undefined; + } +} + +class TestClient extends ApiClient { + constructor(baseUrl: string) { + super(baseUrl); + } + protected buildHeaders(): Record { + return { ...super.buildHeaders(), 'X-Test': '1' }; + } + getThing(id: number) { + return this.request(new GetThingEndpoint(), { id }); + } + postThing(name: string) { + return this.request(new PostThingEndpoint(), { name }); + } +} + +describe('ApiClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('builds the URL with query params and headers, and parses the response', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ name: 'widget' }), + }); + + const client = new TestClient('https://api.example.com'); + const result = await client.getThing(42); + + expect(result).toEqual({ name: 'widget' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.example.com/things/42?verbose=true'); + expect(init.method).toBe('GET'); + expect(init.headers['X-Test']).toBe('1'); + }); + + it('sends a JSON body and Content-Type header for POST', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' }); + + const client = new TestClient('https://api.example.com'); + await client.postThing('widget'); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.body).toBe(JSON.stringify({ name: 'widget' })); + expect(init.headers['Content-Type']).toBe('application/json'); + }); + + it('treats an endpoint-declared acceptable status as success', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => '' }); + + const client = new TestClient('https://api.example.com'); + await expect(client.postThing('widget')).resolves.toBeUndefined(); + }); + + it('throws ApiError for a non-ok, non-acceptable status', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }); + + const client = new TestClient('https://api.example.com'); + await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm test -- test/core/http/ApiClient.test.ts` +Expected: FAIL — cannot find module `src/core/http/ApiClient`. + +- [ ] **Step 4: Implement `src/core/http/ApiClient.ts`** + +```ts +import { Endpoint } from './Endpoint'; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: string + ) { + super(message); + this.name = 'ApiError'; + } +} + +export abstract class ApiClient { + protected constructor(protected readonly baseUrl: string) {} + + protected buildHeaders(): Record { + return { Accept: 'application/json' }; + } + + protected async request( + endpoint: Endpoint, + params: TParams + ): Promise { + const query = endpoint.query(params); + const qs = query ? `?${new URLSearchParams(query).toString()}` : ''; + const url = `${this.baseUrl}${endpoint.path(params)}${qs}`; + const body = endpoint.body(params); + const headers = this.buildHeaders(); + + const init: RequestInit = { method: endpoint.method, headers }; + if (body !== undefined) { + init.body = JSON.stringify(body); + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(url, init); + const text = await res.text(); + const ok = res.ok || endpoint.acceptableStatuses.includes(res.status); + if (!ok) { + throw new ApiError(`${endpoint.method} ${url} returned ${res.status}`, res.status, text); + } + + const json = text.length > 0 ? JSON.parse(text) : undefined; + return endpoint.parseResponse(json); + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm test -- test/core/http/ApiClient.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/core/http/Endpoint.ts src/core/http/ApiClient.ts test/core/http/ApiClient.test.ts +git commit -m "Add core Endpoint/ApiClient base classes" +``` + +--- + +## Task 4: Core Langfuse — prompt types and cached `PromptService` + +**Files:** +- Create: `src/core/langfuse/types.ts` +- Create: `src/core/langfuse/PromptGateway.ts` +- Create: `src/core/langfuse/PromptService.ts` +- Test: `test/core/langfuse/PromptService.test.ts` + +**Interfaces:** +- Produces: `interface CompiledPrompt { name, version, config, messages }` (`types.ts`); `interface RawPrompt { name, version, config, compile(variables) }` and `interface PromptFetcher { fetch(promptName): Promise }` (`PromptGateway.ts`); `class PromptService { constructor(fetcher: PromptFetcher, cacheEnabled: boolean); getCompiled(promptName, variables): Promise; invalidate(promptName?: string): void }` (`PromptService.ts`). `PromptService` is used by `LlmTool` (Task 6) and `WebhookController` (Task 17); `PromptFetcher` is implemented by `LangfusePromptGateway` (Task 10). + +- [ ] **Step 1: Create `src/core/langfuse/types.ts`** + +```ts +export interface CompiledPrompt { + name: string; + version: number; + config: Record; + messages: { role: 'system' | 'user' | 'assistant'; content: string }[]; +} +``` + +- [ ] **Step 2: Create `src/core/langfuse/PromptGateway.ts`** + +```ts +export interface RawPrompt { + name: string; + version: number; + config: Record; + compile(variables: Record): { role: 'system' | 'user' | 'assistant'; content: string }[]; +} + +export interface PromptFetcher { + fetch(promptName: string): Promise; +} +``` + +- [ ] **Step 3: Write the failing test for `PromptService`** + +Create `test/core/langfuse/PromptService.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { PromptService } from '../../../src/core/langfuse/PromptService'; +import type { PromptFetcher, RawPrompt } from '../../../src/core/langfuse/PromptGateway'; + +function makeRawPrompt(name: string, version = 1): RawPrompt { + return { + name, + version, + config: { model: 'gpt-4o-mini' }, + compile: (variables) => [{ role: 'user', content: `hello ${String(variables.who)}` }], + }; +} + +describe('PromptService', () => { + it('fetches once and reuses the cache on subsequent calls when caching is enabled', async () => { + const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet')); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, true); + + await service.getCompiled('greet', { who: 'a' }); + const compiled = await service.getCompiled('greet', { who: 'b' }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(compiled.messages).toEqual([{ role: 'user', content: 'hello b' }]); + expect(compiled.config).toEqual({ model: 'gpt-4o-mini' }); + }); + + it('always refetches when caching is disabled', async () => { + const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet')); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, false); + + await service.getCompiled('greet', { who: 'a' }); + await service.getCompiled('greet', { who: 'b' }); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('invalidate(name) forces a refetch for that name only', async () => { + const fetch = vi.fn().mockResolvedValue(makeRawPrompt('greet')); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, true); + + await service.getCompiled('greet', {}); + service.invalidate('greet'); + await service.getCompiled('greet', {}); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('invalidate() with no name clears the whole cache', async () => { + const fetch = vi.fn().mockImplementation((name: string) => Promise.resolve(makeRawPrompt(name))); + const fetcher: PromptFetcher = { fetch }; + const service = new PromptService(fetcher, true); + + await service.getCompiled('a', {}); + await service.getCompiled('b', {}); + service.invalidate(); + await service.getCompiled('a', {}); + await service.getCompiled('b', {}); + + expect(fetch).toHaveBeenCalledTimes(4); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `pnpm test -- test/core/langfuse/PromptService.test.ts` +Expected: FAIL — cannot find module `src/core/langfuse/PromptService`. + +- [ ] **Step 5: Implement `src/core/langfuse/PromptService.ts`** + +```ts +import type { PromptFetcher, RawPrompt } from './PromptGateway'; +import type { CompiledPrompt } from './types'; + +interface CacheEntry { + raw: RawPrompt; + fetchedAt: number; +} + +export class PromptService { + private readonly cache = new Map(); + + constructor( + private readonly fetcher: PromptFetcher, + private readonly cacheEnabled: boolean + ) {} + + async getCompiled(promptName: string, variables: Record): Promise { + const raw = await this.getRaw(promptName); + return { + name: raw.name, + version: raw.version, + config: raw.config, + messages: raw.compile(variables), + }; + } + + invalidate(promptName?: string): void { + if (promptName) { + this.cache.delete(promptName); + } else { + this.cache.clear(); + } + } + + private async getRaw(promptName: string): Promise { + if (this.cacheEnabled) { + const cached = this.cache.get(promptName); + if (cached) return cached.raw; + } + const raw = await this.fetcher.fetch(promptName); + if (this.cacheEnabled) { + this.cache.set(promptName, { raw, fetchedAt: Date.now() }); + } + return raw; + } +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `pnpm test -- test/core/langfuse/PromptService.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/core/langfuse/types.ts src/core/langfuse/PromptGateway.ts src/core/langfuse/PromptService.ts test/core/langfuse/PromptService.test.ts +git commit -m "Add cached Langfuse PromptService" +``` + +--- + +## Task 5: Core LLM — `LlmClient` (OpenAI-compatible wrapper) + +**Files:** +- Create: `src/core/llm/LlmClient.ts` +- Test: `test/core/llm/LlmClient.test.ts` + +**Interfaces:** +- Produces: `interface ChatMessage { role, content }`, `interface ChatOptions { model, temperature?, maxTokens? }`, `interface ChatResult { content, usage? }`, `class LlmClient { constructor(opts: { baseUrl, apiKey? }); chat(messages, options): Promise }` from `src/core/llm/LlmClient.ts`. Used by `LlmTool` (Task 6). + +- [ ] **Step 1: Write the failing test** + +Create `test/core/llm/LlmClient.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; + +const createMock = vi.fn(); + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation(() => ({ + chat: { completions: { create: createMock } }, + })), +})); + +import { LlmClient } from '../../../src/core/llm/LlmClient'; + +describe('LlmClient', () => { + it('sends messages/options to the OpenAI-compatible endpoint and returns content + usage', async () => { + createMock.mockResolvedValue({ + choices: [{ message: { content: 'hello' } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + + const client = new LlmClient({ baseUrl: 'http://localhost:11434/v1', apiKey: 'key' }); + const result = await client.chat([{ role: 'user', content: 'hi' }], { model: 'llama3', temperature: 0.7 }); + + expect(result).toEqual({ + content: 'hello', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + }); + expect(createMock).toHaveBeenCalledWith({ + model: 'llama3', + temperature: 0.7, + max_tokens: undefined, + messages: [{ role: 'user', content: 'hi' }], + }); + }); + + it('throws when the response has no choices', async () => { + createMock.mockResolvedValue({ choices: [] }); + + const client = new LlmClient({ baseUrl: 'http://localhost:11434/v1' }); + await expect(client.chat([{ role: 'user', content: 'hi' }], { model: 'llama3' })).rejects.toThrow( + /no choices/ + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/core/llm/LlmClient.test.ts` +Expected: FAIL — cannot find module `src/core/llm/LlmClient`. + +- [ ] **Step 3: Implement `src/core/llm/LlmClient.ts`** + +```ts +import OpenAI from 'openai'; + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface ChatOptions { + model: string; + temperature?: number; + maxTokens?: number; +} + +export interface ChatResult { + content: string; + usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }; +} + +export class LlmClient { + private readonly client: OpenAI; + + constructor(opts: { baseUrl: string; apiKey?: string }) { + this.client = new OpenAI({ baseURL: opts.baseUrl, apiKey: opts.apiKey || 'not-needed' }); + } + + async chat(messages: ChatMessage[], options: ChatOptions): Promise { + const response = await this.client.chat.completions.create({ + model: options.model, + temperature: options.temperature, + max_tokens: options.maxTokens, + messages, + }); + + const choice = response.choices[0]; + if (!choice) { + throw new Error('LLM returned no choices'); + } + + return { + content: choice.message?.content ?? '', + usage: response.usage + ? { + promptTokens: response.usage.prompt_tokens, + completionTokens: response.usage.completion_tokens, + totalTokens: response.usage.total_tokens, + } + : undefined, + }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/core/llm/LlmClient.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/core/llm/LlmClient.ts test/core/llm/LlmClient.test.ts +git commit -m "Add core LlmClient wrapping OpenAI-compatible chat completions" +``` + +--- + +## Task 6: Core LLM — `Tracer`/`RunContext` and `LlmTool` base class + +**Files:** +- Create: `src/core/llm/Tracer.ts` +- Create: `src/core/llm/RunContext.ts` +- Create: `src/core/llm/LlmTool.ts` +- Test: `test/core/llm/LlmTool.test.ts` + +**Interfaces:** +- Produces: `interface GenerationInput { name, model, input, metadata? }`, `interface GenerationOutput { output, usage?, metadata? }`, `interface GenerationHandle { end(output) }`, `interface TraceHandle { id, startGeneration(input): GenerationHandle }`, `abstract class Tracer { startTrace(name, metadata?): TraceHandle; flush(): Promise }` (`Tracer.ts`); `interface RunContext { trace: TraceHandle; logger: Logger }` (`RunContext.ts`); `abstract class LlmTool` with `protected abstract promptName`, `protected abstract buildVariables(input, ctx)`, `protected preProcess(input, ctx)`, `protected postProcess(output, input, ctx)`, `protected abstract parseOutput(raw, ctx)`, `async runAsync(input, ctx): Promise` (`LlmTool.ts`). Consumed by `SchemaLlmTool`/`TextLlmTool` (Task 7), `LlmPipeline` (Task 8), and both app tools (Task 15). `Tracer`/`TraceHandle` implemented concretely by `LangfuseTracer` (Task 10) and consumed by `LlmPipeline` (Task 8). + +- [ ] **Step 1: Create `src/core/llm/Tracer.ts`** + +```ts +export interface GenerationInput { + name: string; + model: string; + input: unknown; + metadata?: Record; +} + +export interface GenerationOutput { + output: unknown; + usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }; + metadata?: Record; +} + +export interface GenerationHandle { + end(output: GenerationOutput): void; +} + +export interface TraceHandle { + readonly id: string; + startGeneration(input: GenerationInput): GenerationHandle; +} + +export abstract class Tracer { + abstract startTrace(name: string, metadata?: Record): TraceHandle; + abstract flush(): Promise; +} +``` + +- [ ] **Step 2: Create `src/core/llm/RunContext.ts`** + +```ts +import type { Logger } from '../logging/logger'; +import type { TraceHandle } from './Tracer'; + +export interface RunContext { + trace: TraceHandle; + logger: Logger; +} +``` + +- [ ] **Step 3: Write the failing test for `LlmTool`** + +Create `test/core/llm/LlmTool.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { LlmTool } from '../../../src/core/llm/LlmTool'; +import type { RunContext } from '../../../src/core/llm/RunContext'; +import type { PromptService } from '../../../src/core/langfuse/PromptService'; +import type { LlmClient } from '../../../src/core/llm/LlmClient'; + +interface TestInput { + who: string; +} + +class GreetTool extends LlmTool { + protected readonly promptName = 'greet'; + + protected buildVariables(input: TestInput): Record { + return { who: input.who }; + } + + protected parseOutput(raw: string): string { + return raw.toUpperCase(); + } +} + +function makeCtx() { + const generationEnd = vi.fn(); + const startGeneration = vi.fn().mockReturnValue({ end: generationEnd }); + const trace = { id: 'trace-1', startGeneration }; + const logger = { info: vi.fn(), error: vi.fn() } as unknown as RunContext['logger']; + return { ctx: { trace, logger } as RunContext, startGeneration, generationEnd }; +} + +describe('LlmTool.runAsync', () => { + it('fetches the compiled prompt, calls the LLM, and parses the output', async () => { + const getCompiled = vi.fn().mockResolvedValue({ + name: 'greet', + version: 1, + config: { model: 'llama3', temperature: 0.5 }, + messages: [{ role: 'user', content: 'hi alice' }], + }); + const promptService = { getCompiled } as unknown as PromptService; + + const chat = vi.fn().mockResolvedValue({ content: 'hello alice', usage: { totalTokens: 3 } }); + const llmClient = { chat } as unknown as LlmClient; + + const { ctx, startGeneration, generationEnd } = makeCtx(); + const tool = new GreetTool(promptService, llmClient); + + const result = await tool.runAsync({ who: 'alice' }, ctx); + + expect(result).toBe('HELLO ALICE'); + expect(getCompiled).toHaveBeenCalledWith('greet', { who: 'alice' }); + expect(chat).toHaveBeenCalledWith( + [{ role: 'user', content: 'hi alice' }], + { model: 'llama3', temperature: 0.5, maxTokens: undefined } + ); + expect(startGeneration).toHaveBeenCalledWith( + expect.objectContaining({ name: 'greet', model: 'llama3' }) + ); + expect(generationEnd).toHaveBeenCalledWith( + expect.objectContaining({ output: 'hello alice' }) + ); + }); + + it('ends the generation with error metadata and rethrows when the LLM call fails', async () => { + const promptService = { + getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }), + } as unknown as PromptService; + const llmClient = { chat: vi.fn().mockRejectedValue(new Error('boom')) } as unknown as LlmClient; + + const { ctx, generationEnd } = makeCtx(); + const tool = new GreetTool(promptService, llmClient); + + await expect(tool.runAsync({ who: 'alice' }, ctx)).rejects.toThrow('boom'); + expect(generationEnd).toHaveBeenCalledWith( + expect.objectContaining({ output: null }) + ); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `pnpm test -- test/core/llm/LlmTool.test.ts` +Expected: FAIL — cannot find module `src/core/llm/LlmTool`. + +- [ ] **Step 5: Implement `src/core/llm/LlmTool.ts`** + +```ts +import type { PromptService } from '../langfuse/PromptService'; +import type { ChatMessage, LlmClient } from './LlmClient'; +import type { RunContext } from './RunContext'; + +export abstract class LlmTool { + protected abstract readonly promptName: string; + + constructor( + protected readonly promptService: PromptService, + protected readonly llmClient: LlmClient + ) {} + + protected abstract buildVariables(input: TInput, ctx: RunContext): Record; + + protected async preProcess(input: TInput, _ctx: RunContext): Promise { + return input; + } + + protected async postProcess(output: TOutput, _input: TInput, _ctx: RunContext): Promise { + return output; + } + + protected abstract parseOutput(raw: string, ctx: RunContext): TOutput; + + async runAsync(input: TInput, ctx: RunContext): Promise { + const processedInput = await this.preProcess(input, ctx); + const variables = this.buildVariables(processedInput, ctx); + const compiled = await this.promptService.getCompiled(this.promptName, variables); + + const model = String(compiled.config.model ?? 'gpt-4o-mini'); + const temperature = + typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined; + const maxTokens = + typeof compiled.config.maxTokens === 'number' ? compiled.config.maxTokens : undefined; + + const messages: ChatMessage[] = compiled.messages; + const generation = ctx.trace.startGeneration({ + name: this.promptName, + model, + input: messages, + metadata: { promptVersion: compiled.version }, + }); + + let raw: string; + try { + const result = await this.llmClient.chat(messages, { model, temperature, maxTokens }); + raw = result.content; + generation.end({ output: raw, usage: result.usage }); + } catch (err) { + generation.end({ output: null, metadata: { error: String(err) } }); + throw err; + } + + const parsed = this.parseOutput(raw, ctx); + const output = await this.postProcess(parsed, processedInput, ctx); + ctx.logger.info({ tool: this.promptName }, 'llm tool completed'); + return output; + } +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `pnpm test -- test/core/llm/LlmTool.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/core/llm/Tracer.ts src/core/llm/RunContext.ts src/core/llm/LlmTool.ts test/core/llm/LlmTool.test.ts +git commit -m "Add core Tracer/RunContext types and LlmTool base class" +``` + +--- + +## Task 7: Core LLM — `SchemaLlmTool` and `TextLlmTool` + +**Files:** +- Create: `src/core/llm/SchemaLlmTool.ts` +- Create: `src/core/llm/TextLlmTool.ts` +- Test: `test/core/llm/SchemaLlmTool.test.ts` +- Test: `test/core/llm/TextLlmTool.test.ts` + +**Interfaces:** +- Consumes: `LlmTool`, `RunContext` (Task 6). +- Produces: `class LlmOutputParseError extends Error { raw }`, `function extractJson(content: string): unknown`, `abstract class SchemaLlmTool extends LlmTool` with `protected abstract outputSchema: ZodSchema` (`SchemaLlmTool.ts`); `abstract class TextLlmTool extends LlmTool` (`TextLlmTool.ts`). Used by `MovieRecommenderTool`/`MovieSearchTool` (Task 15) and `WebhookController` does not use `extractJson` directly but relies on the same parsing contract. + +- [ ] **Step 1: Write the failing test for `SchemaLlmTool`** + +Create `test/core/llm/SchemaLlmTool.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { extractJson, LlmOutputParseError, SchemaLlmTool } from '../../../src/core/llm/SchemaLlmTool'; +import type { RunContext } from '../../../src/core/llm/RunContext'; + +describe('extractJson', () => { + it('parses a bare JSON array', () => { + expect(extractJson('[{"a":1}]')).toEqual([{ a: 1 }]); + }); + + it('strips a ```json fenced block with surrounding prose', () => { + const content = 'Sure, here you go:\n```json\n[{"a":1}]\n```\nHope that helps!'; + expect(extractJson(content)).toEqual([{ a: 1 }]); + }); + + it('strips a plain ``` fenced block', () => { + const content = '```\n{"a":1}\n```'; + expect(extractJson(content)).toEqual({ a: 1 }); + }); + + it('throws LlmOutputParseError when no JSON is present', () => { + expect(() => extractJson('no json here')).toThrow(LlmOutputParseError); + }); +}); + +const outputSchema = z.array(z.object({ title: z.string(), year: z.number() })); + +class ListTool extends SchemaLlmTool, z.infer> { + protected readonly promptName = 'list'; + protected readonly outputSchema = outputSchema; + protected buildVariables(): Record { + return {}; + } +} + +function makeTool() { + return new ListTool( + {} as unknown as ConstructorParameters[0], + {} as unknown as ConstructorParameters[1] + ); +} + +describe('SchemaLlmTool.parseOutput (via subclass)', () => { + it('validates parsed JSON against the schema', () => { + const tool = makeTool() as unknown as { parseOutput(raw: string, ctx: RunContext): unknown }; + const result = tool.parseOutput('[{"title":"Dune","year":2021}]', {} as RunContext); + expect(result).toEqual([{ title: 'Dune', year: 2021 }]); + }); + + it('throws LlmOutputParseError when schema validation fails', () => { + const tool = makeTool() as unknown as { parseOutput(raw: string, ctx: RunContext): unknown }; + expect(() => tool.parseOutput('[{"title":"Dune"}]', {} as RunContext)).toThrow(LlmOutputParseError); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/core/llm/SchemaLlmTool.test.ts` +Expected: FAIL — cannot find module `src/core/llm/SchemaLlmTool`. + +- [ ] **Step 3: Implement `src/core/llm/SchemaLlmTool.ts`** + +```ts +import type { ZodSchema } from 'zod'; +import { LlmTool } from './LlmTool'; +import type { RunContext } from './RunContext'; + +export class LlmOutputParseError extends Error { + constructor( + message: string, + readonly raw: string + ) { + super(message); + this.name = 'LlmOutputParseError'; + } +} + +export abstract class SchemaLlmTool extends LlmTool { + protected abstract readonly outputSchema: ZodSchema; + + protected parseOutput(raw: string, _ctx: RunContext): TOutput { + const json = extractJson(raw); + const result = this.outputSchema.safeParse(json); + if (!result.success) { + throw new LlmOutputParseError(`Output failed schema validation: ${result.error.message}`, raw); + } + return result.data; + } +} + +export function extractJson(content: string): unknown { + let text = content.trim(); + + const fenceStart = text.indexOf('```'); + if (fenceStart !== -1) { + text = text.slice(fenceStart); + text = text.replace(/^```json/, '').replace(/^```/, ''); + const fenceEnd = text.indexOf('```'); + if (fenceEnd !== -1) { + text = text.slice(0, fenceEnd); + } + text = text.trim(); + } + + const arrayStart = text.indexOf('['); + const objectStart = text.indexOf('{'); + const starts = [arrayStart, objectStart].filter((i) => i !== -1); + if (starts.length === 0) { + throw new LlmOutputParseError(`No JSON found in LLM response: ${content}`, content); + } + const start = Math.min(...starts); + const isArray = start === arrayStart; + const end = isArray ? text.lastIndexOf(']') : text.lastIndexOf('}'); + if (end === -1 || end <= start) { + throw new LlmOutputParseError(`No JSON found in LLM response: ${content}`, content); + } + + try { + return JSON.parse(text.slice(start, end + 1)); + } catch (err) { + throw new LlmOutputParseError(`Failed to parse JSON from LLM response: ${String(err)}`, content); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/core/llm/SchemaLlmTool.test.ts` +Expected: PASS + +- [ ] **Step 5: Write the failing test for `TextLlmTool`** + +Create `test/core/llm/TextLlmTool.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { TextLlmTool } from '../../../src/core/llm/TextLlmTool'; +import type { RunContext } from '../../../src/core/llm/RunContext'; + +class EchoTool extends TextLlmTool> { + protected readonly promptName = 'echo'; + protected buildVariables(): Record { + return {}; + } +} + +describe('TextLlmTool.parseOutput (via subclass)', () => { + it('trims the raw LLM text and returns it as-is', () => { + const tool = new EchoTool( + {} as unknown as ConstructorParameters[0], + {} as unknown as ConstructorParameters[1] + ) as unknown as { parseOutput(raw: string, ctx: RunContext): string }; + + expect(tool.parseOutput(' hello world \n', {} as RunContext)).toBe('hello world'); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `pnpm test -- test/core/llm/TextLlmTool.test.ts` +Expected: FAIL — cannot find module `src/core/llm/TextLlmTool`. + +- [ ] **Step 7: Implement `src/core/llm/TextLlmTool.ts`** + +```ts +import { LlmTool } from './LlmTool'; +import type { RunContext } from './RunContext'; + +export abstract class TextLlmTool extends LlmTool { + protected parseOutput(raw: string, _ctx: RunContext): string { + return raw.trim(); + } +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `pnpm test -- test/core/llm/TextLlmTool.test.ts` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/core/llm/SchemaLlmTool.ts src/core/llm/TextLlmTool.ts test/core/llm/SchemaLlmTool.test.ts test/core/llm/TextLlmTool.test.ts +git commit -m "Add SchemaLlmTool and TextLlmTool" +``` + +--- + +## Task 8: Core LLM — `LlmPipeline` + +**Files:** +- Create: `src/core/llm/LlmPipeline.ts` +- Test: `test/core/llm/LlmPipeline.test.ts` + +**Interfaces:** +- Consumes: `Tracer`, `TraceHandle`, `RunContext`, `LlmTool` (Task 6), `Logger` (Task 1). +- Produces: `abstract class LlmPipeline` with `constructor(tracer: Tracer, logger: Logger)`, `protected abstract name: string`, `protected abstract runInternal(input, ctx): Promise`, `async runAsync(input): Promise`, `protected runTool(tool: LlmTool, input: I, ctx: RunContext): Promise`. Used by `RecommendationPipeline` (Task 16). + +- [ ] **Step 1: Write the failing test** + +Create `test/core/llm/LlmPipeline.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { LlmPipeline } from '../../../src/core/llm/LlmPipeline'; +import type { RunContext } from '../../../src/core/llm/RunContext'; +import type { Tracer, TraceHandle } from '../../../src/core/llm/Tracer'; +import type { LlmTool } from '../../../src/core/llm/LlmTool'; +import type { Logger } from '../../../src/core/logging/logger'; + +class SumPipeline extends LlmPipeline { + protected readonly name = 'sum-pipeline'; + protected async runInternal(input: number[], ctx: RunContext): Promise { + expect(ctx.trace.id).toBe('trace-1'); + return input.reduce((a, b) => a + b, 0); + } +} + +class ThrowingPipeline extends LlmPipeline { + protected readonly name = 'throwing-pipeline'; + protected async runInternal(): Promise { + throw new Error('pipeline failed'); + } +} + +function makeTracer(trace: TraceHandle) { + const flush = vi.fn().mockResolvedValue(undefined); + const startTrace = vi.fn().mockReturnValue(trace); + const tracer = { startTrace, flush } as unknown as Tracer; + return { tracer, startTrace, flush }; +} + +describe('LlmPipeline', () => { + it('opens one trace, runs runInternal with it, and flushes afterward', async () => { + const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle; + const { tracer, startTrace, flush } = makeTracer(trace); + const logger = {} as unknown as Logger; + + const pipeline = new SumPipeline(tracer, logger); + const result = await pipeline.runAsync([1, 2, 3]); + + expect(result).toBe(6); + expect(startTrace).toHaveBeenCalledWith('sum-pipeline'); + expect(flush).toHaveBeenCalledTimes(1); + }); + + it('still flushes the trace when runInternal throws', async () => { + const trace = { id: 'trace-2', startGeneration: vi.fn() } as unknown as TraceHandle; + const { tracer, flush } = makeTracer(trace); + const logger = {} as unknown as Logger; + + const pipeline = new ThrowingPipeline(tracer, logger); + await expect(pipeline.runAsync()).rejects.toThrow('pipeline failed'); + expect(flush).toHaveBeenCalledTimes(1); + }); + + it('runTool delegates to the given tool.runAsync with the same context', async () => { + const trace = { id: 'trace-3', startGeneration: vi.fn() } as unknown as TraceHandle; + const { tracer } = makeTracer(trace); + const logger = {} as unknown as Logger; + + class ToolRunnerPipeline extends LlmPipeline { + protected readonly name = 'tool-runner'; + protected async runInternal(_input: void, ctx: RunContext): Promise { + const tool = { runAsync: vi.fn().mockResolvedValue('tool-output') } as unknown as LlmTool< + Record, + string + >; + return this.runTool(tool, {}, ctx); + } + } + + const pipeline = new ToolRunnerPipeline(tracer, logger); + const result = await pipeline.runAsync(); + expect(result).toBe('tool-output'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/core/llm/LlmPipeline.test.ts` +Expected: FAIL — cannot find module `src/core/llm/LlmPipeline`. + +- [ ] **Step 3: Implement `src/core/llm/LlmPipeline.ts`** + +```ts +import type { Logger } from '../logging/logger'; +import type { LlmTool } from './LlmTool'; +import type { RunContext } from './RunContext'; +import type { Tracer } from './Tracer'; + +export abstract class LlmPipeline { + constructor( + protected readonly tracer: Tracer, + protected readonly logger: Logger + ) {} + + protected abstract readonly name: string; + protected abstract runInternal(input: TInput, ctx: RunContext): Promise; + + async runAsync(input: TInput): Promise { + const trace = this.tracer.startTrace(this.name); + const ctx: RunContext = { trace, logger: this.logger }; + try { + return await this.runInternal(input, ctx); + } finally { + await this.tracer.flush(); + } + } + + protected runTool( + tool: LlmTool, + input: TToolInput, + ctx: RunContext + ): Promise { + return tool.runAsync(input, ctx); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/core/llm/LlmPipeline.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/core/llm/LlmPipeline.ts test/core/llm/LlmPipeline.test.ts +git commit -m "Add core LlmPipeline base class" +``` + +--- + +## Task 9: Core server — `Controller`, auth hook, `buildServer` + +**Files:** +- Create: `src/core/server/Controller.ts` +- Create: `src/core/server/authHook.ts` +- Create: `src/core/server/buildServer.ts` +- Test: `test/core/server/authHook.test.ts` + +**Interfaces:** +- Produces: `abstract class Controller { abstract register(app: FastifyInstance): void }`; `function registerAuthHook(app, secretKey, exemptPaths?)`; `function buildServer(options: { secretKey, controllers: Controller[] }): FastifyInstance`. Used by all app controllers (Task 17) and `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Create `src/core/server/Controller.ts`** + +```ts +import type { FastifyInstance } from 'fastify'; + +export abstract class Controller { + abstract register(app: FastifyInstance): void; +} +``` + +- [ ] **Step 2: Write the failing test for the auth hook** + +Create `test/core/server/authHook.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import Fastify from 'fastify'; +import { registerAuthHook } from '../../../src/core/server/authHook'; + +function buildApp(secretKey: string) { + const app = Fastify(); + registerAuthHook(app, secretKey); + app.get('/health', async () => ({ status: 'ok' })); + app.get('/recommendations', async () => ({ items: [] })); + return app; +} + +describe('registerAuthHook', () => { + it('allows all requests through when secretKey is empty', async () => { + const app = buildApp(''); + const res = await app.inject({ method: 'GET', url: '/recommendations' }); + expect(res.statusCode).toBe(200); + }); + + it('always exempts /health even when a secret is configured', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + }); + + it('rejects requests without a matching bearer token', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ method: 'GET', url: '/recommendations' }); + expect(res.statusCode).toBe(401); + }); + + it('allows requests with the correct bearer token', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ + method: 'GET', + url: '/recommendations', + headers: { authorization: 'Bearer s3cret' }, + }); + expect(res.statusCode).toBe(200); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm test -- test/core/server/authHook.test.ts` +Expected: FAIL — cannot find module `src/core/server/authHook`. + +- [ ] **Step 4: Implement `src/core/server/authHook.ts`** + +```ts +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export function registerAuthHook( + app: FastifyInstance, + secretKey: string, + exemptPaths: string[] = ['/health'] +): void { + app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + const path = request.url.split('?')[0]; + if (!secretKey || exemptPaths.includes(path)) { + return; + } + const header = request.headers.authorization ?? ''; + const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined; + if (token !== secretKey) { + reply.code(401).send({ error: 'unauthorized' }); + } + }); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm test -- test/core/server/authHook.test.ts` +Expected: PASS + +- [ ] **Step 6: Implement `src/core/server/buildServer.ts`** (wiring only, exercised end-to-end in Task 17) + +```ts +import Fastify, { type FastifyInstance } from 'fastify'; +import type { Controller } from './Controller'; +import { registerAuthHook } from './authHook'; + +export interface BuildServerOptions { + secretKey: string; + controllers: Controller[]; +} + +export function buildServer(options: BuildServerOptions): FastifyInstance { + const app = Fastify({ logger: false }); + registerAuthHook(app, options.secretKey); + for (const controller of options.controllers) { + controller.register(app); + } + return app; +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/core/server/Controller.ts src/core/server/authHook.ts src/core/server/buildServer.ts test/core/server/authHook.test.ts +git commit -m "Add core server Controller base, auth hook, and buildServer" +``` + +--- + +## Task 10: Core Langfuse concrete — `LangfuseTracer` and `LangfusePromptGateway` + +**Files:** +- Create: `src/core/langfuse/LangfuseTracer.ts` +- Create: `src/core/langfuse/LangfusePromptGateway.ts` +- Test: `test/core/langfuse/LangfuseTracer.test.ts` +- Test: `test/core/langfuse/LangfusePromptGateway.test.ts` + +**Interfaces:** +- Consumes: `Tracer`, `TraceHandle`, `GenerationInput`, `GenerationOutput` (Task 6); `PromptFetcher`, `RawPrompt` (Task 4). +- Produces: `class LangfuseTracer extends Tracer { constructor(opts: { publicKey, secretKey, baseUrl }) }`; `class LangfusePromptGateway implements PromptFetcher { constructor(opts: { publicKey, secretKey, baseUrl }) }`. Both used by `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Write the failing test for `LangfuseTracer`** + +Create `test/core/langfuse/LangfuseTracer.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; + +const generationEnd = vi.fn(); +const generation = vi.fn().mockReturnValue({ end: generationEnd }); +const trace = vi.fn().mockReturnValue({ id: 'lf-trace-1', generation }); +const flushAsync = vi.fn().mockResolvedValue(undefined); + +vi.mock('langfuse', () => ({ + Langfuse: vi.fn().mockImplementation(() => ({ trace, flushAsync })), +})); + +import { LangfuseTracer } from '../../../src/core/langfuse/LangfuseTracer'; + +describe('LangfuseTracer', () => { + it('starts a Langfuse trace and exposes its id', () => { + const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + const handle = tracer.startTrace('my-pipeline', { foo: 'bar' }); + + expect(trace).toHaveBeenCalledWith({ name: 'my-pipeline', metadata: { foo: 'bar' } }); + expect(handle.id).toBe('lf-trace-1'); + }); + + it('startGeneration creates a Langfuse generation and end() forwards output/usage', () => { + const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + const handle = tracer.startTrace('my-pipeline'); + const gen = handle.startGeneration({ name: 'step-1', model: 'llama3', input: ['hi'] }); + gen.end({ output: 'done', usage: { totalTokens: 5 } }); + + expect(generation).toHaveBeenCalledWith( + expect.objectContaining({ name: 'step-1', model: 'llama3', input: ['hi'] }) + ); + expect(generationEnd).toHaveBeenCalledWith( + expect.objectContaining({ output: 'done', usage: { totalTokens: 5 } }) + ); + }); + + it('flush() calls flushAsync on the underlying client', async () => { + const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + await tracer.flush(); + expect(flushAsync).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/core/langfuse/LangfuseTracer.test.ts` +Expected: FAIL — cannot find module `src/core/langfuse/LangfuseTracer`. + +- [ ] **Step 3: Implement `src/core/langfuse/LangfuseTracer.ts`** + +```ts +import { Langfuse } from 'langfuse'; +import { Tracer } from '../llm/Tracer'; +import type { GenerationHandle, GenerationInput, GenerationOutput, TraceHandle } from '../llm/Tracer'; + +export class LangfuseTracer extends Tracer { + private readonly client: Langfuse; + + constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) { + super(); + this.client = new Langfuse({ + publicKey: opts.publicKey, + secretKey: opts.secretKey, + baseUrl: opts.baseUrl, + }); + } + + startTrace(name: string, metadata?: Record): TraceHandle { + const trace = this.client.trace({ name, metadata }); + return { + id: trace.id, + startGeneration(input: GenerationInput): GenerationHandle { + const generation = trace.generation({ + name: input.name, + model: input.model, + input: input.input, + metadata: input.metadata, + }); + return { + end(output: GenerationOutput) { + generation.end({ output: output.output, usage: output.usage, metadata: output.metadata }); + }, + }; + }, + }; + } + + async flush(): Promise { + await this.client.flushAsync(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/core/langfuse/LangfuseTracer.test.ts` +Expected: PASS + +- [ ] **Step 5: Write the failing test for `LangfusePromptGateway`** + +Create `test/core/langfuse/LangfusePromptGateway.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; + +const compile = vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]); +const getPrompt = vi.fn().mockResolvedValue({ name: 'greet', version: 2, config: { model: 'llama3' }, compile }); + +vi.mock('langfuse', () => ({ + Langfuse: vi.fn().mockImplementation(() => ({ getPrompt })), +})); + +import { LangfusePromptGateway } from '../../../src/core/langfuse/LangfusePromptGateway'; + +describe('LangfusePromptGateway', () => { + it('fetches a chat prompt from Langfuse bypassing the SDK cache, and adapts it to RawPrompt', async () => { + const gateway = new LangfusePromptGateway({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + const raw = await gateway.fetch('greet'); + + expect(getPrompt).toHaveBeenCalledWith('greet', undefined, { type: 'chat', cacheTtlSeconds: 0 }); + expect(raw.name).toBe('greet'); + expect(raw.version).toBe(2); + expect(raw.config).toEqual({ model: 'llama3' }); + expect(raw.compile({ who: 'alice' })).toEqual([{ role: 'user', content: 'hi' }]); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `pnpm test -- test/core/langfuse/LangfusePromptGateway.test.ts` +Expected: FAIL — cannot find module `src/core/langfuse/LangfusePromptGateway`. + +- [ ] **Step 7: Implement `src/core/langfuse/LangfusePromptGateway.ts`** + +```ts +import { Langfuse } from 'langfuse'; +import type { PromptFetcher, RawPrompt } from './PromptGateway'; + +export class LangfusePromptGateway implements PromptFetcher { + private readonly client: Langfuse; + + constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) { + this.client = new Langfuse({ + publicKey: opts.publicKey, + secretKey: opts.secretKey, + baseUrl: opts.baseUrl, + }); + } + + async fetch(promptName: string): Promise { + const prompt = await this.client.getPrompt(promptName, undefined, { type: 'chat', cacheTtlSeconds: 0 }); + return { + name: prompt.name, + version: prompt.version, + config: (prompt.config ?? {}) as Record, + compile: (variables) => + prompt.compile(variables) as { role: 'system' | 'user' | 'assistant'; content: string }[], + }; + } +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `pnpm test -- test/core/langfuse/LangfusePromptGateway.test.ts` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/core/langfuse/LangfuseTracer.ts src/core/langfuse/LangfusePromptGateway.ts test/core/langfuse/LangfuseTracer.test.ts test/core/langfuse/LangfusePromptGateway.test.ts +git commit -m "Add concrete Langfuse tracer and prompt gateway" +``` + +--- + +## Task 11: App config and shared models + +**Files:** +- Create: `src/app/config/AppConfig.ts` +- Create: `src/app/models.ts` +- Test: `test/app/config/AppConfig.test.ts` + +**Interfaces:** +- Consumes: `loadEnv` (Task 2). +- Produces: `AppConfigSchema`, `type AppConfig`, `function loadAppConfig(source?): AppConfig` (`AppConfig.ts`); `interface Movie`, `interface TMDbMovie`, `interface MovieSuggestion`, `interface Recommendation` (`models.ts`). Used by every remaining app-layer task. + +- [ ] **Step 1: Create `src/app/models.ts`** + +```ts +export interface Movie { + name: string; + year: number; + imdbId: string; + tmdbId: string; + genres: string[]; + communityRating: number; + isFavorite: boolean; + playCount: number; +} + +export interface TMDbMovie { + id: number; + title: string; + year: number; +} + +export interface MovieSuggestion { + title: string; + year: number; + reason: string; +} + +export interface Recommendation { + title: string; + year: number; + tmdbId: number; + reason: string; +} +``` + +- [ ] **Step 2: Write the failing test for `AppConfig`** + +Create `test/app/config/AppConfig.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { loadAppConfig } from '../../../src/app/config/AppConfig'; + +const REQUIRED_ENV = { + JELLYFIN_URL: 'http://jellyfin', + JELLYFIN_API_KEY: 'jf-key', + JELLYFIN_USER_ID: 'user-1', + TMDB_API_KEY: 'tmdb-key', + LANGFUSE_PUBLIC_KEY: 'lf-pub', + LANGFUSE_SECRET_KEY: 'lf-secret', + LANGFUSE_BASE_URL: 'http://langfuse', +}; + +describe('loadAppConfig', () => { + it('applies defaults for optional settings', () => { + const config = loadAppConfig(REQUIRED_ENV); + expect(config.LLM_ENABLED).toBe(false); + expect(config.RADARR_ENABLED).toBe(false); + expect(config.API_PORT).toBe(8080); + expect(config.RECOMMEND_COUNT).toBe(10); + expect(config.MIN_RATED_MOVIES).toBe(5); + expect(config.LANGFUSE_PROMPT_CACHE_ENABLED).toBe(true); + }); + + it('parses boolean and numeric overrides from env strings', () => { + const config = loadAppConfig({ + ...REQUIRED_ENV, + LLM_ENABLED: 'true', + RADARR_ENABLED: 'true', + API_PORT: '9090', + RECOMMEND_COUNT: '20', + LANGFUSE_PROMPT_CACHE_ENABLED: 'false', + }); + expect(config.LLM_ENABLED).toBe(true); + expect(config.RADARR_ENABLED).toBe(true); + expect(config.API_PORT).toBe(9090); + expect(config.RECOMMEND_COUNT).toBe(20); + expect(config.LANGFUSE_PROMPT_CACHE_ENABLED).toBe(false); + }); + + it('throws when a required var is missing', () => { + const { JELLYFIN_URL, ...rest } = REQUIRED_ENV; + expect(() => loadAppConfig(rest)).toThrow(/Invalid environment configuration/); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm test -- test/app/config/AppConfig.test.ts` +Expected: FAIL — cannot find module `src/app/config/AppConfig`. + +- [ ] **Step 4: Implement `src/app/config/AppConfig.ts`** + +```ts +import { z } from 'zod'; +import { loadEnv } from '../../core/config/env'; + +const boolFromEnv = (defaultValue: 'true' | 'false') => + z + .string() + .optional() + .default(defaultValue) + .transform((v) => v === 'true'); + +export const AppConfigSchema = z.object({ + JELLYFIN_URL: z.string(), + JELLYFIN_API_KEY: z.string(), + JELLYFIN_USER_ID: z.string(), + TMDB_API_KEY: z.string(), + + LLM_ENABLED: boolFromEnv('false'), + LLM_BASE_URL: z.string().default('http://localhost:11434/v1'), + LLM_API_KEY: z.string().optional().default(''), + LLM_MODEL: z.string().default('llama3'), + + RADARR_ENABLED: boolFromEnv('false'), + RADARR_URL: z.string().optional().default(''), + RADARR_API_KEY: z.string().optional().default(''), + RADARR_QUALITY_PROFILE_ID: z.coerce.number().default(1), + RADARR_ROOT_FOLDER: z.string().default('/movies'), + + CRON_ENABLED: boolFromEnv('false'), + CRON_SCHEDULE: z.string().default('0 3 * * *'), + + API_ENABLED: z + .string() + .optional() + .default('true') + .transform((v) => v !== 'false'), + API_PORT: z.coerce.number().default(8080), + SECRET_KEY: z.string().optional().default(''), + + RECOMMEND_COUNT: z.coerce.number().default(10), + RECOMMEND_MAX_RETRY: z.coerce.number().default(3), + MIN_RATED_MOVIES: z.coerce.number().default(5), + + LANGFUSE_PUBLIC_KEY: z.string(), + LANGFUSE_SECRET_KEY: z.string(), + LANGFUSE_BASE_URL: z.string(), + LANGFUSE_PROMPT_CACHE_ENABLED: boolFromEnv('true'), +}); + +export type AppConfig = z.infer; + +export function loadAppConfig(source: NodeJS.ProcessEnv = process.env): AppConfig { + return loadEnv(AppConfigSchema, source); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm test -- test/app/config/AppConfig.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/app/config/AppConfig.ts src/app/models.ts test/app/config/AppConfig.test.ts +git commit -m "Add app config schema and shared models" +``` + +--- + +## Task 12: App clients — `JellyfinClient` + +**Files:** +- Create: `src/app/clients/jellyfin/endpoints/ItemsEndpoint.ts` +- Create: `src/app/clients/jellyfin/JellyfinClient.ts` +- Test: `test/app/clients/jellyfin/JellyfinClient.test.ts` + +**Interfaces:** +- Consumes: `Endpoint`, `ApiClient` (Task 3); `Movie` (Task 11). +- Produces: `class JellyfinClient extends ApiClient { constructor(baseUrl, apiKey, userId); getFavoriteMovies(): Promise; getRecentlyWatched(): Promise; getAllMovies(): Promise }`. Used by `RecommendationPipeline` (Task 16) and `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Create `src/app/clients/jellyfin/endpoints/ItemsEndpoint.ts`** + +```ts +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { Movie } from '../../../models'; + +interface JellyfinItem { + Name: string; + ProductionYear: number; + ProviderIds: Record; + Genres: string[]; + CommunityRating: number; + UserData: { IsFavorite: boolean; PlayCount: number }; +} + +interface JellyfinItemsResponse { + Items: JellyfinItem[]; +} + +export interface ItemsParams { + userId: string; + query: Record; +} + +export class ItemsEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + + path(params: ItemsParams): string { + return `/Users/${params.userId}/Items`; + } + + query(params: ItemsParams): Record { + return params.query; + } + + parseResponse(json: unknown): Movie[] { + const response = json as JellyfinItemsResponse; + return response.Items.map((item) => ({ + name: item.Name, + year: item.ProductionYear, + imdbId: item.ProviderIds?.Imdb ?? '', + tmdbId: item.ProviderIds?.Tmdb ?? '', + genres: item.Genres ?? [], + communityRating: item.CommunityRating ?? 0, + isFavorite: item.UserData?.IsFavorite ?? false, + playCount: item.UserData?.PlayCount ?? 0, + })); + } +} +``` + +- [ ] **Step 2: Write the failing test for `JellyfinClient`** + +Create `test/app/clients/jellyfin/JellyfinClient.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JellyfinClient } from '../../../../src/app/clients/jellyfin/JellyfinClient'; + +describe('JellyfinClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + Items: [ + { + Name: 'Dune', + ProductionYear: 2021, + ProviderIds: { Imdb: 'tt1160419', Tmdb: '438631' }, + Genres: ['Sci-Fi'], + CommunityRating: 8.0, + UserData: { IsFavorite: true, PlayCount: 2 }, + }, + ], + }), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('getFavoriteMovies requests favorites and maps the response to Movie[]', async () => { + const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1'); + const movies = await client.getFavoriteMovies(); + + expect(movies).toEqual([ + { + name: 'Dune', + year: 2021, + imdbId: 'tt1160419', + tmdbId: '438631', + genres: ['Sci-Fi'], + communityRating: 8.0, + isFavorite: true, + playCount: 2, + }, + ]); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/Users/user-1/Items'); + expect(url).toContain('IsFavorite=true'); + expect(init.headers.Authorization).toBe('MediaBrowser Token="jf-key"'); + }); + + it('getRecentlyWatched requests played items sorted by date', async () => { + const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1'); + await client.getRecentlyWatched(); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('Filters=IsPlayed'); + expect(url).toContain('SortBy=DatePlayed'); + }); + + it('getAllMovies requests the full library', async () => { + const client = new JellyfinClient('http://jellyfin', 'jf-key', 'user-1'); + await client.getAllMovies(); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('Limit=10000'); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm test -- test/app/clients/jellyfin/JellyfinClient.test.ts` +Expected: FAIL — cannot find module `src/app/clients/jellyfin/JellyfinClient`. + +- [ ] **Step 4: Implement `src/app/clients/jellyfin/JellyfinClient.ts`** + +```ts +import { ApiClient } from '../../../core/http/ApiClient'; +import type { Movie } from '../../models'; +import { ItemsEndpoint } from './endpoints/ItemsEndpoint'; + +export class JellyfinClient extends ApiClient { + private readonly endpoint = new ItemsEndpoint(); + + constructor( + baseUrl: string, + private readonly apiKey: string, + private readonly userId: string + ) { + super(baseUrl); + } + + protected buildHeaders(): Record { + return { + ...super.buildHeaders(), + Authorization: `MediaBrowser Token="${this.apiKey}"`, + }; + } + + getFavoriteMovies(): Promise { + return this.request(this.endpoint, { + userId: this.userId, + query: { + IncludeItemTypes: 'Movie', + Recursive: 'true', + IsFavorite: 'true', + Fields: 'ProviderIds,Genres,CommunityRating,UserData', + }, + }); + } + + getRecentlyWatched(): Promise { + return this.request(this.endpoint, { + userId: this.userId, + query: { + IncludeItemTypes: 'Movie', + Recursive: 'true', + Filters: 'IsPlayed', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: '50', + Fields: 'ProviderIds,Genres,CommunityRating,UserData', + }, + }); + } + + getAllMovies(): Promise { + return this.request(this.endpoint, { + userId: this.userId, + query: { + IncludeItemTypes: 'Movie', + Recursive: 'true', + Fields: 'ProviderIds', + Limit: '10000', + }, + }); + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm test -- test/app/clients/jellyfin/JellyfinClient.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/app/clients/jellyfin test/app/clients/jellyfin +git commit -m "Add JellyfinClient" +``` + +--- + +## Task 13: App clients — `TmdbClient` + +**Files:** +- Create: `src/app/clients/tmdb/endpoints/SearchMovieEndpoint.ts` +- Create: `src/app/clients/tmdb/endpoints/MovieListEndpoint.ts` +- Create: `src/app/clients/tmdb/TmdbClient.ts` +- Test: `test/app/clients/tmdb/TmdbClient.test.ts` + +**Interfaces:** +- Consumes: `Endpoint`, `ApiClient` (Task 3); `TMDbMovie` (Task 11). +- Produces: `class TmdbClient extends ApiClient { constructor(apiKey, baseUrl?); searchMovie(title, year?): Promise; getSimilar(tmdbId): Promise; getRecommendations(tmdbId): Promise }`. Used by `RecommendationPipeline` (Task 16) and `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Create `src/app/clients/tmdb/endpoints/SearchMovieEndpoint.ts`** + +```ts +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { TMDbMovie } from '../../../models'; + +interface TMDbResult { + id: number; + title: string; + release_date: string; +} + +interface TMDbSearchResponse { + results: TMDbResult[]; +} + +export interface SearchMovieParams { + apiKey: string; + title: string; + year?: number; +} + +export function toTMDbMovie(result: TMDbResult): TMDbMovie { + const year = result.release_date && result.release_date.length >= 4 ? Number(result.release_date.slice(0, 4)) : 0; + return { id: result.id, title: result.title, year }; +} + +export class SearchMovieEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + + path(): string { + return '/3/search/movie'; + } + + query(params: SearchMovieParams): Record { + const query: Record = { api_key: params.apiKey, query: params.title }; + if (params.year) query.year = String(params.year); + return query; + } + + parseResponse(json: unknown): TMDbMovie[] { + return (json as TMDbSearchResponse).results.map(toTMDbMovie); + } +} +``` + +- [ ] **Step 2: Create `src/app/clients/tmdb/endpoints/MovieListEndpoint.ts`** + +```ts +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { TMDbMovie } from '../../../models'; +import { toTMDbMovie } from './SearchMovieEndpoint'; + +interface TMDbResult { + id: number; + title: string; + release_date: string; +} + +interface TMDbListResponse { + results: TMDbResult[]; +} + +export interface MovieListParams { + apiKey: string; + tmdbId: number; +} + +export class SimilarMoviesEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(params: MovieListParams): string { + return `/3/movie/${params.tmdbId}/similar`; + } + query(params: MovieListParams): Record { + return { api_key: params.apiKey }; + } + parseResponse(json: unknown): TMDbMovie[] { + return (json as TMDbListResponse).results.map(toTMDbMovie); + } +} + +export class RecommendedMoviesEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(params: MovieListParams): string { + return `/3/movie/${params.tmdbId}/recommendations`; + } + query(params: MovieListParams): Record { + return { api_key: params.apiKey }; + } + parseResponse(json: unknown): TMDbMovie[] { + return (json as TMDbListResponse).results.map(toTMDbMovie); + } +} +``` + +- [ ] **Step 3: Write the failing test for `TmdbClient`** + +Create `test/app/clients/tmdb/TmdbClient.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TmdbClient } from '../../../../src/app/clients/tmdb/TmdbClient'; + +describe('TmdbClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('searchMovie returns the first result mapped to a TMDbMovie', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ results: [{ id: 438631, title: 'Dune', release_date: '2021-09-15' }] }), + }); + + const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org'); + const result = await client.searchMovie('Dune', 2021); + + expect(result).toEqual({ id: 438631, title: 'Dune', year: 2021 }); + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('/3/search/movie'); + expect(url).toContain('query=Dune'); + expect(url).toContain('year=2021'); + }); + + it('searchMovie returns undefined when there are no results', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) }); + + const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org'); + const result = await client.searchMovie('Nonexistent'); + expect(result).toBeUndefined(); + }); + + it('getSimilar and getRecommendations hit the right paths', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) }); + + const client = new TmdbClient('tmdb-key', 'https://api.themoviedb.org'); + await client.getSimilar(438631); + await client.getRecommendations(438631); + + expect(fetchMock.mock.calls[0][0]).toContain('/3/movie/438631/similar'); + expect(fetchMock.mock.calls[1][0]).toContain('/3/movie/438631/recommendations'); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `pnpm test -- test/app/clients/tmdb/TmdbClient.test.ts` +Expected: FAIL — cannot find module `src/app/clients/tmdb/TmdbClient`. + +- [ ] **Step 5: Implement `src/app/clients/tmdb/TmdbClient.ts`** + +```ts +import { ApiClient } from '../../../core/http/ApiClient'; +import type { TMDbMovie } from '../../models'; +import { SearchMovieEndpoint } from './endpoints/SearchMovieEndpoint'; +import { RecommendedMoviesEndpoint, SimilarMoviesEndpoint } from './endpoints/MovieListEndpoint'; + +const TMDB_BASE_URL = 'https://api.themoviedb.org'; + +export class TmdbClient extends ApiClient { + private readonly searchEndpoint = new SearchMovieEndpoint(); + private readonly similarEndpoint = new SimilarMoviesEndpoint(); + private readonly recommendedEndpoint = new RecommendedMoviesEndpoint(); + + constructor( + private readonly apiKey: string, + baseUrl: string = TMDB_BASE_URL + ) { + super(baseUrl); + } + + async searchMovie(title: string, year?: number): Promise { + const results = await this.request(this.searchEndpoint, { apiKey: this.apiKey, title, year }); + return results[0]; + } + + getSimilar(tmdbId: number): Promise { + return this.request(this.similarEndpoint, { apiKey: this.apiKey, tmdbId }); + } + + getRecommendations(tmdbId: number): Promise { + return this.request(this.recommendedEndpoint, { apiKey: this.apiKey, tmdbId }); + } +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `pnpm test -- test/app/clients/tmdb/TmdbClient.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/app/clients/tmdb test/app/clients/tmdb +git commit -m "Add TmdbClient" +``` + +--- + +## Task 14: App clients — `RadarrClient` + +**Files:** +- Create: `src/app/clients/radarr/endpoints/LookupEndpoint.ts` +- Create: `src/app/clients/radarr/endpoints/AddMovieEndpoint.ts` +- Create: `src/app/clients/radarr/endpoints/GetExistingEndpoint.ts` +- Create: `src/app/clients/radarr/RadarrClient.ts` +- Test: `test/app/clients/radarr/RadarrClient.test.ts` + +**Interfaces:** +- Consumes: `Endpoint`, `ApiClient` (Task 3). +- Produces: `interface RadarrMovie { title, tmdbId, qualityProfileId, rootFolderPath, monitored, addOptions }`; `class RadarrClient extends ApiClient { constructor(baseUrl, apiKey, qualityProfileId, rootFolder); lookup(tmdbId): Promise; addMovie(movie: RadarrMovie): Promise; getExisting(): Promise }`. Used by `RecommendationPipeline` (Task 16), `RequestController` (Task 17), `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Create `src/app/clients/radarr/endpoints/LookupEndpoint.ts`** + +```ts +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; + +export interface RadarrMovie { + title: string; + tmdbId: number; + qualityProfileId: number; + rootFolderPath: string; + monitored: boolean; + addOptions: { searchForMovie: boolean }; +} + +interface RadarrLookupResult { + title: string; + tmdbId: number; +} + +export interface LookupParams { + tmdbId: number; +} + +export class LookupEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(): string { + return '/api/v3/movie/lookup/tmdb'; + } + query(params: LookupParams): Record { + return { tmdbId: String(params.tmdbId) }; + } + parseResponse(json: unknown): RadarrLookupResult { + return json as RadarrLookupResult; + } +} +``` + +- [ ] **Step 2: Create `src/app/clients/radarr/endpoints/AddMovieEndpoint.ts`** + +```ts +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { RadarrMovie } from './LookupEndpoint'; + +export class AddMovieEndpoint extends Endpoint { + readonly method: HttpMethod = 'POST'; + readonly acceptableStatuses = [400]; + path(): string { + return '/api/v3/movie'; + } + body(params: RadarrMovie): unknown { + return params; + } + parseResponse(): void { + return undefined; + } +} +``` + +- [ ] **Step 3: Create `src/app/clients/radarr/endpoints/GetExistingEndpoint.ts`** + +```ts +import { Endpoint, type HttpMethod } from '../../../../core/http/Endpoint'; +import type { RadarrMovie } from './LookupEndpoint'; + +export class GetExistingEndpoint extends Endpoint { + readonly method: HttpMethod = 'GET'; + path(): string { + return '/api/v3/movie'; + } + parseResponse(json: unknown): RadarrMovie[] { + return json as RadarrMovie[]; + } +} +``` + +- [ ] **Step 4: Write the failing test for `RadarrClient`** + +Create `test/app/clients/radarr/RadarrClient.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RadarrClient } from '../../../../src/app/clients/radarr/RadarrClient'; + +describe('RadarrClient', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lookup resolves TMDb metadata and fills in client-configured fields', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ title: 'Dune', tmdbId: 438631 }), + }); + + const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies'); + const movie = await client.lookup(438631); + + expect(movie).toEqual({ + title: 'Dune', + tmdbId: 438631, + qualityProfileId: 4, + rootFolderPath: '/movies', + monitored: true, + addOptions: { searchForMovie: true }, + }); + expect(fetchMock.mock.calls[0][1].headers['X-Api-Key']).toBe('radarr-key'); + }); + + it('addMovie treats HTTP 400 (already exists) as success', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 400, text: async () => '' }); + + const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies'); + await expect( + client.addMovie({ + title: 'Dune', + tmdbId: 438631, + qualityProfileId: 4, + rootFolderPath: '/movies', + monitored: true, + addOptions: { searchForMovie: true }, + }) + ).resolves.toBeUndefined(); + }); + + it('getExisting returns the full Radarr library', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ title: 'Dune', tmdbId: 438631 }]), + }); + + const client = new RadarrClient('http://radarr', 'radarr-key', 4, '/movies'); + const existing = await client.getExisting(); + expect(existing[0].tmdbId).toBe(438631); + }); +}); +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `pnpm test -- test/app/clients/radarr/RadarrClient.test.ts` +Expected: FAIL — cannot find module `src/app/clients/radarr/RadarrClient`. + +- [ ] **Step 6: Implement `src/app/clients/radarr/RadarrClient.ts`** + +```ts +import { ApiClient } from '../../../core/http/ApiClient'; +import { LookupEndpoint, type RadarrMovie } from './endpoints/LookupEndpoint'; +import { AddMovieEndpoint } from './endpoints/AddMovieEndpoint'; +import { GetExistingEndpoint } from './endpoints/GetExistingEndpoint'; + +export type { RadarrMovie } from './endpoints/LookupEndpoint'; + +export class RadarrClient extends ApiClient { + private readonly lookupEndpoint = new LookupEndpoint(); + private readonly addMovieEndpoint = new AddMovieEndpoint(); + private readonly getExistingEndpoint = new GetExistingEndpoint(); + + constructor( + baseUrl: string, + private readonly apiKey: string, + private readonly qualityProfileId: number, + private readonly rootFolder: string + ) { + super(baseUrl); + } + + protected buildHeaders(): Record { + return { ...super.buildHeaders(), 'X-Api-Key': this.apiKey }; + } + + async lookup(tmdbId: number): Promise { + const result = await this.request(this.lookupEndpoint, { tmdbId }); + return { + title: result.title, + tmdbId: result.tmdbId, + qualityProfileId: this.qualityProfileId, + rootFolderPath: this.rootFolder, + monitored: true, + addOptions: { searchForMovie: true }, + }; + } + + addMovie(movie: RadarrMovie): Promise { + return this.request(this.addMovieEndpoint, movie); + } + + getExisting(): Promise { + return this.request(this.getExistingEndpoint, undefined); + } +} +``` + +- [ ] **Step 7: Run test to verify it passes** + +Run: `pnpm test -- test/app/clients/radarr/RadarrClient.test.ts` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/app/clients/radarr test/app/clients/radarr +git commit -m "Add RadarrClient" +``` + +--- + +## Task 15: App tools — `MovieRecommenderTool` and `MovieSearchTool` + +**Files:** +- Create: `src/app/tools/MovieRecommenderTool.ts` +- Create: `src/app/tools/MovieSearchTool.ts` +- Test: `test/app/tools/MovieRecommenderTool.test.ts` +- Test: `test/app/tools/MovieSearchTool.test.ts` + +**Interfaces:** +- Consumes: `SchemaLlmTool`, `RunContext` (Tasks 6-7); `Movie`, `MovieSuggestion` (Task 11); `PromptService` (Task 4); `LlmClient` (Task 5). +- Produces: `interface RecommendInput { favorites, watched, library, previouslySuggested, count }`, `class MovieRecommenderTool extends SchemaLlmTool` (promptName `'movie-recommender'`) with `protected buildVariables` exposed for testing via the same pattern as prior tool tests; `interface SearchInput { query, count }`, `class MovieSearchTool extends SchemaLlmTool` (promptName `'movie-search'`). Both used by `RecommendationPipeline` (Task 16) and `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Write the failing test for `MovieRecommenderTool`** + +Create `test/app/tools/MovieRecommenderTool.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { MovieRecommenderTool } from '../../../src/app/tools/MovieRecommenderTool'; +import type { RunContext } from '../../../src/core/llm/RunContext'; +import type { Movie } from '../../../src/app/models'; + +function makeTool() { + return new MovieRecommenderTool( + {} as unknown as ConstructorParameters[0], + {} as unknown as ConstructorParameters[1] + ) as unknown as { + buildVariables(input: unknown, ctx: RunContext): Record; + parseOutput(raw: string, ctx: RunContext): unknown; + }; +} + +const movie = (name: string, year: number): Movie => ({ + name, + year, + imdbId: '', + tmdbId: '', + genres: [], + communityRating: 0, + isFavorite: false, + playCount: 0, +}); + +describe('MovieRecommenderTool.buildVariables', () => { + it('formats favorites/watched/library/previouslySuggested as readable lists', () => { + const tool = makeTool(); + const variables = tool.buildVariables( + { + favorites: [movie('Dune', 2021)], + watched: [], + library: [movie('Arrival', 2016)], + previouslySuggested: [], + count: 5, + }, + {} as RunContext + ); + + expect(variables).toEqual({ + count: 5, + favorites: '- Dune (2021)', + watched: '(none)', + library: '- Arrival (2016)', + previouslySuggested: '(none)', + }); + }); +}); + +describe('MovieRecommenderTool.parseOutput', () => { + it('validates suggestions against the movie suggestion schema', () => { + const tool = makeTool(); + const result = tool.parseOutput( + '[{"title":"Dune","year":2021,"reason":"sci-fi epic"}]', + {} as RunContext + ); + expect(result).toEqual([{ title: 'Dune', year: 2021, reason: 'sci-fi epic' }]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/app/tools/MovieRecommenderTool.test.ts` +Expected: FAIL — cannot find module `src/app/tools/MovieRecommenderTool`. + +- [ ] **Step 3: Implement `src/app/tools/MovieRecommenderTool.ts`** + +```ts +import { z } from 'zod'; +import { SchemaLlmTool } from '../../core/llm/SchemaLlmTool'; +import type { RunContext } from '../../core/llm/RunContext'; +import type { Movie, MovieSuggestion } from '../models'; + +export interface RecommendInput { + favorites: Movie[]; + watched: Movie[]; + library: Movie[]; + previouslySuggested: Movie[]; + count: number; +} + +const movieSuggestionSchema = z.object({ + title: z.string(), + year: z.number(), + reason: z.string(), +}); + +export class MovieRecommenderTool extends SchemaLlmTool { + protected readonly promptName = 'movie-recommender'; + protected readonly outputSchema = z.array(movieSuggestionSchema); + + protected buildVariables(input: RecommendInput, _ctx: RunContext): Record { + return { + count: input.count, + favorites: formatMovieList(input.favorites), + watched: formatMovieList(input.watched), + library: formatMovieList(input.library), + previouslySuggested: formatMovieList(input.previouslySuggested), + }; + } +} + +function formatMovieList(movies: Movie[]): string { + if (movies.length === 0) return '(none)'; + return movies.map((m) => `- ${m.name} (${m.year})`).join('\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/app/tools/MovieRecommenderTool.test.ts` +Expected: PASS + +- [ ] **Step 5: Write the failing test for `MovieSearchTool`** + +Create `test/app/tools/MovieSearchTool.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { MovieSearchTool } from '../../../src/app/tools/MovieSearchTool'; +import type { RunContext } from '../../../src/core/llm/RunContext'; + +describe('MovieSearchTool.buildVariables', () => { + it('passes the query and requested count through as prompt variables', () => { + const tool = new MovieSearchTool( + {} as unknown as ConstructorParameters[0], + {} as unknown as ConstructorParameters[1] + ) as unknown as { buildVariables(input: unknown, ctx: RunContext): Record }; + + const variables = tool.buildVariables({ query: 'sci-fi like Blade Runner', count: 5 }, {} as RunContext); + expect(variables).toEqual({ query: 'sci-fi like Blade Runner', count: 5 }); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `pnpm test -- test/app/tools/MovieSearchTool.test.ts` +Expected: FAIL — cannot find module `src/app/tools/MovieSearchTool`. + +- [ ] **Step 7: Implement `src/app/tools/MovieSearchTool.ts`** + +```ts +import { z } from 'zod'; +import { SchemaLlmTool } from '../../core/llm/SchemaLlmTool'; +import type { RunContext } from '../../core/llm/RunContext'; +import type { MovieSuggestion } from '../models'; + +export interface SearchInput { + query: string; + count: number; +} + +const movieSuggestionSchema = z.object({ + title: z.string(), + year: z.number(), + reason: z.string(), +}); + +export class MovieSearchTool extends SchemaLlmTool { + protected readonly promptName = 'movie-search'; + protected readonly outputSchema = z.array(movieSuggestionSchema); + + protected buildVariables(input: SearchInput, _ctx: RunContext): Record { + return { query: input.query, count: input.count }; + } +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `pnpm test -- test/app/tools/MovieSearchTool.test.ts` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/app/tools test/app/tools +git commit -m "Add MovieRecommenderTool and MovieSearchTool" +``` + +--- + +## Task 16: App pipeline — `RecommendationPipeline` + +**Files:** +- Create: `src/app/pipelines/RecommendationPipeline.ts` +- Test: `test/app/pipelines/RecommendationPipeline.test.ts` + +**Interfaces:** +- Consumes: `LlmPipeline`, `RunContext`, `Tracer` (Tasks 6-8); `JellyfinClient` (Task 12); `TmdbClient` (Task 13); `RadarrClient` (Task 14); `MovieRecommenderTool`, `RecommendInput`, `MovieSearchTool` (Task 15); `Movie`, `MovieSuggestion`, `Recommendation`, `TMDbMovie` (Task 11). +- Produces: `interface RecommendationRequest { count, useLlm }`, `interface RecommendationPipelineOptions { jellyfin, tmdb, radarr?, tool, searchTool, minRatedMovies, maxRetry }`, `class RecommendationPipeline extends LlmPipeline` with `constructor(tracer, logger, options)`, `async autoRequest(recommendations): Promise`, `async searchAsync(query, count): Promise`. Used by `RecommendationsController`, `RequestController` is independent (Task 17), `cron.ts` and `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Write the failing test** + +Create `test/app/pipelines/RecommendationPipeline.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { RecommendationPipeline } from '../../../src/app/pipelines/RecommendationPipeline'; +import type { JellyfinClient } from '../../../src/app/clients/jellyfin/JellyfinClient'; +import type { TmdbClient } from '../../../src/app/clients/tmdb/TmdbClient'; +import type { RadarrClient } from '../../../src/app/clients/radarr/RadarrClient'; +import type { MovieRecommenderTool } from '../../../src/app/tools/MovieRecommenderTool'; +import type { MovieSearchTool } from '../../../src/app/tools/MovieSearchTool'; +import type { Movie, TMDbMovie } from '../../../src/app/models'; +import type { Tracer, TraceHandle } from '../../../src/core/llm/Tracer'; +import type { Logger } from '../../../src/core/logging/logger'; + +const movie = (name: string, year: number, tmdbId: string): Movie => ({ + name, + year, + imdbId: '', + tmdbId, + genres: [], + communityRating: 0, + isFavorite: false, + playCount: 0, +}); + +function makeTracer() { + const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle; + const tracer = { + startTrace: vi.fn().mockReturnValue(trace), + flush: vi.fn().mockResolvedValue(undefined), + } as unknown as Tracer; + return tracer; +} + +describe('RecommendationPipeline.runAsync', () => { + it('uses favorites as the signal when there are enough, resolves suggestions via TMDb, and excludes library/Radarr matches', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]), + getRecentlyWatched: vi.fn(), + getAllMovies: vi.fn().mockResolvedValue([movie('Arrival', 2016, '2')]), + } as unknown as JellyfinClient; + + const searchMovie = vi + .fn() + .mockResolvedValueOnce({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie); + const tmdb = { searchMovie } as unknown as TmdbClient; + + const radarr = { + getExisting: vi.fn().mockResolvedValue([{ tmdbId: 4 }]), + } as unknown as RadarrClient; + + const tool = { + runAsync: vi.fn().mockResolvedValue([{ title: 'Interstellar', year: 2014, reason: 'space epic' }]), + } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + radarr, + tool, + searchTool, + minRatedMovies: 1, + maxRetry: 2, + }); + + const result = await pipeline.runAsync({ count: 1, useLlm: true }); + + expect(jellyfin.getRecentlyWatched).not.toHaveBeenCalled(); + expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'space epic' }]); + expect(tool.runAsync).toHaveBeenCalledWith( + expect.objectContaining({ favorites: [movie('Dune', 2021, '1')], count: 1 }), + expect.anything() + ); + }); + + it('falls back to recently-watched as the signal when favorites are below the threshold', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([]), + getRecentlyWatched: vi.fn().mockResolvedValue([movie('Arrival', 2016, '2')]), + getAllMovies: vi.fn().mockResolvedValue([]), + } as unknown as JellyfinClient; + const tmdb = { searchMovie: vi.fn().mockResolvedValue(undefined) } as unknown as TmdbClient; + const tool = { runAsync: vi.fn().mockResolvedValue([]) } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool, + searchTool, + minRatedMovies: 5, + maxRetry: 0, + }); + + await pipeline.runAsync({ count: 1, useLlm: true }); + + expect(jellyfin.getRecentlyWatched).toHaveBeenCalled(); + expect(tool.runAsync).toHaveBeenCalledWith( + expect.objectContaining({ watched: [movie('Arrival', 2016, '2')] }), + expect.anything() + ); + }); + + it('retries up to maxRetry times, excluding already-suggested movies, until count is reached', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]), + getRecentlyWatched: vi.fn(), + getAllMovies: vi.fn().mockResolvedValue([]), + } as unknown as JellyfinClient; + + const tmdb = { + searchMovie: vi + .fn() + .mockResolvedValueOnce({ id: 10, title: 'AlreadyInLibrary', year: 2000 } as TMDbMovie) + .mockResolvedValueOnce({ id: 11, title: 'NewPick', year: 2022 } as TMDbMovie), + } as unknown as TmdbClient; + + const tool = { + runAsync: vi + .fn() + .mockResolvedValueOnce([{ title: 'AlreadyInLibrary', year: 2000, reason: 'r1' }]) + .mockResolvedValueOnce([{ title: 'NewPick', year: 2022, reason: 'r2' }]), + } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool, + searchTool, + minRatedMovies: 1, + maxRetry: 2, + }); + + // Simulate id 10 already excluded (already in library) by pre-seeding via getAllMovies + jellyfin.getAllMovies = vi.fn().mockResolvedValue([movie('AlreadyInLibrary', 2000, '10')]); + + const result = await pipeline.runAsync({ count: 1, useLlm: true }); + + expect(tool.runAsync).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ title: 'NewPick', year: 2022, tmdbId: 11, reason: 'r2' }]); + }); + + it('uses the TMDb similarity/frequency fallback when useLlm is false', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]), + getRecentlyWatched: vi.fn(), + getAllMovies: vi.fn().mockResolvedValue([]), + } as unknown as JellyfinClient; + + const tmdb = { + getSimilar: vi.fn().mockResolvedValue([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]), + getRecommendations: vi + .fn() + .mockResolvedValue([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]), + searchMovie: vi.fn().mockResolvedValue({ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie), + } as unknown as TmdbClient; + + const tool = { runAsync: vi.fn() } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool, + searchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + const result = await pipeline.runAsync({ count: 1, useLlm: false }); + + expect(tool.runAsync).not.toHaveBeenCalled(); + expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]); + }); +}); + +describe('RecommendationPipeline.autoRequest', () => { + it('looks up and adds each recommendation to Radarr when Radarr is configured', async () => { + const radarr = { + lookup: vi.fn().mockResolvedValue({ + title: 'Dune', + tmdbId: 1, + qualityProfileId: 1, + rootFolderPath: '/movies', + monitored: true, + addOptions: { searchForMovie: true }, + }), + addMovie: vi.fn().mockResolvedValue(undefined), + } as unknown as RadarrClient; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin: {} as JellyfinClient, + tmdb: {} as TmdbClient, + radarr, + tool: {} as MovieRecommenderTool, + searchTool: {} as MovieSearchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + await pipeline.autoRequest([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]); + + expect(radarr.lookup).toHaveBeenCalledWith(1); + expect(radarr.addMovie).toHaveBeenCalled(); + }); + + it('does nothing when Radarr is not configured', async () => { + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin: {} as JellyfinClient, + tmdb: {} as TmdbClient, + tool: {} as MovieRecommenderTool, + searchTool: {} as MovieSearchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + await expect(pipeline.autoRequest([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }])).resolves.toBeUndefined(); + }); +}); + +describe('RecommendationPipeline.searchAsync', () => { + it('runs the search tool, resolves via TMDb, and excludes library matches', async () => { + const jellyfin = { + getAllMovies: vi.fn().mockResolvedValue([movie('AlreadyHave', 1999, '5')]), + } as unknown as JellyfinClient; + const tmdb = { + searchMovie: vi.fn().mockResolvedValue({ id: 6, title: 'New Find', year: 2020 } as TMDbMovie), + } as unknown as TmdbClient; + const searchTool = { + runAsync: vi.fn().mockResolvedValue([{ title: 'New Find', year: 2020, reason: 'matches query' }]), + } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool: {} as MovieRecommenderTool, + searchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + const result = await pipeline.searchAsync('sci-fi like Dune', 5); + + expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]); + expect(searchTool.runAsync).toHaveBeenCalledWith({ query: 'sci-fi like Dune', count: 5 }, expect.anything()); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/app/pipelines/RecommendationPipeline.test.ts` +Expected: FAIL — cannot find module `src/app/pipelines/RecommendationPipeline`. + +- [ ] **Step 3: Implement `src/app/pipelines/RecommendationPipeline.ts`** + +```ts +import { LlmPipeline } from '../../core/llm/LlmPipeline'; +import type { RunContext } from '../../core/llm/RunContext'; +import type { JellyfinClient } from '../clients/jellyfin/JellyfinClient'; +import type { TmdbClient } from '../clients/tmdb/TmdbClient'; +import type { RadarrClient } from '../clients/radarr/RadarrClient'; +import type { MovieRecommenderTool } from '../tools/MovieRecommenderTool'; +import type { MovieSearchTool } from '../tools/MovieSearchTool'; +import type { Movie, MovieSuggestion, Recommendation, TMDbMovie } from '../models'; +import type { Tracer } from '../../core/llm/Tracer'; +import type { Logger } from '../../core/logging/logger'; + +export interface RecommendationRequest { + count: number; + useLlm: boolean; +} + +export interface RecommendationPipelineOptions { + jellyfin: JellyfinClient; + tmdb: TmdbClient; + radarr?: RadarrClient; + tool: MovieRecommenderTool; + searchTool: MovieSearchTool; + minRatedMovies: number; + maxRetry: number; +} + +export class RecommendationPipeline extends LlmPipeline { + protected readonly name = 'recommendation-pipeline'; + + constructor( + tracer: Tracer, + logger: Logger, + private readonly options: RecommendationPipelineOptions + ) { + super(tracer, logger); + } + + protected async runInternal(input: RecommendationRequest, ctx: RunContext): Promise { + const favorites = await this.options.jellyfin.getFavoriteMovies(); + const useFavoritesAsSignal = favorites.length >= this.options.minRatedMovies; + const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched(); + const signal = useFavoritesAsSignal ? favorites : watched; + + const library = await this.options.jellyfin.getAllMovies(); + const exclude = new Set(toTmdbIds(library)); + + if (this.options.radarr) { + const existing = await this.options.radarr.getExisting(); + for (const m of existing) exclude.add(m.tmdbId); + } + + const recommendations: Recommendation[] = []; + const seen: Movie[] = []; + + for (let attempt = 0; attempt <= this.options.maxRetry && recommendations.length < input.count; attempt++) { + const remaining = input.count - recommendations.length; + + let suggestions: MovieSuggestion[]; + if (input.useLlm) { + suggestions = await this.runTool( + this.options.tool, + { favorites, watched, library, previouslySuggested: seen, count: remaining }, + ctx + ); + } else { + suggestions = await this.tmdbFallback(signal, exclude, remaining); + } + + if (suggestions.length === 0) break; + + for (const suggestion of suggestions) { + if (recommendations.length >= input.count) break; + const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); + if (!resolved) continue; + seen.push(toSeenMovie(resolved)); + if (exclude.has(resolved.id)) continue; + exclude.add(resolved.id); + recommendations.push({ + title: resolved.title, + year: resolved.year, + tmdbId: resolved.id, + reason: suggestion.reason, + }); + } + } + + return recommendations; + } + + async autoRequest(recommendations: Recommendation[]): Promise { + if (!this.options.radarr) return; + for (const rec of recommendations) { + const movie = await this.options.radarr.lookup(rec.tmdbId); + await this.options.radarr.addMovie(movie); + } + } + + async searchAsync(query: string, count: number): Promise { + const trace = this.tracer.startTrace(`${this.name}-search`); + const ctx: RunContext = { trace, logger: this.logger }; + try { + const library = await this.options.jellyfin.getAllMovies(); + const exclude = new Set(toTmdbIds(library)); + const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx); + + const recommendations: Recommendation[] = []; + for (const suggestion of suggestions) { + if (recommendations.length >= count) break; + const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); + if (!resolved || exclude.has(resolved.id)) continue; + exclude.add(resolved.id); + recommendations.push({ + title: resolved.title, + year: resolved.year, + tmdbId: resolved.id, + reason: suggestion.reason, + }); + } + return recommendations; + } finally { + await this.tracer.flush(); + } + } + + private async tmdbFallback(signals: Movie[], exclude: Set, count: number): Promise { + const freq = new Map(); + const meta = new Map(); + + for (const m of signals) { + const tmdbId = Number(m.tmdbId); + if (!tmdbId) continue; + const [similar, recommended] = await Promise.all([ + this.options.tmdb.getSimilar(tmdbId), + this.options.tmdb.getRecommendations(tmdbId), + ]); + for (const tm of [...similar, ...recommended]) { + if (!exclude.has(tm.id)) { + freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1); + meta.set(tm.id, tm); + } + } + } + + const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]); + const suggestions: MovieSuggestion[] = []; + for (const [id] of sorted) { + if (suggestions.length >= count) break; + const tm = meta.get(id)!; + suggestions.push({ title: tm.title, year: tm.year, reason: 'Recommended by TMDb' }); + } + return suggestions; + } +} + +function toTmdbIds(movies: Movie[]): number[] { + return movies.map((m) => Number(m.tmdbId)).filter((id) => Number.isFinite(id) && id !== 0); +} + +function toSeenMovie(resolved: TMDbMovie): Movie { + return { + name: resolved.title, + year: resolved.year, + imdbId: '', + tmdbId: String(resolved.id), + genres: [], + communityRating: 0, + isFavorite: false, + playCount: 0, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/app/pipelines/RecommendationPipeline.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/app/pipelines test/app/pipelines +git commit -m "Add RecommendationPipeline" +``` + +--- + +## Task 17: App controllers + +**Files:** +- Create: `src/app/controllers/HealthController.ts` +- Create: `src/app/controllers/RecommendationsController.ts` +- Create: `src/app/controllers/RequestController.ts` +- Create: `src/app/controllers/WebhookController.ts` +- Test: `test/app/controllers/HealthController.test.ts` +- Test: `test/app/controllers/RecommendationsController.test.ts` +- Test: `test/app/controllers/RequestController.test.ts` +- Test: `test/app/controllers/WebhookController.test.ts` + +**Interfaces:** +- Consumes: `Controller`, `buildServer` (Task 9); `AppConfig` (Task 11); `RecommendationPipeline` (Task 16); `RadarrClient` (Task 14); `PromptService` (Task 4). +- Produces: `class HealthController extends Controller`, `class RecommendationsController extends Controller`, `class RequestController extends Controller`, `class WebhookController extends Controller`, each with `constructor(...)` and `register(app)`. Used by `src/app/index.ts` (Task 18). + +- [ ] **Step 1: Write the failing test for `HealthController`** + +Create `test/app/controllers/HealthController.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import Fastify from 'fastify'; +import { HealthController } from '../../../src/app/controllers/HealthController'; +import type { AppConfig } from '../../../src/app/config/AppConfig'; + +describe('HealthController', () => { + it('reports status ok with llm/radarr flags from config', async () => { + const app = Fastify(); + new HealthController({ LLM_ENABLED: true, RADARR_ENABLED: false } as AppConfig).register(app); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: 'ok', llm: true, radarr: false }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test -- test/app/controllers/HealthController.test.ts` +Expected: FAIL — cannot find module `src/app/controllers/HealthController`. + +- [ ] **Step 3: Implement `src/app/controllers/HealthController.ts`** + +```ts +import type { FastifyInstance } from 'fastify'; +import { Controller } from '../../core/server/Controller'; +import type { AppConfig } from '../config/AppConfig'; + +export class HealthController extends Controller { + constructor(private readonly config: AppConfig) { + super(); + } + + register(app: FastifyInstance): void { + app.get('/health', async () => ({ + status: 'ok', + llm: this.config.LLM_ENABLED, + radarr: this.config.RADARR_ENABLED, + })); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test -- test/app/controllers/HealthController.test.ts` +Expected: PASS + +- [ ] **Step 5: Write the failing test for `RecommendationsController`** + +Create `test/app/controllers/RecommendationsController.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import Fastify from 'fastify'; +import { RecommendationsController } from '../../../src/app/controllers/RecommendationsController'; +import type { AppConfig } from '../../../src/app/config/AppConfig'; +import type { RecommendationPipeline } from '../../../src/app/pipelines/RecommendationPipeline'; + +function makeApp(config: Partial, pipeline: Partial) { + const app = Fastify(); + new RecommendationsController(config as AppConfig, pipeline as RecommendationPipeline).register(app); + return app; +} + +describe('RecommendationsController', () => { + it('GET /recommendations runs the pipeline with the configured count and LLM flag', async () => { + const runAsync = vi.fn().mockResolvedValue([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]); + const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: true }, { runAsync } as unknown as RecommendationPipeline); + + const res = await app.inject({ method: 'GET', url: '/recommendations' }); + expect(res.statusCode).toBe(200); + expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }); + }); + + it('GET /recommendations?count= and force_llm/force_tmdb override config', async () => { + const runAsync = vi.fn().mockResolvedValue([]); + const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: false }, { runAsync } as unknown as RecommendationPipeline); + + await app.inject({ method: 'GET', url: '/recommendations?count=3&force_llm=true' }); + expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true }); + }); + + it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => { + const recs = [{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]; + const runAsync = vi.fn().mockResolvedValue(recs); + const autoRequest = vi.fn().mockResolvedValue(undefined); + const app = makeApp( + { RECOMMEND_COUNT: 10, LLM_ENABLED: true, RADARR_ENABLED: true }, + { runAsync, autoRequest } as unknown as RecommendationPipeline + ); + + const res = await app.inject({ method: 'POST', url: '/recommendations/trigger' }); + expect(res.statusCode).toBe(200); + expect(autoRequest).toHaveBeenCalledWith(recs); + }); + + it('POST /recommendations/search requires LLM to be enabled and a query body', async () => { + const app = makeApp({ LLM_ENABLED: false }, {} as RecommendationPipeline); + const res = await app.inject({ method: 'POST', url: '/recommendations/search', payload: { query: 'x' } }); + expect(res.statusCode).toBe(400); + }); + + it('POST /recommendations/search runs pipeline.searchAsync and returns results', async () => { + const searchAsync = vi.fn().mockResolvedValue([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]); + const app = makeApp( + { LLM_ENABLED: true, RECOMMEND_COUNT: 10 }, + { searchAsync } as unknown as RecommendationPipeline + ); + + const res = await app.inject({ + method: 'POST', + url: '/recommendations/search', + payload: { query: 'sci-fi like Blade Runner' }, + }); + expect(res.statusCode).toBe(200); + expect(searchAsync).toHaveBeenCalledWith('sci-fi like Blade Runner', 10); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `pnpm test -- test/app/controllers/RecommendationsController.test.ts` +Expected: FAIL — cannot find module `src/app/controllers/RecommendationsController`. + +- [ ] **Step 7: Implement `src/app/controllers/RecommendationsController.ts`** + +```ts +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { Controller } from '../../core/server/Controller'; +import type { AppConfig } from '../config/AppConfig'; +import type { RecommendationPipeline } from '../pipelines/RecommendationPipeline'; + +interface ListQuery { + count?: string; + force_llm?: string; + force_tmdb?: string; +} + +interface SearchBody { + query?: string; +} + +export class RecommendationsController extends Controller { + constructor( + private readonly config: AppConfig, + private readonly pipeline: RecommendationPipeline + ) { + super(); + } + + register(app: FastifyInstance): void { + app.get('/recommendations', (request, reply) => this.handleList(request as FastifyRequest<{ Querystring: ListQuery }>, reply)); + app.post('/recommendations/search', (request, reply) => + this.handleSearch(request as FastifyRequest<{ Body: SearchBody }>, reply) + ); + app.post('/recommendations/trigger', (request, reply) => this.handleTrigger(request, reply)); + } + + private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) { + const count = request.query.count ? Number(request.query.count) : this.config.RECOMMEND_COUNT; + const useLlm = + request.query.force_tmdb === 'true' + ? false + : request.query.force_llm === 'true' + ? true + : this.config.LLM_ENABLED; + + try { + const recommendations = await this.pipeline.runAsync({ count, useLlm }); + reply.send(recommendations); + } catch (err) { + reply.code(500).send({ error: String(err) }); + } + } + + private async handleTrigger(_request: FastifyRequest, reply: FastifyReply) { + try { + const recommendations = await this.pipeline.runAsync({ + count: this.config.RECOMMEND_COUNT, + useLlm: this.config.LLM_ENABLED, + }); + if (this.config.RADARR_ENABLED) { + await this.pipeline.autoRequest(recommendations); + } + reply.send(recommendations); + } catch (err) { + reply.code(500).send({ error: String(err) }); + } + } + + private async handleSearch(request: FastifyRequest<{ Body: SearchBody }>, reply: FastifyReply) { + if (!this.config.LLM_ENABLED) { + reply.code(400).send({ error: 'LLM is not enabled' }); + return; + } + const query = request.body?.query; + if (!query) { + reply.code(400).send({ error: 'body must be {"query": "..."}' }); + return; + } + try { + const recommendations = await this.pipeline.searchAsync(query, this.config.RECOMMEND_COUNT); + reply.send(recommendations); + } catch (err) { + reply.code(500).send({ error: String(err) }); + } + } +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `pnpm test -- test/app/controllers/RecommendationsController.test.ts` +Expected: PASS + +- [ ] **Step 9: Write the failing test for `RequestController`** + +Create `test/app/controllers/RequestController.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import Fastify from 'fastify'; +import { RequestController } from '../../../src/app/controllers/RequestController'; +import type { AppConfig } from '../../../src/app/config/AppConfig'; +import type { RadarrClient } from '../../../src/app/clients/radarr/RadarrClient'; + +describe('RequestController', () => { + it('returns 400 when Radarr is not enabled', async () => { + const app = Fastify(); + new RequestController({ RADARR_ENABLED: false } as AppConfig, undefined).register(app); + + const res = await app.inject({ method: 'POST', url: '/recommendations/request', payload: { tmdb_id: 1 } }); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when tmdb_id is missing', async () => { + const app = Fastify(); + new RequestController({ RADARR_ENABLED: true } as AppConfig, {} as RadarrClient).register(app); + + const res = await app.inject({ method: 'POST', url: '/recommendations/request', payload: {} }); + expect(res.statusCode).toBe(400); + }); + + it('looks up and adds the movie, returning its title', async () => { + const radarr = { + lookup: vi.fn().mockResolvedValue({ title: 'Dune', tmdbId: 1 }), + addMovie: vi.fn().mockResolvedValue(undefined), + } as unknown as RadarrClient; + const app = Fastify(); + new RequestController({ RADARR_ENABLED: true } as AppConfig, radarr).register(app); + + const res = await app.inject({ method: 'POST', url: '/recommendations/request', payload: { tmdb_id: 1 } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ requested: true, title: 'Dune' }); + }); +}); +``` + +- [ ] **Step 10: Run test to verify it fails** + +Run: `pnpm test -- test/app/controllers/RequestController.test.ts` +Expected: FAIL — cannot find module `src/app/controllers/RequestController`. + +- [ ] **Step 11: Implement `src/app/controllers/RequestController.ts`** + +```ts +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { Controller } from '../../core/server/Controller'; +import type { AppConfig } from '../config/AppConfig'; +import type { RadarrClient } from '../clients/radarr/RadarrClient'; + +interface RequestBody { + tmdb_id?: number; +} + +export class RequestController extends Controller { + constructor( + private readonly config: AppConfig, + private readonly radarr: RadarrClient | undefined + ) { + super(); + } + + register(app: FastifyInstance): void { + app.post('/recommendations/request', (request, reply) => + this.handle(request as FastifyRequest<{ Body: RequestBody }>, reply) + ); + } + + private async handle(request: FastifyRequest<{ Body: RequestBody }>, reply: FastifyReply) { + if (!this.config.RADARR_ENABLED || !this.radarr) { + reply.code(400).send({ error: 'Radarr is not enabled' }); + return; + } + const tmdbId = request.body?.tmdb_id; + if (!tmdbId) { + reply.code(400).send({ error: 'body must be {"tmdb_id": 12345}' }); + return; + } + try { + const movie = await this.radarr.lookup(tmdbId); + await this.radarr.addMovie(movie); + reply.send({ requested: true, title: movie.title }); + } catch (err) { + reply.code(500).send({ error: String(err) }); + } + } +} +``` + +- [ ] **Step 12: Run test to verify it passes** + +Run: `pnpm test -- test/app/controllers/RequestController.test.ts` +Expected: PASS + +- [ ] **Step 13: Write the failing test for `WebhookController`** + +Create `test/app/controllers/WebhookController.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import Fastify from 'fastify'; +import { WebhookController } from '../../../src/app/controllers/WebhookController'; +import type { PromptService } from '../../../src/core/langfuse/PromptService'; + +describe('WebhookController', () => { + it('invalidates a single prompt when promptName is given', async () => { + const invalidate = vi.fn(); + const app = Fastify(); + new WebhookController({ invalidate } as unknown as PromptService).register(app); + + const res = await app.inject({ + method: 'POST', + url: '/webhooks/langfuse/invalidate', + payload: { promptName: 'movie-recommender' }, + }); + expect(res.statusCode).toBe(200); + expect(invalidate).toHaveBeenCalledWith('movie-recommender'); + expect(res.json()).toEqual({ invalidated: true, promptName: 'movie-recommender' }); + }); + + it('invalidates the whole cache when no promptName is given', async () => { + const invalidate = vi.fn(); + const app = Fastify(); + new WebhookController({ invalidate } as unknown as PromptService).register(app); + + const res = await app.inject({ method: 'POST', url: '/webhooks/langfuse/invalidate', payload: {} }); + expect(invalidate).toHaveBeenCalledWith(undefined); + expect(res.json()).toEqual({ invalidated: true, promptName: 'all' }); + }); +}); +``` + +- [ ] **Step 14: Run test to verify it fails** + +Run: `pnpm test -- test/app/controllers/WebhookController.test.ts` +Expected: FAIL — cannot find module `src/app/controllers/WebhookController`. + +- [ ] **Step 15: Implement `src/app/controllers/WebhookController.ts`** + +```ts +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { Controller } from '../../core/server/Controller'; +import type { PromptService } from '../../core/langfuse/PromptService'; + +interface InvalidateBody { + promptName?: string; +} + +export class WebhookController extends Controller { + constructor(private readonly promptService: PromptService) { + super(); + } + + register(app: FastifyInstance): void { + app.post('/webhooks/langfuse/invalidate', (request, reply) => + this.handle(request as FastifyRequest<{ Body: InvalidateBody }>, reply) + ); + } + + private async handle(request: FastifyRequest<{ Body: InvalidateBody }>, reply: FastifyReply) { + const promptName = request.body?.promptName; + this.promptService.invalidate(promptName); + reply.send({ invalidated: true, promptName: promptName ?? 'all' }); + } +} +``` + +- [ ] **Step 16: Run test to verify it passes** + +Run: `pnpm test -- test/app/controllers/WebhookController.test.ts` +Expected: PASS + +- [ ] **Step 17: Run the full test suite** + +Run: `pnpm test` +Expected: All tests pass. + +- [ ] **Step 18: Commit** + +```bash +git add src/app/controllers test/app/controllers +git commit -m "Add Health/Recommendations/Request/Webhook controllers" +``` + +--- + +## Task 18: App wiring — `cron.ts` and `index.ts` + +**Files:** +- Create: `src/app/cron.ts` +- Create: `src/app/index.ts` + +**Interfaces:** +- Consumes: everything produced in Tasks 1-17. +- Produces: `function scheduleCron(config, pipeline, logger): void`; the process entrypoint `main()` (not exported — invoked at module load). + +- [ ] **Step 1: Implement `src/app/cron.ts`** + +```ts +import cron from 'node-cron'; +import type { Logger } from '../core/logging/logger'; +import type { AppConfig } from './config/AppConfig'; +import type { RecommendationPipeline } from './pipelines/RecommendationPipeline'; + +export function scheduleCron(config: AppConfig, pipeline: RecommendationPipeline, logger: Logger): void { + if (!config.CRON_ENABLED) return; + + cron.schedule(config.CRON_SCHEDULE, async () => { + logger.info('Cron: running recommendation pipeline'); + try { + const recommendations = await pipeline.runAsync({ + count: config.RECOMMEND_COUNT, + useLlm: config.LLM_ENABLED, + }); + if (config.RADARR_ENABLED) { + await pipeline.autoRequest(recommendations); + } + logger.info({ count: recommendations.length }, 'Cron: pipeline finished'); + } catch (err) { + logger.error({ err }, 'Cron: pipeline failed'); + } + }); + + logger.info({ schedule: config.CRON_SCHEDULE }, 'Cron scheduled'); +} +``` + +- [ ] **Step 2: Implement `src/app/index.ts`** + +```ts +import 'dotenv/config'; +import { loadAppConfig } from './config/AppConfig'; +import { createLogger } from '../core/logging/logger'; +import { LangfuseTracer } from '../core/langfuse/LangfuseTracer'; +import { LangfusePromptGateway } from '../core/langfuse/LangfusePromptGateway'; +import { PromptService } from '../core/langfuse/PromptService'; +import { LlmClient } from '../core/llm/LlmClient'; +import { buildServer } from '../core/server/buildServer'; +import { JellyfinClient } from './clients/jellyfin/JellyfinClient'; +import { TmdbClient } from './clients/tmdb/TmdbClient'; +import { RadarrClient } from './clients/radarr/RadarrClient'; +import { MovieRecommenderTool } from './tools/MovieRecommenderTool'; +import { MovieSearchTool } from './tools/MovieSearchTool'; +import { RecommendationPipeline } from './pipelines/RecommendationPipeline'; +import { HealthController } from './controllers/HealthController'; +import { RecommendationsController } from './controllers/RecommendationsController'; +import { RequestController } from './controllers/RequestController'; +import { WebhookController } from './controllers/WebhookController'; +import { scheduleCron } from './cron'; + +async function main(): Promise { + const config = loadAppConfig(); + const logger = createLogger('sugarr'); + + const langfuseOpts = { + publicKey: config.LANGFUSE_PUBLIC_KEY, + secretKey: config.LANGFUSE_SECRET_KEY, + baseUrl: config.LANGFUSE_BASE_URL, + }; + const tracer = new LangfuseTracer(langfuseOpts); + const promptGateway = new LangfusePromptGateway(langfuseOpts); + const promptService = new PromptService(promptGateway, config.LANGFUSE_PROMPT_CACHE_ENABLED); + const llmClient = new LlmClient({ baseUrl: config.LLM_BASE_URL, apiKey: config.LLM_API_KEY }); + + const jellyfin = new JellyfinClient(config.JELLYFIN_URL, config.JELLYFIN_API_KEY, config.JELLYFIN_USER_ID); + const tmdb = new TmdbClient(config.TMDB_API_KEY); + const radarr = config.RADARR_ENABLED + ? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER) + : undefined; + + const recommenderTool = new MovieRecommenderTool(promptService, llmClient); + const searchTool = new MovieSearchTool(promptService, llmClient); + + const pipeline = new RecommendationPipeline(tracer, logger, { + jellyfin, + tmdb, + radarr, + tool: recommenderTool, + searchTool, + minRatedMovies: config.MIN_RATED_MOVIES, + maxRetry: config.RECOMMEND_MAX_RETRY, + }); + + logger.info( + { + jellyfin: config.JELLYFIN_URL, + llmEnabled: config.LLM_ENABLED, + radarrEnabled: config.RADARR_ENABLED, + cronEnabled: config.CRON_ENABLED, + apiEnabled: config.API_ENABLED, + }, + 'sugarr starting' + ); + + scheduleCron(config, pipeline, logger); + + if (config.API_ENABLED) { + const app = buildServer({ + secretKey: config.SECRET_KEY, + controllers: [ + new HealthController(config), + new RecommendationsController(config, pipeline), + new RequestController(config, radarr), + new WebhookController(promptService), + ], + }); + await app.listen({ port: config.API_PORT, host: '0.0.0.0' }); + logger.info({ port: config.API_PORT }, 'HTTP API listening'); + } +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 3: Type-check and build** + +Run: `pnpm build` +Expected: Compiles with no errors, `dist/app/index.js` produced. + +- [ ] **Step 4: Manual smoke test against real or local services** + +Ensure `.env` (repo root) has valid `JELLYFIN_URL`/`JELLYFIN_API_KEY`/`JELLYFIN_USER_ID`/`TMDB_API_KEY`/`LANGFUSE_PUBLIC_KEY`/`LANGFUSE_SECRET_KEY`/`LANGFUSE_BASE_URL` values, and that a Langfuse prompt named `movie-recommender` (chat type, with a `config.model` field) exists if `LLM_ENABLED=true`. + +Run: `pnpm dev` +Expected: Log line `HTTP API listening` with the configured port, no crash. + +In a second terminal: +Run: `curl -s http://localhost:8080/health` +Expected: `{"status":"ok","llm":false,"radarr":false}` (or `true` values matching your `.env`). + +Run: `curl -s "http://localhost:8080/recommendations?force_tmdb=true&count=3"` +Expected: A JSON array of up to 3 recommendations (TMDb-fallback path, doesn't require Langfuse). + +Stop the dev server (Ctrl+C). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/cron.ts src/app/index.ts +git commit -m "Wire up cron scheduling and the app entrypoint" +``` + +--- + +## Task 19: Docker packaging + +**Files:** +- Create: `Dockerfile.node` +- Create: `docker-compose.node.yml` +- Modify: `.env.example` (create if it doesn't already exist at repo root, adding the four `LANGFUSE_*` vars alongside the existing Go app vars) + +**Interfaces:** +- Consumes: `package.json` (Task 1), `src/app/index.ts` (Task 18). +- Produces: a buildable Docker image and compose service `sugarr-node`, independent of the existing Go `Dockerfile`/`docker-compose.yml`. + +- [ ] **Step 1: Create `Dockerfile.node`** + +```dockerfile +FROM node:22-alpine AS build +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY tsconfig.json ./ +COPY src ./src +RUN pnpm build + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod +COPY --from=build /app/dist ./dist +ENTRYPOINT ["node", "dist/app/index.js"] +``` + +- [ ] **Step 2: Create `docker-compose.node.yml`** + +```yaml +services: + sugarr-node: + build: + context: . + dockerfile: Dockerfile.node + container_name: sugarr-node + restart: unless-stopped + ports: + - "${API_PORT}:${API_PORT}" + environment: + JELLYFIN_URL: ${JELLYFIN_URL} + JELLYFIN_API_KEY: ${JELLYFIN_API_KEY} + JELLYFIN_USER_ID: ${JELLYFIN_USER_ID} + TMDB_API_KEY: ${TMDB_API_KEY} + LLM_ENABLED: ${LLM_ENABLED} + LLM_BASE_URL: ${LLM_BASE_URL} + LLM_MODEL: ${LLM_MODEL} + LLM_API_KEY: ${LLM_API_KEY} + RADARR_ENABLED: ${RADARR_ENABLED} + RADARR_URL: ${RADARR_URL} + RADARR_API_KEY: ${RADARR_API_KEY} + RADARR_QUALITY_PROFILE_ID: ${RADARR_QUALITY_PROFILE_ID} + RADARR_ROOT_FOLDER: ${RADARR_ROOT_FOLDER} + CRON_ENABLED: ${CRON_ENABLED} + CRON_SCHEDULE: ${CRON_SCHEDULE} + API_ENABLED: ${API_ENABLED} + API_PORT: ${API_PORT} + SECRET_KEY: ${SECRET_KEY} + RECOMMEND_COUNT: ${RECOMMEND_COUNT} + RECOMMEND_MAX_RETRY: ${RECOMMEND_MAX_RETRY} + MIN_RATED_MOVIES: ${MIN_RATED_MOVIES} + LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY} + LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY} + LANGFUSE_BASE_URL: ${LANGFUSE_BASE_URL} + LANGFUSE_PROMPT_CACHE_ENABLED: ${LANGFUSE_PROMPT_CACHE_ENABLED} + networks: + - media + +networks: + media: +``` + +- [ ] **Step 3: Create `.env.example`** + +``` +# Required +JELLYFIN_URL= +JELLYFIN_API_KEY= +JELLYFIN_USER_ID= +TMDB_API_KEY= + +# LLM (disabled by default — works with Ollama, OpenAI, LM Studio, etc.) +LLM_ENABLED=false +LLM_BASE_URL=http://localhost:11434/v1 +LLM_API_KEY= +LLM_MODEL=llama3 + +# Radarr (disabled by default) +RADARR_ENABLED=false +RADARR_URL= +RADARR_API_KEY= +RADARR_QUALITY_PROFILE_ID=1 +RADARR_ROOT_FOLDER=/movies + +# Cron auto-run (disabled by default) +CRON_ENABLED=false +CRON_SCHEDULE=0 3 * * * + +# HTTP API (enabled by default) +API_ENABLED=true +API_PORT=8080 +SECRET_KEY= + +# Tuning +RECOMMEND_COUNT=10 +RECOMMEND_MAX_RETRY=3 +MIN_RATED_MOVIES=5 + +# Langfuse (required by the TypeScript app) +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= +LANGFUSE_BASE_URL= +LANGFUSE_PROMPT_CACHE_ENABLED=true +``` + +- [ ] **Step 4: Build the Docker image** + +Run: `docker compose -f docker-compose.node.yml build` +Expected: Image builds successfully with no errors. + +- [ ] **Step 5: Manual verification** + +Run: `docker compose -f docker-compose.node.yml up -d` +Expected: Container starts and stays up. + +Run: `docker compose -f docker-compose.node.yml logs sugarr-node --tail 50` +Expected: Log line `HTTP API listening`. + +Run: `curl -s http://localhost:${API_PORT}/health` +Expected: `{"status":"ok",...}` response. + +Run: `docker compose -f docker-compose.node.yml down` +Expected: Container stops cleanly. + +- [ ] **Step 6: Commit** + +```bash +git add Dockerfile.node docker-compose.node.yml .env.example +git commit -m "Add Docker packaging for the TypeScript app" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** `ApiClient`/`Endpoint` (Task 3), `LlmTool`/`SchemaLlmTool`/`TextLlmTool` (Tasks 6-7), `LlmPipeline` (Task 8), cached `PromptService` + cache-disable env + webhook invalidation (Tasks 4, 17), core/app folder split (all tasks), Jellyfin/TMDb/Radarr clients (Tasks 12-14), recommendation pipeline with retry + TMDb fallback + auto-request + search (Task 16), identical HTTP routes + bearer auth (Tasks 9, 17), cron (Task 18), Docker (Task 19) — all covered. +- **Placeholder scan:** none found — every step contains complete, concrete code. +- **Type consistency:** `RunContext`, `TraceHandle`, `Tracer`, `PromptService`, `LlmClient`, `Movie`/`MovieSuggestion`/`Recommendation`/`TMDbMovie`, and every controller/client/tool/pipeline constructor signature are used identically across the tasks that produce and consume them.