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:
2026-08-12 21:40:09 +02:00
parent f26a5d72b2
commit ae8bfaa2eb
12 changed files with 388 additions and 16 deletions

View File

@@ -30,7 +30,8 @@ export class RecommendationsController extends Controller {
}
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 =
request.query.force_tmdb === 'true'
? false

View File

@@ -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)
: undefined;
const recommenderTool = new MovieRecommenderTool(promptService, llmClient);
const searchTool = new MovieSearchTool(promptService, llmClient);
const recommenderTool = new MovieRecommenderTool(promptService, llmClient, config.LLM_MODEL);
const searchTool = new MovieSearchTool(promptService, llmClient, config.LLM_MODEL);
const pipeline = new RecommendationPipeline(tracer, logger, {
jellyfin,

View File

@@ -41,12 +41,21 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched();
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));
if (this.options.radarr) {
const existing = await this.options.radarr.getExisting();
for (const m of existing) exclude.add(m.tmdbId);
try {
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[] = [];
@@ -70,7 +79,13 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
for (const suggestion of suggestions) {
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;
seen.push(toSeenMovie(resolved));
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 ctx: RunContext = { trace, logger: this.logger };
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 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);
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;
exclude.add(resolved.id);
recommendations.push({
@@ -133,10 +159,17 @@ export class RecommendationPipeline extends LlmPipeline<RecommendationRequest, R
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),
]);
let similar: TMDbMovie[] = [];
let recommended: TMDbMovie[] = [];
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]) {
if (!exclude.has(tm.id)) {
freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1);

View File

@@ -38,7 +38,8 @@ export abstract class ApiClient {
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 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;

View File

@@ -7,7 +7,8 @@ export abstract class LlmTool<TInput, TOutput> {
constructor(
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>;
@@ -27,7 +28,7 @@ export abstract class LlmTool<TInput, TOutput> {
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 model = String(compiled.config.model ?? this.defaultModel);
const temperature =
typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined;
const maxTokens =