mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
Fix final review findings: key leak, model fallback, error resilience, docs
- ApiClient: strip query string from ApiError messages so API keys (e.g. TMDb's api_key query param) never leak into 500 response bodies or logs. - LlmTool: accept an optional defaultModel, wired from AppConfig's LLM_MODEL, so Langfuse prompts that omit a model config fall back to the configured LLM rather than silently requesting gpt-4o-mini. - RecommendationPipeline: restore the Go original's graceful degradation — library/Radarr fetch failures, per-suggestion TMDb resolution failures, and per-signal TMDb similarity/recommendation failures are now logged and skipped instead of aborting the whole run (applies to both runInternal and searchAsync). - RecommendationsController: validate the ?count= query param (must be a positive integer) instead of silently producing NaN. - Commit the Langfuse prompt definitions (langfuse/*.prompt.json) and add a README covering setup, prompt seeding, cache invalidation, and the HTTP API surface — the branch was not otherwise runnable by anyone but the author.
This commit is contained in:
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# sugarr
|
||||||
|
|
||||||
|
Movie-recommendation companion for Jellyfin: pulls your favorite/recently-watched
|
||||||
|
movies, gets suggestions from an LLM (via Langfuse-managed prompts) or a TMDb
|
||||||
|
similarity/frequency fallback, resolves them against TMDb, excludes anything
|
||||||
|
already in your library or Radarr, and can auto-request the rest via Radarr.
|
||||||
|
Runs on a cron schedule and/or as an HTTP API.
|
||||||
|
|
||||||
|
This repo currently has two implementations:
|
||||||
|
|
||||||
|
- **`cmd/`, `internal/`** — the original Go implementation.
|
||||||
|
- **`src/`** — a TypeScript/OOP rewrite (Node + Fastify + Langfuse), the one
|
||||||
|
documented below. See `docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md`
|
||||||
|
and `docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md` for the
|
||||||
|
design and implementation plan.
|
||||||
|
|
||||||
|
## Running the TypeScript app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp .env.example .env # fill in the values below
|
||||||
|
pnpm dev # or: pnpm build && pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required environment variables
|
||||||
|
|
||||||
|
See `.env.example` for the full list with defaults. At minimum you need:
|
||||||
|
|
||||||
|
- `JELLYFIN_URL`, `JELLYFIN_API_KEY`, `JELLYFIN_USER_ID`, `TMDB_API_KEY`
|
||||||
|
- `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL` — the app
|
||||||
|
fetches its LLM prompts from Langfuse at runtime (see below), so these are
|
||||||
|
required even if `LLM_ENABLED=false` (only the recommend/search endpoints
|
||||||
|
actually call Langfuse; if you never enable the LLM path you can point
|
||||||
|
these at any reachable Langfuse instance).
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.node.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `Dockerfile.node` / `docker-compose.node.yml`, kept separate from the Go
|
||||||
|
app's `Dockerfile` / `docker-compose.yml`.
|
||||||
|
|
||||||
|
## Seeding Langfuse prompts
|
||||||
|
|
||||||
|
The app fetches two chat prompts from Langfuse by name — `movie-recommender`
|
||||||
|
and `movie-search` — compiles them with per-request variables, and reads
|
||||||
|
model/temperature/etc. from each prompt version's `config` field. Ready-to-import
|
||||||
|
definitions are in `langfuse/`:
|
||||||
|
|
||||||
|
- `langfuse/movie-recommender.prompt.json`
|
||||||
|
- `langfuse/movie-search.prompt.json`
|
||||||
|
|
||||||
|
Create each one in your Langfuse project (UI: **Prompts → New prompt**, type
|
||||||
|
**Chat**; or via the Langfuse API/SDK) using the `prompt` array as the chat
|
||||||
|
messages and the `config` object as the prompt's config. Variables use
|
||||||
|
`{{name}}` interpolation:
|
||||||
|
|
||||||
|
- `movie-recommender`: `count`, `favorites`, `watched`, `library`, `previouslySuggested`
|
||||||
|
- `movie-search`: `query`, `count`
|
||||||
|
|
||||||
|
## Prompt caching and invalidation
|
||||||
|
|
||||||
|
Compiled prompts are cached in memory per process (`LANGFUSE_PROMPT_CACHE_ENABLED`,
|
||||||
|
default `true`; set to `false` to always fetch fresh from Langfuse — useful
|
||||||
|
while iterating on a prompt). To invalidate the cache after updating a prompt
|
||||||
|
in Langfuse without restarting the app, call:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /webhooks/langfuse/invalidate
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <SECRET_KEY> # if SECRET_KEY is set
|
||||||
|
|
||||||
|
{"promptName": "movie-recommender"} # or {} / omit body to clear the whole cache
|
||||||
|
```
|
||||||
|
|
||||||
|
This isn't wired to a Langfuse-native push webhook (self-hosted Langfuse has
|
||||||
|
no built-in "prompt updated" event) — call it manually, from CI after a prompt
|
||||||
|
change, or from Langfuse Cloud's webhook feature if you have one configured.
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/health` | exempt from auth |
|
||||||
|
| GET | `/recommendations` | `?count=`, `?force_llm=true`, `?force_tmdb=true` |
|
||||||
|
| POST | `/recommendations/search` | body `{"query": "..."}`, requires `LLM_ENABLED=true` |
|
||||||
|
| POST | `/recommendations/trigger` | runs the pipeline + auto-requests via Radarr if enabled |
|
||||||
|
| POST | `/recommendations/request` | body `{"tmdb_id": 12345}`, requires `RADARR_ENABLED=true` |
|
||||||
|
| POST | `/webhooks/langfuse/invalidate` | body `{"promptName"?: string}` |
|
||||||
|
|
||||||
|
If `SECRET_KEY` is set, all routes except `/health` require
|
||||||
|
`Authorization: Bearer <SECRET_KEY>`.
|
||||||
20
langfuse/movie-recommender.prompt.json
Normal file
20
langfuse/movie-recommender.prompt.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "movie-recommender",
|
||||||
|
"type": "chat",
|
||||||
|
"prompt": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly {{count}} movies they would enjoy.\n\nIMPORTANT:\n- Do NOT suggest any movie from the \"Already In Library\" list.\n- Do NOT suggest any movie from the \"Already Suggested\" list.\n- Return ONLY a JSON array, no other text.\n- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "User's favorite movies:\n{{favorites}}\n\nUser's recently watched movies:\n{{watched}}\n\nAlready in library (DO NOT suggest these):\n{{library}}\n\nAlready suggested (DO NOT repeat these):\n{{previouslySuggested}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"model": "llama3",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"maxTokens": 1024
|
||||||
|
},
|
||||||
|
"labels": ["production"]
|
||||||
|
}
|
||||||
20
langfuse/movie-search.prompt.json
Normal file
20
langfuse/movie-search.prompt.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "movie-search",
|
||||||
|
"type": "chat",
|
||||||
|
"prompt": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a movie recommendation engine. Return ONLY a JSON array of {{count}} movies matching the user's request. No other text.\nEach entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "The user is looking for: {{query}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"model": "llama3",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"maxTokens": 1024
|
||||||
|
},
|
||||||
|
"labels": ["production"]
|
||||||
|
}
|
||||||
@@ -30,7 +30,8 @@ export class RecommendationsController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||||
const count = request.query.count ? Number(request.query.count) : this.config.RECOMMEND_COUNT;
|
const parsedCount = request.query.count ? Number(request.query.count) : NaN;
|
||||||
|
const count = Number.isInteger(parsedCount) && parsedCount > 0 ? parsedCount : this.config.RECOMMEND_COUNT;
|
||||||
const useLlm =
|
const useLlm =
|
||||||
request.query.force_tmdb === 'true'
|
request.query.force_tmdb === 'true'
|
||||||
? false
|
? false
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ async function main(): Promise<void> {
|
|||||||
? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER)
|
? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const recommenderTool = new MovieRecommenderTool(promptService, llmClient);
|
const recommenderTool = new MovieRecommenderTool(promptService, llmClient, config.LLM_MODEL);
|
||||||
const searchTool = new MovieSearchTool(promptService, llmClient);
|
const searchTool = new MovieSearchTool(promptService, llmClient, config.LLM_MODEL);
|
||||||
|
|
||||||
const pipeline = new RecommendationPipeline(tracer, logger, {
|
const pipeline = new RecommendationPipeline(tracer, logger, {
|
||||||
jellyfin,
|
jellyfin,
|
||||||
|
|||||||
@@ -41,12 +41,21 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched();
|
const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched();
|
||||||
const signal = useFavoritesAsSignal ? favorites : watched;
|
const signal = useFavoritesAsSignal ? favorites : watched;
|
||||||
|
|
||||||
const library = await this.options.jellyfin.getAllMovies();
|
let library: Movie[] = [];
|
||||||
|
try {
|
||||||
|
library = await this.options.jellyfin.getAllMovies();
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn({ err }, 'Could not fetch library; exclusion may be incomplete');
|
||||||
|
}
|
||||||
const exclude = new Set<number>(toTmdbIds(library));
|
const exclude = new Set<number>(toTmdbIds(library));
|
||||||
|
|
||||||
if (this.options.radarr) {
|
if (this.options.radarr) {
|
||||||
const existing = await this.options.radarr.getExisting();
|
try {
|
||||||
for (const m of existing) exclude.add(m.tmdbId);
|
const existing = await this.options.radarr.getExisting();
|
||||||
|
for (const m of existing) exclude.add(m.tmdbId);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn({ err }, 'Could not fetch Radarr library; exclusion may be incomplete');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recommendations: Recommendation[] = [];
|
const recommendations: Recommendation[] = [];
|
||||||
@@ -70,7 +79,13 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
|
|
||||||
for (const suggestion of suggestions) {
|
for (const suggestion of suggestions) {
|
||||||
if (recommendations.length >= input.count) break;
|
if (recommendations.length >= input.count) break;
|
||||||
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
let resolved: TMDbMovie | undefined;
|
||||||
|
try {
|
||||||
|
resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!resolved) continue;
|
if (!resolved) continue;
|
||||||
seen.push(toSeenMovie(resolved));
|
seen.push(toSeenMovie(resolved));
|
||||||
if (exclude.has(resolved.id)) continue;
|
if (exclude.has(resolved.id)) continue;
|
||||||
@@ -103,14 +118,25 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
const trace = this.tracer.startTrace(`${this.name}-search`);
|
const trace = this.tracer.startTrace(`${this.name}-search`);
|
||||||
const ctx: RunContext = { trace, logger: this.logger };
|
const ctx: RunContext = { trace, logger: this.logger };
|
||||||
try {
|
try {
|
||||||
const library = await this.options.jellyfin.getAllMovies();
|
let library: Movie[] = [];
|
||||||
|
try {
|
||||||
|
library = await this.options.jellyfin.getAllMovies();
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn({ err }, 'Could not fetch library; exclusion may be incomplete');
|
||||||
|
}
|
||||||
const exclude = new Set<number>(toTmdbIds(library));
|
const exclude = new Set<number>(toTmdbIds(library));
|
||||||
const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx);
|
const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx);
|
||||||
|
|
||||||
const recommendations: Recommendation[] = [];
|
const recommendations: Recommendation[] = [];
|
||||||
for (const suggestion of suggestions) {
|
for (const suggestion of suggestions) {
|
||||||
if (recommendations.length >= count) break;
|
if (recommendations.length >= count) break;
|
||||||
const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
let resolved: TMDbMovie | undefined;
|
||||||
|
try {
|
||||||
|
resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!resolved || exclude.has(resolved.id)) continue;
|
if (!resolved || exclude.has(resolved.id)) continue;
|
||||||
exclude.add(resolved.id);
|
exclude.add(resolved.id);
|
||||||
recommendations.push({
|
recommendations.push({
|
||||||
@@ -133,10 +159,17 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
|
|||||||
for (const m of signals) {
|
for (const m of signals) {
|
||||||
const tmdbId = Number(m.tmdbId);
|
const tmdbId = Number(m.tmdbId);
|
||||||
if (!tmdbId) continue;
|
if (!tmdbId) continue;
|
||||||
const [similar, recommended] = await Promise.all([
|
let similar: TMDbMovie[] = [];
|
||||||
this.options.tmdb.getSimilar(tmdbId),
|
let recommended: TMDbMovie[] = [];
|
||||||
this.options.tmdb.getRecommendations(tmdbId),
|
try {
|
||||||
]);
|
[similar, recommended] = await Promise.all([
|
||||||
|
this.options.tmdb.getSimilar(tmdbId),
|
||||||
|
this.options.tmdb.getRecommendations(tmdbId),
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err, tmdbId }, 'TMDb similar/recommendations failed, skipping signal movie');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (const tm of [...similar, ...recommended]) {
|
for (const tm of [...similar, ...recommended]) {
|
||||||
if (!exclude.has(tm.id)) {
|
if (!exclude.has(tm.id)) {
|
||||||
freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1);
|
freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1);
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export abstract class ApiClient {
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const ok = res.ok || endpoint.acceptableStatuses.includes(res.status);
|
const ok = res.ok || endpoint.acceptableStatuses.includes(res.status);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new ApiError(`${endpoint.method} ${url} returned ${res.status}`, res.status, text);
|
const safeUrl = url.split('?')[0];
|
||||||
|
throw new ApiError(`${endpoint.method} ${safeUrl} returned ${res.status}`, res.status, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = text.length > 0 ? JSON.parse(text) : undefined;
|
const json = text.length > 0 ? JSON.parse(text) : undefined;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export abstract class LlmTool<TInput, TOutput> {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly promptService: PromptService,
|
protected readonly promptService: PromptService,
|
||||||
protected readonly llmClient: LlmClient
|
protected readonly llmClient: LlmClient,
|
||||||
|
protected readonly defaultModel: string = 'gpt-4o-mini'
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>;
|
protected abstract buildVariables(input: TInput, ctx: RunContext): Record<string, unknown>;
|
||||||
@@ -27,7 +28,7 @@ export abstract class LlmTool<TInput, TOutput> {
|
|||||||
const variables = this.buildVariables(processedInput, ctx);
|
const variables = this.buildVariables(processedInput, ctx);
|
||||||
const compiled = await this.promptService.getCompiled(this.promptName, variables);
|
const compiled = await this.promptService.getCompiled(this.promptName, variables);
|
||||||
|
|
||||||
const model = String(compiled.config.model ?? 'gpt-4o-mini');
|
const model = String(compiled.config.model ?? this.defaultModel);
|
||||||
const temperature =
|
const temperature =
|
||||||
typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined;
|
typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined;
|
||||||
const maxTokens =
|
const maxTokens =
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ describe('RecommendationsController', () => {
|
|||||||
expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true });
|
expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to the configured count for a malformed or non-positive count query param', async () => {
|
||||||
|
const runAsync = vi.fn().mockResolvedValue([]);
|
||||||
|
const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: true }, { runAsync } as unknown as RecommendationPipeline);
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/recommendations?count=abc' });
|
||||||
|
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/recommendations?count=-5' });
|
||||||
|
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/recommendations?count=0' });
|
||||||
|
expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => {
|
it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => {
|
||||||
const recs = [{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }];
|
const recs = [{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }];
|
||||||
const runAsync = vi.fn().mockResolvedValue(recs);
|
const runAsync = vi.fn().mockResolvedValue(recs);
|
||||||
|
|||||||
@@ -171,6 +171,119 @@ describe('RecommendationPipeline.runAsync', () => {
|
|||||||
expect(tool.runAsync).not.toHaveBeenCalled();
|
expect(tool.runAsync).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
|
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continues with an incomplete exclusion set when fetching the library fails', async () => {
|
||||||
|
const jellyfin = {
|
||||||
|
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
|
||||||
|
getRecentlyWatched: vi.fn(),
|
||||||
|
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
|
||||||
|
} as unknown as JellyfinClient;
|
||||||
|
const tmdb = {
|
||||||
|
searchMovie: vi.fn().mockResolvedValue({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
|
||||||
|
} as unknown as TmdbClient;
|
||||||
|
const tool = {
|
||||||
|
runAsync: vi.fn().mockResolvedValue([{ title: 'Interstellar', year: 2014, reason: 'r' }]),
|
||||||
|
} as unknown as MovieRecommenderTool;
|
||||||
|
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
|
||||||
|
const logger = { warn: vi.fn() } as unknown as Logger;
|
||||||
|
|
||||||
|
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
||||||
|
jellyfin,
|
||||||
|
tmdb,
|
||||||
|
tool,
|
||||||
|
searchTool,
|
||||||
|
minRatedMovies: 1,
|
||||||
|
maxRetry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await pipeline.runAsync({ count: 1, useLlm: true });
|
||||||
|
|
||||||
|
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r' }]);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
'Could not fetch library; exclusion may be incomplete'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a suggestion whose TMDb resolution fails instead of aborting the run', 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()
|
||||||
|
.mockRejectedValueOnce(new Error('tmdb down'))
|
||||||
|
.mockResolvedValueOnce({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
|
||||||
|
} as unknown as TmdbClient;
|
||||||
|
const tool = {
|
||||||
|
runAsync: vi.fn().mockResolvedValue([
|
||||||
|
{ title: 'Unresolvable', year: 1999, reason: 'r1' },
|
||||||
|
{ title: 'Interstellar', year: 2014, reason: 'r2' },
|
||||||
|
]),
|
||||||
|
} as unknown as MovieRecommenderTool;
|
||||||
|
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
|
||||||
|
const logger = { warn: vi.fn() } as unknown as Logger;
|
||||||
|
|
||||||
|
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
||||||
|
jellyfin,
|
||||||
|
tmdb,
|
||||||
|
tool,
|
||||||
|
searchTool,
|
||||||
|
minRatedMovies: 1,
|
||||||
|
maxRetry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await pipeline.runAsync({ count: 2, useLlm: true });
|
||||||
|
|
||||||
|
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r2' }]);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ err: expect.any(Error), title: 'Unresolvable' }),
|
||||||
|
'TMDb search failed, skipping suggestion'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a signal movie whose TMDb similar/recommendations lookup fails in the fallback path', async () => {
|
||||||
|
const jellyfin = {
|
||||||
|
getFavoriteMovies: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([movie('Broken', 2000, '1'), movie('Dune', 2021, '2')]),
|
||||||
|
getRecentlyWatched: vi.fn(),
|
||||||
|
getAllMovies: vi.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as JellyfinClient;
|
||||||
|
const tmdb = {
|
||||||
|
getSimilar: vi.fn().mockImplementation((id: number) => {
|
||||||
|
if (id === 1) return Promise.reject(new Error('tmdb down'));
|
||||||
|
return Promise.resolve([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]);
|
||||||
|
}),
|
||||||
|
getRecommendations: vi.fn().mockImplementation((id: number) => {
|
||||||
|
if (id === 1) return Promise.reject(new Error('tmdb down'));
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}),
|
||||||
|
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 logger = { warn: vi.fn() } as unknown as Logger;
|
||||||
|
|
||||||
|
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
||||||
|
jellyfin,
|
||||||
|
tmdb,
|
||||||
|
tool,
|
||||||
|
searchTool,
|
||||||
|
minRatedMovies: 2,
|
||||||
|
maxRetry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await pipeline.runAsync({ count: 1, useLlm: false });
|
||||||
|
|
||||||
|
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ err: expect.any(Error), tmdbId: 1 }),
|
||||||
|
'TMDb similar/recommendations failed, skipping signal movie'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RecommendationPipeline.autoRequest', () => {
|
describe('RecommendationPipeline.autoRequest', () => {
|
||||||
@@ -283,4 +396,34 @@ describe('RecommendationPipeline.searchAsync', () => {
|
|||||||
expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]);
|
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());
|
expect(searchTool.runAsync).toHaveBeenCalledWith({ query: 'sci-fi like Dune', count: 5 }, expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continues with an empty exclusion set when fetching the library fails', async () => {
|
||||||
|
const jellyfin = {
|
||||||
|
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
|
||||||
|
} 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 logger = { warn: vi.fn() } as unknown as Logger;
|
||||||
|
|
||||||
|
const pipeline = new RecommendationPipeline(makeTracer(), 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(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
'Could not fetch library; exclusion may be incomplete'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,4 +101,11 @@ describe('ApiClient', () => {
|
|||||||
const client = new TestClient('https://api.example.com');
|
const client = new TestClient('https://api.example.com');
|
||||||
await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError);
|
await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits the query string (which may carry secrets) from the ApiError message', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
|
||||||
|
|
||||||
|
const client = new TestClient('https://api.example.com');
|
||||||
|
await expect(client.getThing(1)).rejects.toThrow('GET https://api.example.com/things/1 returned 500');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,44 @@ describe('LlmTool.runAsync', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to the constructor-provided defaultModel when the prompt config omits model', async () => {
|
||||||
|
const getCompiled = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] });
|
||||||
|
const promptService = { getCompiled } as unknown as PromptService;
|
||||||
|
const chat = vi.fn().mockResolvedValue({ content: 'hi' });
|
||||||
|
const llmClient = { chat } as unknown as LlmClient;
|
||||||
|
|
||||||
|
const { ctx } = makeCtx();
|
||||||
|
const tool = new GreetTool(promptService, llmClient, 'llama3');
|
||||||
|
|
||||||
|
await tool.runAsync({ who: 'alice' }, ctx);
|
||||||
|
|
||||||
|
expect(chat).toHaveBeenCalledWith(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
{ model: 'llama3', temperature: undefined, maxTokens: undefined }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to gpt-4o-mini when no defaultModel is given and the prompt config omits model', async () => {
|
||||||
|
const getCompiled = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] });
|
||||||
|
const promptService = { getCompiled } as unknown as PromptService;
|
||||||
|
const chat = vi.fn().mockResolvedValue({ content: 'hi' });
|
||||||
|
const llmClient = { chat } as unknown as LlmClient;
|
||||||
|
|
||||||
|
const { ctx } = makeCtx();
|
||||||
|
const tool = new GreetTool(promptService, llmClient);
|
||||||
|
|
||||||
|
await tool.runAsync({ who: 'alice' }, ctx);
|
||||||
|
|
||||||
|
expect(chat).toHaveBeenCalledWith(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
{ model: 'gpt-4o-mini', temperature: undefined, maxTokens: undefined }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('ends the generation with error metadata and rethrows when the LLM call fails', async () => {
|
it('ends the generation with error metadata and rethrows when the LLM call fails', async () => {
|
||||||
const promptService = {
|
const promptService = {
|
||||||
getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }),
|
getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }),
|
||||||
|
|||||||
Reference in New Issue
Block a user