summarizer/Program.cs
Clinton Billedeaux cea352b576
Add local document summarization and custom prompt mode
Summarize local PDF, Word, and plain text files via a new DocumentService
(PdfPig + DocumentFormat.OpenXml), and add a Custom summary mode that uses
the user's own instructions as the system prompt. Update the console UI,
transcript saving, and README to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:56:57 -05:00

352 lines
14 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
using YoutubeSummarizer.Configuration;
using YoutubeSummarizer.Models;
using YoutubeSummarizer.Services;
// ═════════════════════════════════════════════════════════════════════════════
// Bootstrap
// ═════════════════════════════════════════════════════════════════════════════
// Build configuration from appsettings.json (required) with optional
// environment variable overrides (useful for CI or containerized deployment).
// Environment variables follow the pattern: YouTube__ApiKey, LLM__ApiKey, etc.
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddEnvironmentVariables() // overrides appsettings values if set
.Build();
// Bind configuration sections to strongly-typed objects.
var appSettings = new AppSettings();
config.Bind(appSettings);
// Validate required keys up front — fail fast with a clear message rather
// than letting the first API call blow up with a cryptic 401.
ValidateSettings(appSettings);
// Wire up DI container.
// For a console app this is lightweight, but it mirrors the pattern used
// in the LIKA/IKA ASP.NET services so the code is easy to lift into a
// background service or API controller later.
var services = new ServiceCollection();
// Register HttpClient for the YouTube timedtext endpoint.
// Using IHttpClientFactory gives us connection pooling and the ability to
// attach Polly retry policies.
services.AddHttpClient<YouTubeService>(client =>
{
client.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (compatible; YoutubeSummarizer/1.0)");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Register services with their config dependencies.
services.AddSingleton(appSettings.LLM);
services.AddSingleton(appSettings.Summarizer);
services.AddTransient<SummarizerService>();
var serviceProvider = services.BuildServiceProvider();
// ═════════════════════════════════════════════════════════════════════════════
// Main loop
// ═════════════════════════════════════════════════════════════════════════════
ConsoleRenderer.PrintBanner();
// Handle Ctrl+C gracefully so any in-progress API call can finish or cancel.
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true; // prevent immediate termination
cts.Cancel();
AnsiConsole.MarkupLine("\n[yellow]Cancellation requested. Finishing current operation...[/]");
};
while (!cts.Token.IsCancellationRequested)
{
var input = ConsoleRenderer.PromptForUrl();
if (string.IsNullOrWhiteSpace(input)) continue;
if (input.Equals("q", StringComparison.OrdinalIgnoreCase)) break;
// Auto-detect: an existing local file wins over YouTube URL parsing —
// a real path on disk is an unambiguous signal, whereas a bare 11-char
// YouTube ID could theoretically collide with a short filename.
var candidatePath = NormalizeCandidatePath(input);
if (File.Exists(candidatePath))
{
if (!DocumentService.IsSupportedFile(candidatePath))
{
ConsoleRenderer.PrintError($"Unsupported file type: {Path.GetExtension(candidatePath)}");
ConsoleRenderer.PrintWarning("Supported: .txt, .md, .csv, .pdf, .docx, .odt, .xlsx");
continue;
}
var saveTranscript = ConsoleRenderer.PromptSaveTranscript();
var (summaryMode, customPrompt) = PromptModeAndCustomPrompt();
await ProcessDocumentAsync(
candidatePath, serviceProvider, appSettings.Summarizer,
saveTranscript, summaryMode, customPrompt, cts.Token);
continue;
}
// Parse the video ID from the URL
var videoId = YouTubeService.ExtractVideoId(input);
if (videoId is null)
{
ConsoleRenderer.PrintError("Could not extract a YouTube video ID, and no such file exists.");
ConsoleRenderer.PrintWarning(
"Accepted: watch?v=..., youtu.be/..., /shorts/..., /embed/..., or a path to an existing file.");
continue;
}
var saveVideoTranscript = ConsoleRenderer.PromptSaveTranscript();
var (videoSummaryMode, videoCustomPrompt) = PromptModeAndCustomPrompt();
await ProcessVideoAsync(
videoId, serviceProvider, appSettings.Summarizer,
saveVideoTranscript, videoSummaryMode, videoCustomPrompt, cts.Token);
}
AnsiConsole.MarkupLine("[grey]Goodbye![/]");
// ═════════════════════════════════════════════════════════════════════════════
// Video processing pipeline
// ═════════════════════════════════════════════════════════════════════════════
/// <summary>
/// Fetches a YouTube video's metadata + transcript, then hands off to the
/// shared summarize/save/display pipeline.
/// </summary>
static async Task ProcessVideoAsync(
string videoId,
IServiceProvider sp,
SummarizerSettings summarizerSettings,
bool saveTranscript,
SummaryMode summaryMode,
string? customPrompt,
CancellationToken ct)
{
try
{
var youtubeService = sp.GetRequiredService<YouTubeService>();
// ── Step 1: Metadata ──────────────────────────────────────────────
ConsoleRenderer.PrintWorking("Fetching video metadata");
var metadata = await youtubeService.GetVideoMetadataAsync(videoId, ct);
if (metadata is null)
{
ConsoleRenderer.PrintError($"Video not found or is private: {videoId}");
return;
}
ConsoleRenderer.SetTitle($"Summarize {metadata.Title}");
AnsiConsole.MarkupLine($" [bold]{Markup.Escape(metadata.Title)}[/]");
// ── Step 2: Transcript ────────────────────────────────────────────
ConsoleRenderer.PrintWorking("Fetching transcript");
var transcript = await youtubeService.GetTranscriptAsync(metadata, ct);
await RunPipelineAsync(
metadata, transcript, sp, summarizerSettings,
saveTranscript, summaryMode, customPrompt, ct);
}
catch (OperationCanceledException)
{
// User pressed Ctrl+C — nothing to report, the loop will exit
}
catch (Exception ex)
{
ConsoleRenderer.PrintError(ex.Message);
// Print the stack trace for debugging without overwhelming normal users
// who will rarely see this path.
AnsiConsole.WriteException(ex,
ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes);
}
}
/// <summary>
/// Reads a local document's text, wraps it as video-shaped metadata/transcript
/// (see <see cref="DocumentService"/>), then hands off to the shared
/// summarize/save/display pipeline.
/// </summary>
static async Task ProcessDocumentAsync(
string path,
IServiceProvider sp,
SummarizerSettings summarizerSettings,
bool saveTranscript,
SummaryMode summaryMode,
string? customPrompt,
CancellationToken ct)
{
try
{
var metadata = DocumentService.BuildMetadata(path);
ConsoleRenderer.SetTitle($"Summarize {metadata.Title}");
AnsiConsole.MarkupLine($" [bold]{Markup.Escape(metadata.Title)}[/]");
ConsoleRenderer.PrintWorking("Reading document");
var transcript = await DocumentService.ExtractTextAsync(path, ct);
await RunPipelineAsync(
metadata, transcript, sp, summarizerSettings,
saveTranscript, summaryMode, customPrompt, ct);
}
catch (OperationCanceledException)
{
// User pressed Ctrl+C — nothing to report, the loop will exit
}
catch (Exception ex)
{
ConsoleRenderer.PrintError(ex.Message);
AnsiConsole.WriteException(ex,
ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes);
}
}
/// <summary>
/// Shared tail of the pipeline once metadata + transcript text are in hand,
/// regardless of whether they came from YouTube or a local document:
/// 1. Summarize (LLM Chat Completions)
/// 2. Save transcript + summary to file (if requested)
/// 3. Display (ConsoleRenderer)
/// </summary>
static async Task RunPipelineAsync(
VideoMetadata metadata,
VideoTranscript transcript,
IServiceProvider sp,
SummarizerSettings summarizerSettings,
bool saveTranscript,
SummaryMode summaryMode,
string? customPrompt,
CancellationToken ct)
{
var summarizerService = sp.GetRequiredService<SummarizerService>();
// Optionally show raw transcript for debugging / inspection
if (summarizerSettings.ShowTranscript)
{
AnsiConsole.WriteLine();
AnsiConsole.Write(new Rule("RAW TRANSCRIPT").RuleStyle("grey"));
AnsiConsole.WriteLine(transcript.Text);
AnsiConsole.Write(new Rule("END TRANSCRIPT").RuleStyle("grey"));
AnsiConsole.WriteLine();
}
AnsiConsole.MarkupLine(
$" [grey]Transcript:[/] {transcript.Source} | {transcript.WordCount:N0} words");
// Always run the standard summary (used for file saving).
ConsoleRenderer.PrintWorking("Summarizing with LLM");
var standardSummary = await summarizerService.SummarizeAsync(
metadata, transcript, mode: SummaryMode.Standard, ct: ct);
// If the user chose a different mode, run a second pass for display.
VideoSummary displaySummary;
if (summaryMode != SummaryMode.Standard)
{
ConsoleRenderer.PrintWorking(summaryMode switch
{
SummaryMode.PersonalFilter => "Applying Personal Information Filter",
SummaryMode.Custom => "Applying custom instructions",
_ => "Summarizing"
});
displaySummary = await summarizerService.SummarizeAsync(
metadata, transcript, mode: summaryMode, customPrompt: customPrompt, ct: ct);
}
else
{
displaySummary = standardSummary;
}
// Save transcript + standard summary to file
if (saveTranscript)
{
var transcriptsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Downloads", "transcripts");
ConsoleRenderer.PrintWorking("Saving transcript to file");
var savedPath = await TranscriptFileService.SaveAsync(
metadata, transcript, summaryText: standardSummary.SummaryText,
outputDirectory: transcriptsDir, ct: ct);
ConsoleRenderer.PrintFileSaved(savedPath);
}
// Display
ConsoleRenderer.PrintSummary(displaySummary, showTranscriptSource: true);
}
/// <summary>
/// Prompts for summary mode, and — only when the user picks Custom — for the
/// instructions themselves, re-prompting until non-empty.
/// </summary>
static (SummaryMode Mode, string? CustomPrompt) PromptModeAndCustomPrompt()
{
var mode = ConsoleRenderer.PromptSummaryMode();
if (mode != SummaryMode.Custom) return (mode, null);
string? customPrompt;
do
{
customPrompt = ConsoleRenderer.PromptCustomInstructions();
if (string.IsNullOrWhiteSpace(customPrompt))
ConsoleRenderer.PrintWarning("Custom instructions can't be empty — try again.");
} while (string.IsNullOrWhiteSpace(customPrompt));
return (mode, customPrompt);
}
/// <summary>
/// Strips quotes some terminals add around a drag-and-dropped path and
/// expands a leading "~" to the user's home directory.
/// </summary>
static string NormalizeCandidatePath(string input)
{
var trimmed = input.Trim();
if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"')
trimmed = trimmed[1..^1];
if (trimmed == "~" || trimmed.StartsWith("~/"))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
trimmed = trimmed == "~" ? home : Path.Combine(home, trimmed[2..]);
}
return trimmed;
}
// ═════════════════════════════════════════════════════════════════════════════
// Configuration validation
// ═════════════════════════════════════════════════════════════════════════════
static void ValidateSettings(AppSettings settings)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(settings.LLM.ApiKey) ||
settings.LLM.ApiKey == "YOUR_API_KEY_HERE")
{
// For local Ollama, we don't strictly need a real key, but it shouldn't be the placeholder.
// If they are using OpenAI, they definitely need a key.
if (settings.LLM.BaseUrl.Contains("openai.com", StringComparison.OrdinalIgnoreCase))
{
errors.Add("LLM:ApiKey is not set in appsettings.json (Required for OpenAI)");
}
}
if (errors.Count > 0)
{
AnsiConsole.MarkupLine("\n[red]Configuration errors:[/]");
errors.ForEach(e => AnsiConsole.MarkupLine($" [red]✗[/] {Markup.Escape(e)}"));
AnsiConsole.MarkupLine("\n[grey]Copy appsettings.example.json → appsettings.json and fill in your keys.[/]\n");
Environment.Exit(1);
}
}