diff --git a/Input/MuPDF.NET.PDF4LLM/Magazines-PDF.pdf b/Input/MuPDF.NET.PDF4LLM/Magazines-PDF.pdf new file mode 100644 index 0000000..3104e1d Binary files /dev/null and b/Input/MuPDF.NET.PDF4LLM/Magazines-PDF.pdf differ diff --git a/Input/MuPDF.NET.PDF4LLM/nato-members.pdf b/Input/MuPDF.NET.PDF4LLM/nato-members.pdf new file mode 100644 index 0000000..9c2f971 Binary files /dev/null and b/Input/MuPDF.NET.PDF4LLM/nato-members.pdf differ diff --git a/MuPDF.NET.Examples.sln b/MuPDF.NET.Examples.sln index aa3bd08..f1628ed 100644 --- a/MuPDF.NET.Examples.sln +++ b/MuPDF.NET.Examples.sln @@ -101,6 +101,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "30-FileAnnot", "MuPDF.NET\3 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "31-ParallelPixmap", "MuPDF.NET\31-ParallelPixmap\31-ParallelPixmap.csproj", "{6AD9C794-F0D4-4005-A89F-3AA11CF998CF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "09-LoadAi", "MuPDF.NET.PDF4LLM\09-LoadAi\09-LoadAi.csproj", "{6930829A-5FEE-4711-8FFA-483C8FEC719D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "10-Ollama", "MuPDF.NET.PDF4LLM\10-Ollama\10-Ollama.csproj", "{B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "11-OllamaMagazines", "MuPDF.NET.PDF4LLM\11-OllamaMagazines\11-OllamaMagazines.csproj", "{C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -651,6 +657,42 @@ Global {6AD9C794-F0D4-4005-A89F-3AA11CF998CF}.Release|x64.Build.0 = Release|Any CPU {6AD9C794-F0D4-4005-A89F-3AA11CF998CF}.Release|x86.ActiveCfg = Release|Any CPU {6AD9C794-F0D4-4005-A89F-3AA11CF998CF}.Release|x86.Build.0 = Release|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Debug|x64.Build.0 = Debug|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Debug|x86.Build.0 = Debug|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Release|Any CPU.Build.0 = Release|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Release|x64.ActiveCfg = Release|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Release|x64.Build.0 = Release|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Release|x86.ActiveCfg = Release|Any CPU + {6930829A-5FEE-4711-8FFA-483C8FEC719D}.Release|x86.Build.0 = Release|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Debug|x64.Build.0 = Debug|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Debug|x86.Build.0 = Debug|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Release|Any CPU.Build.0 = Release|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Release|x64.ActiveCfg = Release|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Release|x64.Build.0 = Release|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Release|x86.ActiveCfg = Release|Any CPU + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17}.Release|x86.Build.0 = Release|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Debug|x64.ActiveCfg = Debug|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Debug|x64.Build.0 = Debug|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Debug|x86.Build.0 = Debug|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Release|Any CPU.Build.0 = Release|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Release|x64.ActiveCfg = Release|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Release|x64.Build.0 = Release|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Release|x86.ActiveCfg = Release|Any CPU + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -701,5 +743,8 @@ Global {45390D20-78E7-4DB4-A31F-59E5DCC3BDB2} = {C9700E2F-F6F1-A264-19EE-D9EB5C62093D} {0E41A6C8-E17A-48C2-A7EA-BEEC651F3C00} = {C9700E2F-F6F1-A264-19EE-D9EB5C62093D} {6AD9C794-F0D4-4005-A89F-3AA11CF998CF} = {C9700E2F-F6F1-A264-19EE-D9EB5C62093D} + {6930829A-5FEE-4711-8FFA-483C8FEC719D} = {EEFA252F-3CE0-C131-3F8C-AF1C5E35DF91} + {B8C1D4E2-7A3F-4B9C-9E51-2D6A8F0C4B17} = {EEFA252F-3CE0-C131-3F8C-AF1C5E35DF91} + {C9D2E5F3-8B40-4CAD-AF62-3E7B9A1D5C28} = {EEFA252F-3CE0-C131-3F8C-AF1C5E35DF91} EndGlobalSection EndGlobal diff --git a/MuPDF.NET.PDF4LLM/01-ToMarkdown/README.md b/MuPDF.NET.PDF4LLM/01-ToMarkdown/README.md index 6fc4f75..4775084 100644 --- a/MuPDF.NET.PDF4LLM/01-ToMarkdown/README.md +++ b/MuPDF.NET.PDF4LLM/01-ToMarkdown/README.md @@ -28,3 +28,7 @@ dotnet run --project MuPDF.NET.PDF4LLM\01-ToMarkdown - `MuPDF4LLM.SetUseLayout(false)` - `MuPDF4LLM.ToMarkdown` + +## Related + +- [`09-LoadAi`](../09-LoadAi/) — index that Markdown, then Ask / Search (why this package exists) diff --git a/MuPDF.NET.PDF4LLM/06-LlamaMarkdownReader/README.md b/MuPDF.NET.PDF4LLM/06-LlamaMarkdownReader/README.md index 195e693..114a233 100644 --- a/MuPDF.NET.PDF4LLM/06-LlamaMarkdownReader/README.md +++ b/MuPDF.NET.PDF4LLM/06-LlamaMarkdownReader/README.md @@ -29,3 +29,7 @@ dotnet run --project MuPDF.NET.PDF4LLM\06-LlamaMarkdownReader - `MuPDF4LLM.SetUseLayout(false)` — classic RAG markdown (stable Expected/) - `PDFMarkdownReader.LoadData` - `LlamaIndexDocument.Text` / `ExtraInfo` + +## Related + +- [`09-LoadAi`](../09-LoadAi/) — embed those chunks and Ask / Search diff --git a/MuPDF.NET.PDF4LLM/09-LoadAi/09-LoadAi.csproj b/MuPDF.NET.PDF4LLM/09-LoadAi/09-LoadAi.csproj new file mode 100644 index 0000000..91455d5 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/09-LoadAi/09-LoadAi.csproj @@ -0,0 +1,12 @@ + + + Exe + MuPDF.NET.Examples.PDF4LLM.LoadAi + 09-LoadAi + + + + + + + diff --git a/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/ask.txt b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/ask.txt new file mode 100644 index 0000000..a30b3e0 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/ask.txt @@ -0,0 +1,5 @@ +Development-mode answer (set AZURE_OPENAI_* environment variables for a real model). + +Based on indexed excerpts, the United Kingdom (London) and Turkey (Ankara) are NATO members whose capitals exceed 5 million people. + +Question: Which NATO member countries have capitals with more than 5 million people? diff --git a/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/index.summary.txt b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/index.summary.txt new file mode 100644 index 0000000..f2dab46 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/index.summary.txt @@ -0,0 +1,3 @@ +chunkCount=13 +source=national-capitals.pdf +source=nato-members.pdf diff --git a/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/search.txt b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/search.txt new file mode 100644 index 0000000..2b9693f --- /dev/null +++ b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/search.txt @@ -0,0 +1,6 @@ +query=capital cities with more than 50% of national population +1. national-capitals.pdf p3 +2. national-capitals.pdf p2 +3. national-capitals.pdf p6 +4. national-capitals.pdf p4 +5. national-capitals.pdf p4 diff --git a/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/summarize.txt b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/summarize.txt new file mode 100644 index 0000000..6be7cd4 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/09-LoadAi/Expected/summarize.txt @@ -0,0 +1,3 @@ +Development-mode summary (set AZURE_OPENAI_* for a real model). + +The document lists capital cities with population figures. Several capitals exceed 5 million residents. diff --git a/MuPDF.NET.PDF4LLM/09-LoadAi/Program.cs b/MuPDF.NET.PDF4LLM/09-LoadAi/Program.cs new file mode 100644 index 0000000..bd62f35 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/09-LoadAi/Program.cs @@ -0,0 +1,175 @@ +using System.Text; +using Microsoft.Extensions.AI; +using MuPDF.NET.Examples.Common; +using MuPDF.NET.PDF4LLM; +using MuPDF.NET.PDF4LLM.AI; +using MuPDF.NET.PDF4LLM.AI.Models; +using MuPDF.NET.PDF4LLM.AI.Options; + +namespace MuPDF.NET.Examples.PDF4LLM.LoadAi; + +internal static class Program +{ + static void Main(string[] args) + { + ExampleArgs.Parse(args); + ConsoleEx.Title("MuPDF.NET.PDF4LLM / 09-LoadAi"); + LoadAi().GetAwaiter().GetResult(); + } + + /// + /// Index PDFs for RAG, then Search / Ask / Summarize. + /// + static async Task LoadAi() + { + string capitals = ExamplePaths.Pdf4LlmInput("national-capitals.pdf"); + string nato = ExamplePaths.Pdf4LlmInput("nato-members.pdf"); + string output = ExamplePaths.Output("MuPDF.NET.PDF4LLM", "09-LoadAi", "load-ai.txt"); + var check = new ResultCheck("MuPDF.NET.PDF4LLM", "09-LoadAi"); + + bool azure = TryAzureOptions(out MicrosoftAIConnectorOptions options); + if (!azure) + options = MicrosoftAIConnectorOptions.CreateForDevelopment(chatClient: new ExampleRagChatClient()); + + ConsoleEx.Info(azure + ? "Pipeline: Azure OpenAI (live model — ask/search text not compared to Expected/)" + : "Pipeline: development (in-memory index + local IChatClient)"); + + bool prior = MuPDF4LLM.UseLayout; + try + { + // Classic extract so Expected/ stays stable without pymupdf-layout. + MuPDF4LLM.SetUseLayout(false); + options.UseLayout = false; + + // PDF → chunks → embed → vector index (Microsoft.Extensions.AI). + AiDocumentCollection aiDocs = await MuPDF4LLM.LoadAiAsync( + new[] { capitals, nato }, + options); + + const string question = + "Which NATO member countries have capitals with more than 5 million people?"; + const string searchQuery = "capital cities with more than 50% of national population"; + + string answer = await aiDocs.AskAsync(question); + string summary = await aiDocs.SummarizeAsync("national-capitals.pdf"); + IReadOnlyList hits = await aiDocs.SearchAsync(searchQuery, topK: 5); + + var report = new StringBuilder(); + report.Append("chunkCount=").Append(aiDocs.ChunkCount).Append('\n'); + foreach (string source in aiDocs.SourceFiles.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) + report.Append("source=").Append(source).Append('\n'); + + // Index shape is local extraction — comparable even with Azure embeddings. + check.Text(report.ToString(), "index.summary.txt"); + + var search = new StringBuilder(); + search.Append("query=").Append(searchQuery).Append('\n'); + for (int i = 0; i < hits.Count; i++) + { + SearchResult hit = hits[i]; + search.Append(i + 1).Append(". ") + .Append(hit.Chunk.SourceFileName) + .Append(" p").Append(hit.Chunk.PageNumber) + .Append('\n'); + } + + File.WriteAllText(output, report.ToString() + + "--- ask ---\n" + ResultCheck.NormalizeText(answer) + "\n" + + "--- summarize ---\n" + ResultCheck.NormalizeText(summary) + "\n" + + "--- search ---\n" + search); + + ConsoleEx.Info($"Indexed {aiDocs.ChunkCount} chunks"); + ConsoleEx.Info($"Ask: {answer.Replace('\n', ' ')[..Math.Min(120, answer.Length)]}"); + + if (!azure) + { + check.Text(ResultCheck.NormalizeText(answer) + "\n", "ask.txt"); + check.Text(ResultCheck.NormalizeText(summary) + "\n", "summarize.txt"); + check.Text(search.ToString(), "search.txt"); + } + } + finally + { + MuPDF4LLM.SetUseLayout(prior); + } + + check.Finish(); + } + + static bool TryAzureOptions(out MicrosoftAIConnectorOptions options) + { + string? endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT"); + string? apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); + string? chatDeployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_CHAT_DEPLOYMENT"); + string? embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT"); + + if (string.IsNullOrWhiteSpace(endpoint) + || string.IsNullOrWhiteSpace(apiKey) + || string.IsNullOrWhiteSpace(chatDeployment) + || string.IsNullOrWhiteSpace(embeddingDeployment)) + { + options = null!; + return false; + } + + options = new AzureAiOptions + { + OpenAiEndpoint = endpoint, + OpenAiApiKey = apiKey, + ChatDeploymentName = chatDeployment, + EmbeddingDeploymentName = embeddingDeployment, + SearchEndpoint = Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT"), + SearchApiKey = Environment.GetEnvironmentVariable("AZURE_SEARCH_API_KEY"), + SearchIndexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX"), + }.ToConnectorOptions(); + return true; + } + + /// Offline chat client so the sample runs with no API keys. + sealed class ExampleRagChatClient : IChatClient + { + public ChatClientMetadata Metadata { get; } = new("example-rag"); + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var list = messages.ToList(); + string system = list.FirstOrDefault(m => m.Role == ChatRole.System)?.Text ?? ""; + string user = list.LastOrDefault(m => m.Role == ChatRole.User)?.Text ?? ""; + + string answer; + if (system.Contains("Summarize", StringComparison.OrdinalIgnoreCase)) + { + answer = + "Development-mode summary (set AZURE_OPENAI_* for a real model).\n\n" + + "The document lists capital cities with population figures. " + + "Several capitals exceed 5 million residents."; + } + else + { + int idx = user.IndexOf("Question:", StringComparison.Ordinal); + string question = idx >= 0 ? user[(idx + "Question:".Length)..].Trim() : user; + answer = + "Development-mode answer (set AZURE_OPENAI_* environment variables for a real model).\n\n" + + "Based on indexed excerpts, the United Kingdom (London) and Turkey (Ankara) " + + "are NATO members whose capitals exceed 5 million people.\n\n" + + $"Question: {question}"; + } + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, answer))); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } +} diff --git a/MuPDF.NET.PDF4LLM/09-LoadAi/README.md b/MuPDF.NET.PDF4LLM/09-LoadAi/README.md new file mode 100644 index 0000000..42ff860 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/09-LoadAi/README.md @@ -0,0 +1,43 @@ +# 09-LoadAi + +Index PDFs into a RAG pipeline (`LoadAiAsync`), then **search**, **ask**, and **summarize**. This is why MuPDF.NET.PDF4LLM exists: extract clean page text, then hand it to `Microsoft.Extensions.AI` (`IEmbeddingGenerator` / `IChatClient`). + +Default run needs **no API keys**. It uses `CreateForDevelopment()` (hash embeddings + in-memory index) and a local `IChatClient` so `dotnet run` and `run-all` always PASS. + +If all of `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_CHAT_DEPLOYMENT`, and `AZURE_OPENAI_EMBEDDING_DEPLOYMENT` are set, the **same** `AskAsync` / `SearchAsync` calls use Azure OpenAI (optional Azure AI Search via `AZURE_SEARCH_*`). Live model text is written to `Output/` and is **not** compared to `Expected/`. + +Swap the stub `IChatClient` for OpenAI, Azure, Ollama, or any `Microsoft.Extensions.AI` client. The MuPDF.NET.PDF4LLM part stays the same. Ready-made Ollama samples: [`10-Ollama`](../10-Ollama/) and [`11-OllamaMagazines`](../11-OllamaMagazines/). + +## Sample method + +`LoadAi()` in `Program.cs`. + +## Package + +- [MuPDF.NET.PDF4LLM](https://www.nuget.org/packages/MuPDF.NET.PDF4LLM) + +## Input / output + +| | Path | +|--|------| +| Input | `national-capitals.pdf`, `nato-members.pdf` | +| Output | `load-ai.txt` | +| Expected | `index.summary.txt` (+ `ask.txt` / `summarize.txt` / `search.txt` in development mode) | + +## Run + +```powershell +dotnet run --project MuPDF.NET.PDF4LLM\09-LoadAi +``` + +## APIs used + +- `MuPDF4LLM.LoadAiAsync` +- `MicrosoftAIConnectorOptions.CreateForDevelopment` (or `AzureAiOptions.ToConnectorOptions`) +- `AiDocumentCollection.AskAsync` / `SummarizeAsync` / `SearchAsync` + +## Related + +- [`01-ToMarkdown`](../01-ToMarkdown/) — the Markdown this pipeline indexes +- [`06-LlamaMarkdownReader`](../06-LlamaMarkdownReader/) — LlamaIndex-shaped chunks without embed/ask +- [`10-Ollama`](../10-Ollama/) — same pipeline with a local Ollama chat + embedding model diff --git a/MuPDF.NET.PDF4LLM/10-Ollama/10-Ollama.csproj b/MuPDF.NET.PDF4LLM/10-Ollama/10-Ollama.csproj new file mode 100644 index 0000000..1aef150 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/10-Ollama/10-Ollama.csproj @@ -0,0 +1,13 @@ + + + Exe + MuPDF.NET.Examples.PDF4LLM.OllamaCapitals + 10-Ollama + + + + + + + + diff --git a/MuPDF.NET.PDF4LLM/10-Ollama/OllamaRag.cs b/MuPDF.NET.PDF4LLM/10-Ollama/OllamaRag.cs new file mode 100644 index 0000000..6920af5 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/10-Ollama/OllamaRag.cs @@ -0,0 +1,286 @@ +using System.Net.Http; +using Microsoft.Extensions.AI; +using MuPDF.NET.Examples.Common; +using MuPDF.NET.PDF4LLM; +using MuPDF.NET.PDF4LLM.AI; +using MuPDF.NET.PDF4LLM.AI.Models; +using MuPDF.NET.PDF4LLM.AI.Options; +using OllamaSharp; +using OllamaSharp.Models; + +namespace MuPDF.NET.Examples.PDF4LLM.OllamaSupport; + +/// +/// Shared Ollama + LoadAiAsync host used by 10-Ollama and 11-OllamaMagazines. +/// +internal static class OllamaRag +{ + public static async Task RunAsync( + string exampleName, + string pdfFileName, + IReadOnlyList askQuestions, + IReadOnlyList searchQueries, + bool repl) + { + string host = Environment.GetEnvironmentVariable("OLLAMA_HOST") ?? "http://localhost:11434"; + string chatModel = Environment.GetEnvironmentVariable("OLLAMA_MODEL") ?? "mistral"; + string embedModel = Environment.GetEnvironmentVariable("OLLAMA_EMBED_MODEL") ?? "nomic-embed-text"; + + if (!Uri.TryCreate(host, UriKind.Absolute, out Uri? uri)) + { + Console.WriteLine($"SKIP — Invalid OLLAMA_HOST '{host}'."); + return; + } + + string pdfPath = ExamplePaths.Pdf4LlmInput(pdfFileName); + string output = ExamplePaths.Output("MuPDF.NET.PDF4LLM", exampleName, "ollama.txt"); + + using HttpClient chatHttp = CreateHttp(uri); + using HttpClient embedHttp = CreateHttp(uri); + using OllamaApiClient chatOllama = new(chatHttp, chatModel); + using OllamaApiClient embedOllama = new(embedHttp, embedModel); + + string? resolvedChat = await TryResolveModelAsync(chatOllama, uri, chatModel); + if (resolvedChat == null) + return; + + chatModel = resolvedChat; + string? resolvedEmbed = chatModel; + if (!string.Equals(chatModel, embedModel, StringComparison.OrdinalIgnoreCase)) + { + resolvedEmbed = await TryResolveModelAsync(embedOllama, uri, embedModel); + if (resolvedEmbed == null) + return; + } + + embedModel = resolvedEmbed!; + ConsoleEx.Info($"Ollama {uri} chat={chatModel} embed={embedModel}"); + ConsoleEx.Info($"Indexing {pdfFileName} …"); + + bool layout = MuPDF4LLM.LayoutAvailable; + bool prior = MuPDF4LLM.UseLayout; + var report = new System.Text.StringBuilder(); + try + { + MuPDF4LLM.SetUseLayout(layout); + var options = new MicrosoftAIConnectorOptions + { + ChatClient = chatOllama, + EmbeddingGenerator = embedOllama, + UseLayout = layout, + }; + + AiDocumentCollection aiDocs = await MuPDF4LLM.LoadAiAsync(new[] { pdfPath }, options); + ConsoleEx.Info($"Indexed {aiDocs.ChunkCount} chunks from: {string.Join(", ", aiDocs.SourceFiles)}"); + + report.Append("chunkCount=").Append(aiDocs.ChunkCount).Append('\n'); + foreach (string source in aiDocs.SourceFiles) + report.Append("source=").Append(source).Append('\n'); + + Console.WriteLine(); + Console.WriteLine("--- Ask ---"); + foreach (string question in askQuestions) + { + string answer = await PrintAskAsync(aiDocs, question); + report.Append("--- ask: ").Append(question).Append(" ---\n"); + report.Append(ResultCheck.NormalizeText(answer)).Append('\n'); + } + + Console.WriteLine("--- Search ---"); + foreach (string query in searchQueries) + { + string search = await PrintSearchAsync(aiDocs, query); + report.Append("--- search: ").Append(query).Append(" ---\n"); + report.Append(search); + } + + File.WriteAllText(output, report.ToString()); + ConsoleEx.Info($"Wrote: {output}"); + + var check = new ResultCheck("MuPDF.NET.PDF4LLM", exampleName); + check.Equal(aiDocs.ChunkCount > 0, true, "indexed chunks"); + check.Finish(); + + if (!repl || Console.IsInputRedirected) + return; + + Console.WriteLine($"Ask another question about {pdfFileName}. Type 'exit' to quit."); + Console.WriteLine(); + while (true) + { + Console.Write("> "); + string? line = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(line)) + continue; + if (line.Equals("exit", StringComparison.OrdinalIgnoreCase)) + break; + Console.WriteLine(); + await PrintAskAsync(aiDocs, line); + } + } + catch (HttpRequestException ex) + { + Console.Error.WriteLine($"Indexing or chat failed: {ex.Message}"); + if (ex.InnerException != null) + Console.Error.WriteLine(ex.InnerException.Message); + if (ex.StatusCode == System.Net.HttpStatusCode.NotImplemented) + { + Console.Error.WriteLine("That model cannot embed. Pull an embedding model:"); + Console.Error.WriteLine(" ollama pull nomic-embed-text"); + } + + Environment.ExitCode = 1; + } + finally + { + MuPDF4LLM.SetUseLayout(prior); + } + } + + static async Task PrintAskAsync(AiDocumentCollection aiDocs, string question) + { + Console.WriteLine($"Ask: {question}"); + try + { + string answer = await aiDocs.AskAsync(question); + Console.WriteLine(answer); + Console.WriteLine(); + return answer; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Ask failed: {ex.Message}"); + if (ex.InnerException != null) + Console.Error.WriteLine(ex.InnerException.Message); + Console.WriteLine(); + return ""; + } + } + + static async Task PrintSearchAsync(AiDocumentCollection aiDocs, string query) + { + Console.WriteLine($"Search: {query}"); + var block = new System.Text.StringBuilder(); + try + { + IReadOnlyList hits = await aiDocs.SearchAsync(query, topK: 10); + Console.WriteLine($" Ranked all {aiDocs.ChunkCount} chunks; showing top {hits.Count}."); + for (int i = 0; i < hits.Count; i++) + { + SearchResult hit = hits[i]; + if (i > 0) + Console.WriteLine(); + Console.WriteLine($" {i + 1}. [{hit.Score:F3}] {hit.Chunk.SourceFileName} p{hit.Chunk.PageNumber}"); + foreach (string line in SplitLines(hit.Chunk.Text)) + Console.WriteLine($" {line}"); + block.Append(i + 1).Append(". ") + .Append(hit.Chunk.SourceFileName) + .Append(" p").Append(hit.Chunk.PageNumber) + .Append('\n'); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Search failed: {ex.Message}"); + if (ex.InnerException != null) + Console.Error.WriteLine(ex.InnerException.Message); + } + + Console.WriteLine(); + return block.ToString(); + } + + static IEnumerable SplitLines(string text) + { + string[] lines = text.Replace("\r\n", "\n").Replace('\r', '\n').Trim().Split('\n'); + for (int i = 0; i < lines.Length; i++) + lines[i] = lines[i].TrimEnd(); + return lines; + } + + static HttpClient CreateHttp(Uri uri) => + new() + { + BaseAddress = uri, + Timeout = TimeSpan.FromMinutes(10), + }; + + static async Task TryResolveModelAsync(OllamaApiClient ollama, Uri uri, string model) + { + try + { + if (!await ollama.IsRunningAsync()) + { + Console.WriteLine($"SKIP — Cannot reach Ollama at {uri}."); + Console.WriteLine("Install Ollama, start it (`ollama serve` or the Ollama app), then:"); + Console.WriteLine(" ollama pull mistral"); + Console.WriteLine(" ollama pull nomic-embed-text"); + Console.WriteLine("See MuPDF.NET.PDF4LLM/10-Ollama/README.md."); + return null; + } + + IEnumerable local = await ollama.ListLocalModelsAsync(); + string? installed = ResolveInstalledName(local, model); + if (installed == null) + { + Console.WriteLine($"SKIP — Model '{model}' is not installed."); + Console.WriteLine($"Install it with: ollama pull {model}"); + return null; + } + + if (!string.Equals(installed, model, StringComparison.OrdinalIgnoreCase)) + ConsoleEx.Info($"Using installed model '{installed}' for '{model}'."); + + ollama.SelectedModel = installed; + return installed; + } + catch (HttpRequestException ex) + { + Console.WriteLine($"SKIP — Cannot reach Ollama at {uri}."); + Console.WriteLine(ex.Message); + Console.WriteLine("Start the Ollama app or `ollama serve`, then retry."); + return null; + } + } + + static string? ResolveInstalledName(IEnumerable models, string requested) + { + var names = new List(); + foreach (Model model in models) + { + AddName(names, model.Name); + AddName(names, model.ModelName); + } + + foreach (string name in names) + { + if (string.Equals(name, requested, StringComparison.OrdinalIgnoreCase)) + return name; + } + + string latest = requested + ":latest"; + foreach (string name in names) + { + if (string.Equals(name, latest, StringComparison.OrdinalIgnoreCase)) + return name; + } + + foreach (string name in names) + { + int colon = name.IndexOf(':'); + string baseName = colon < 0 ? name : name.Substring(0, colon); + if (string.Equals(baseName, requested, StringComparison.OrdinalIgnoreCase)) + return name; + } + + return null; + } + + static void AddName(List names, string? candidate) + { + if (string.IsNullOrEmpty(candidate)) + return; + if (!names.Exists(n => string.Equals(n, candidate, StringComparison.OrdinalIgnoreCase))) + names.Add(candidate); + } +} diff --git a/MuPDF.NET.PDF4LLM/10-Ollama/Program.cs b/MuPDF.NET.PDF4LLM/10-Ollama/Program.cs new file mode 100644 index 0000000..3e09296 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/10-Ollama/Program.cs @@ -0,0 +1,48 @@ +using MuPDF.NET.Examples.Common; +using MuPDF.NET.Examples.PDF4LLM.OllamaSupport; + +namespace MuPDF.NET.Examples.PDF4LLM.OllamaCapitals; + +internal static class Program +{ + static void Main(string[] args) + { + ExampleArgs.Parse(args); + ConsoleEx.Title("MuPDF.NET.PDF4LLM / 10-Ollama"); + OllamaCapitals(args).GetAwaiter().GetResult(); + } + + /// + /// Index national-capitals.pdf with a local Ollama chat + embedding model, then Ask / Search. + /// + static Task OllamaCapitals(string[] args) + { + bool repl = args.Any(a => string.Equals(a, "--repl", StringComparison.OrdinalIgnoreCase)); + return OllamaRag.RunAsync( + "10-Ollama", + "national-capitals.pdf", + AskQuestions, + SearchQueries, + repl); + } + + static readonly string[] AskQuestions = + { + "What is the capital of South Sudan?", + "What was the population of Juba in 2017?", + "What is the capital of Canada?", + "What percentage of Gibraltar's population lives in the capital?", + "What is the capital of Tanzania?", + "What is the listed population of Vatican City?", + "What is the capital of the United States?", + "What percentage of the United States population lives in Washington D.C.?", + "What is the melting point of unobtainium?", + }; + + static readonly string[] SearchQueries = + { + "capital of South Sudan", + "Vatican City population", + "Ottawa Canada capital", + }; +} diff --git a/MuPDF.NET.PDF4LLM/10-Ollama/README.md b/MuPDF.NET.PDF4LLM/10-Ollama/README.md new file mode 100644 index 0000000..14a0eff --- /dev/null +++ b/MuPDF.NET.PDF4LLM/10-Ollama/README.md @@ -0,0 +1,129 @@ +# 10-Ollama + +Index `national-capitals.pdf` with **[Ollama](https://ollama.com)** as the `IChatClient` and `IEmbeddingGenerator`, then **Ask** and **Search** through `MuPDF4LLM.LoadAiAsync`. + +This is the same RAG path as [`09-LoadAi`](../09-LoadAi/), but the chat and embedding models run **locally**. Default `dotnet run` / `run-all` **SKIP** when Ollama is not running or the models are not pulled — no API keys required. + +You need **two** Ollama models: a chat model (`mistral`) and a separate embedding model (`nomic-embed-text`). Chat models typically cannot embed. + +## Install Ollama + +### Windows + +1. Download the installer from [https://ollama.com/download](https://ollama.com/download) (or `winget install Ollama.Ollama`). +2. Run the installer. The Ollama app starts a server at `http://localhost:11434`. +3. Open a terminal and confirm: + +```powershell +ollama --version +curl http://localhost:11434/api/tags +``` + +If the app is not running, start it from the Start menu, or: + +```powershell +ollama serve +``` + +### macOS + +```bash +brew install ollama +# or download https://ollama.com/download +ollama serve +``` + +### Linux + +```bash +curl -fsSL https://ollama.com/install.sh | sh +ollama serve +``` + +The install script adds a systemd user service on many distros. `curl http://localhost:11434/api/tags` should return JSON. + +## Pull the models + +```powershell +ollama pull mistral +ollama pull nomic-embed-text +``` + +| Model | Role | Notes | +|-------|------|--------| +| `mistral` | Chat (`AskAsync`) | 8k context; override with `OLLAMA_MODEL` (`llama3.1`, `qwen3`, …) | +| `nomic-embed-text` | Embeddings (index + search) | Required; `mistral` cannot embed | + +List what is installed: + +```powershell +ollama list +``` + +## Sample method + +`OllamaCapitals()` in `Program.cs`. Shared host: `OllamaRag.RunAsync` in `OllamaRag.cs`. + +## Package + +- [MuPDF.NET.PDF4LLM](https://www.nuget.org/packages/MuPDF.NET.PDF4LLM) +- [OllamaSharp](https://www.nuget.org/packages/OllamaSharp) (`IChatClient` / `IEmbeddingGenerator` for Ollama) +- [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI) + +## Input / output + +| | Path | +|--|------| +| Input | `Input/MuPDF.NET.PDF4LLM/national-capitals.pdf` | +| Output | `Output/MuPDF.NET.PDF4LLM/10-Ollama/ollama.txt` | +| Expected | none (live model text is not baselined) | + +## Run + +From the **MuPDF.NET.Examples** repo root, after Ollama is up and the two models are pulled: + +```powershell +dotnet run --project MuPDF.NET.PDF4LLM\10-Ollama +``` + +Interactive extra questions (after the canned Ask / Search list): + +```powershell +dotnet run --project MuPDF.NET.PDF4LLM\10-Ollama -- --repl +``` + +Without Ollama, `dotnet run` prints `SKIP —`. `run-all` always skips this project (live model; run it directly). + +## Environment + +| Variable | Default | Meaning | +|----------|---------|---------| +| `OLLAMA_HOST` | `http://localhost:11434` | Ollama API base URL | +| `OLLAMA_MODEL` | `mistral` | Chat model | +| `OLLAMA_EMBED_MODEL` | `nomic-embed-text` | Embedding model | + +## Canned questions + +Facts from the capitals table (South Sudan / Juba, Canada, Gibraltar, Tanzania, Vatican City, United States) plus **What is the melting point of unobtainium?** which the PDF cannot answer (the model should abstain). + +## APIs used + +- `MuPDF4LLM.LoadAiAsync` +- `MicrosoftAIConnectorOptions` (`ChatClient`, `EmbeddingGenerator`, `UseLayout`) +- `AiDocumentCollection.AskAsync` / `SearchAsync` +- `OllamaSharp.OllamaApiClient` + +## Troubleshooting + +| Symptom | What to do | +|---------|------------| +| `SKIP — Cannot reach Ollama` | Start the Ollama app or `ollama serve`. Check `OLLAMA_HOST`. | +| `SKIP — Model 'mistral' is not installed` | `ollama pull mistral` (or your `OLLAMA_MODEL`) | +| `That model cannot embed` / HTTP 501 | Pull `nomic-embed-text`. Do not use the chat model as the embedder. | +| Indexing is slow or the connection resets | Local models are heavy; keep `nomic-embed-text` for embed and `mistral` for chat. Close other GPU apps. | +| Answers ignore the PDF | Ask uses retrieved excerpts only. Try Search first to see which chunks ranked. | + +## Related + +- [`09-LoadAi`](../09-LoadAi/) — same `LoadAiAsync` path with a stub client or Azure OpenAI +- [`11-OllamaMagazines`](../11-OllamaMagazines/) — same Ollama wiring on `Magazines-PDF.pdf` diff --git a/MuPDF.NET.PDF4LLM/11-OllamaMagazines/11-OllamaMagazines.csproj b/MuPDF.NET.PDF4LLM/11-OllamaMagazines/11-OllamaMagazines.csproj new file mode 100644 index 0000000..b8a5d53 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/11-OllamaMagazines/11-OllamaMagazines.csproj @@ -0,0 +1,16 @@ + + + Exe + MuPDF.NET.Examples.PDF4LLM.OllamaMagazines + 11-OllamaMagazines + + + + + + + + + + + diff --git a/MuPDF.NET.PDF4LLM/11-OllamaMagazines/Program.cs b/MuPDF.NET.PDF4LLM/11-OllamaMagazines/Program.cs new file mode 100644 index 0000000..5185622 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/11-OllamaMagazines/Program.cs @@ -0,0 +1,49 @@ +using MuPDF.NET.Examples.Common; +using MuPDF.NET.Examples.PDF4LLM.OllamaSupport; + +namespace MuPDF.NET.Examples.PDF4LLM.OllamaMagazines; + +internal static class Program +{ + static void Main(string[] args) + { + ExampleArgs.Parse(args); + ConsoleEx.Title("MuPDF.NET.PDF4LLM / 11-OllamaMagazines"); + OllamaMagazines(args).GetAwaiter().GetResult(); + } + + /// + /// Index Magazines-PDF.pdf with a local Ollama chat + embedding model, then Ask / Search. + /// + static Task OllamaMagazines(string[] args) + { + bool repl = args.Any(a => string.Equals(a, "--repl", StringComparison.OrdinalIgnoreCase)); + return OllamaRag.RunAsync( + "11-OllamaMagazines", + "Magazines-PDF.pdf", + AskQuestions, + SearchQueries, + repl); + } + + static readonly string[] AskQuestions = + { + "What is a magazine, according to the definition in this document?", + "Name the ten types of magazines listed in the document.", + "What do fashion magazines focus on?", + "Which magazine is given as an example of a lifestyle magazine?", + "What details does a magazine masthead contain?", + "What is the difference between a magazine and a newspaper?", + "What is the difference between a magazine, a journal, and a catalog?", + "What standard magazine sizes are used in the US and UK?", + "What type of paper is commonly used for magazine printing?", + "What is the melting point of unobtainium?", + }; + + static readonly string[] SearchQueries = + { + "lifestyle magazine Rolling Stone", + "magazine masthead editors writers designers", + "standard magazine sizes 8.5 by 11", + }; +} diff --git a/MuPDF.NET.PDF4LLM/11-OllamaMagazines/README.md b/MuPDF.NET.PDF4LLM/11-OllamaMagazines/README.md new file mode 100644 index 0000000..9d5d6c0 --- /dev/null +++ b/MuPDF.NET.PDF4LLM/11-OllamaMagazines/README.md @@ -0,0 +1,50 @@ +# 11-OllamaMagazines + +Index `Magazines-PDF.pdf` (a primer on magazine types, uses, parts, and sizes) with **local Ollama**, then **Ask** and **Search**. + +Install Ollama and pull models first — see **[`10-Ollama/README.md`](../10-Ollama/README.md)** (Windows / macOS / Linux install, `ollama pull mistral`, `ollama pull nomic-embed-text`). + +## Sample method + +`OllamaMagazines()` in `Program.cs`. Shared host: [`OllamaRag.cs`](../10-Ollama/OllamaRag.cs). + +## Package + +- [MuPDF.NET.PDF4LLM](https://www.nuget.org/packages/MuPDF.NET.PDF4LLM) +- [OllamaSharp](https://www.nuget.org/packages/OllamaSharp) +- [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI) + +## Input / output + +| | Path | +|--|------| +| Input | `Input/MuPDF.NET.PDF4LLM/Magazines-PDF.pdf` | +| Output | `Output/MuPDF.NET.PDF4LLM/11-OllamaMagazines/ollama.txt` | +| Expected | none (live model text is not baselined) | + +## Run + +```powershell +dotnet run --project MuPDF.NET.PDF4LLM\11-OllamaMagazines +dotnet run --project MuPDF.NET.PDF4LLM\11-OllamaMagazines -- --repl +``` + +Without Ollama, `dotnet run` prints `SKIP —`. `run-all` always skips this project (live model; run it directly). + +Same environment variables as [`10-Ollama`](../10-Ollama/): `OLLAMA_HOST`, `OLLAMA_MODEL`, `OLLAMA_EMBED_MODEL`. + +## Canned questions + +Definition of a magazine; the ten types; fashion magazines; Rolling Stone as a lifestyle example; masthead contents; magazine vs newspaper; magazine vs journal vs catalog; US/UK sizes (8.5"×11" and 5.5"×8.5"); pulp paper; plus **What is the melting point of unobtainium?** (should abstain). + +## APIs used + +- `MuPDF4LLM.LoadAiAsync` +- `MicrosoftAIConnectorOptions` (`ChatClient`, `EmbeddingGenerator`, `UseLayout`) +- `AiDocumentCollection.AskAsync` / `SearchAsync` +- `OllamaSharp.OllamaApiClient` + +## Related + +- [`10-Ollama`](../10-Ollama/) — Ollama install + capitals PDF +- [`09-LoadAi`](../09-LoadAi/) — `LoadAiAsync` without Ollama diff --git a/README.md b/README.md index e6e3907..8e6fa8b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Package versions are pinned in [`Versions.props`](Versions.props). [`NuGet.Confi - Packages from [nuget.org](https://www.nuget.org/) (see `Versions.props`) - Optional `MUPDF_OFFICE_KEY` for Office unlock - `02-ToJsonLayout` / `03-ToText`: optional [pymupdf-layout](https://pypi.org/project/pymupdf-layout/) for layout mode +- `09-LoadAi`: optional `AZURE_OPENAI_*` (and `AZURE_SEARCH_*`) for a live model; default run needs no keys +- `10-Ollama` / `11-OllamaMagazines`: local [Ollama](https://ollama.com) (`ollama pull mistral` and `ollama pull nomic-embed-text`); SKIP when Ollama is not running — see `MuPDF.NET.PDF4LLM/10-Ollama/README.md` ## Run (compare against Expected/) @@ -96,7 +98,7 @@ Each project folder has its own `README.md` (what it shows, inputs, how to run, | Product | Projects | |---------|----------| | MuPDF.NET | Open/Save, Pages, Render, Parallel pixmap, Text, Recolor, Story/HTML, Annotations, Widgets, Insert/Replace/Extract images, Outline/Links, Tables, Barcodes, Embedded files, Metadata, TextWriter, Draw shapes, ZUGFeRD, Color management, Rewrite images, GetDrawings, Rotate/Crop, Encrypt, Compress, Watermark, Image→PDF, Image filters, Page ops, File annot | -| MuPDF.NET.PDF4LLM | Markdown, JSON layout, Plain text, OCR, Tables→CSV, Llama markdown reader, GetKeyValues, Markdown→PDF | +| MuPDF.NET.PDF4LLM | Markdown, JSON layout, Plain text, OCR, Tables→CSV, Llama markdown reader, GetKeyValues, Markdown→PDF, LoadAi (RAG ask/search), Ollama capitals, Ollama magazines | | MuPDF.NET.Office | Unlock/fonts, Open HWPX/DOCX, Export PDF, Export MD/JSON, With PDF4LLM | ## Versions diff --git a/Versions.props b/Versions.props index 9a125e7..810f0d9 100644 --- a/Versions.props +++ b/Versions.props @@ -5,7 +5,7 @@ --> 3.28.2.4 - 1.28.2 + 1.28.2.1 1.28.2 diff --git a/run-all.ps1 b/run-all.ps1 index c5eaf7f..0b189a5 100644 --- a/run-all.ps1 +++ b/run-all.ps1 @@ -32,6 +32,14 @@ $pass = 0 foreach ($p in $projects) { $name = $p.Directory.Name + # Live Ollama samples: SKIP in the batch runner (slow, needs a local server). + # Run them directly: dotnet run --project MuPDF.NET.PDF4LLM\10-Ollama + if ($name -eq '10-Ollama' -or $name -eq '11-OllamaMagazines') { + Write-Host "SKIP $name (live Ollama; see that project's README)" + $skip++ + continue + } + $argList = @( 'run', '--project', $p.FullName, '-c', $Configuration, '--no-build'