mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 07:41:08 +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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user