diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index 0fed4451..8bd9abc5 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -716,7 +716,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore public enum FileExtractionErrorCode { + /// + /// No failure happened. + /// + NONE, + /// /// A code this version does not know, e.g. from a newer runtime. /// UNKNOWN, + // + // Codes reported by the Rust runtime: + // + INVALID_REQUEST, FILE_NOT_FOUND, FILE_NOT_READABLE, @@ -23,4 +32,28 @@ public enum FileExtractionErrorCode NO_TEXT_EXTRACTED, UNSUPPORTED, INTERNAL, + + // + // Codes reported by the app itself: + // + + /// + /// The runtime answered with an unsuccessful HTTP status. + /// + REQUEST_FAILED, + + /// + /// Reading the file took longer than the app is willing to wait. + /// + TIMEOUT, + + /// + /// The runtime sent something the app could not deserialize. + /// + INVALID_RESPONSE, + + /// + /// The extraction finished without reporting a failure, but produced no content at all. + /// + NO_CONTENT, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/FileExtractionOutcome.cs b/app/MindWork AI Studio/Tools/FileExtractionOutcome.cs new file mode 100644 index 00000000..063f8835 --- /dev/null +++ b/app/MindWork AI Studio/Tools/FileExtractionOutcome.cs @@ -0,0 +1,23 @@ +namespace AIStudio.Tools; + +/// +/// How reading a file ended. +/// +public enum FileExtractionOutcome +{ + /// + /// The whole file was read. + /// + SUCCESS, + + /// + /// Parts of the file could not be read, e.g. single pages of a PDF, while the remaining + /// content is still usable. + /// + PARTIAL, + + /// + /// The file could not be read. There is no content the app is allowed to use. + /// + FAILED, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/FileExtractionResult.cs b/app/MindWork AI Studio/Tools/FileExtractionResult.cs new file mode 100644 index 00000000..c8f4f74a --- /dev/null +++ b/app/MindWork AI Studio/Tools/FileExtractionResult.cs @@ -0,0 +1,36 @@ +namespace AIStudio.Tools; + +/// +/// The result of reading a file through the Rust runtime. +/// +/// +/// Content and failure travel together on purpose. When reading a file returns a bare string, a +/// failed extraction is indistinguishable from an empty document, and the empty document reaches +/// the AI as if that were the content of the user's file. +/// +/// How the extraction ended. +/// The extracted content. Empty when the extraction failed. +/// Why the extraction failed or lost parts of the file. +/// The technical failure description, meant for logs and diagnostics. +/// The pages which could not be read, when known. +public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome, string Content, FileExtractionErrorCode ErrorCode, string? ErrorMessage, IReadOnlyList FailedPages) +{ + private static readonly int[] NO_FAILED_PAGES = []; + + public static FileExtractionResult Success(string content) => new(FileExtractionOutcome.SUCCESS, content, FileExtractionErrorCode.NONE, null, NO_FAILED_PAGES); + + public static FileExtractionResult Partial(string content, IReadOnlyList failedPages) => new(FileExtractionOutcome.PARTIAL, content, FileExtractionErrorCode.PAGE_EXTRACTION_FAILED, null, failedPages); + + public static FileExtractionResult Failed(FileExtractionErrorCode errorCode, string? errorMessage) => new(FileExtractionOutcome.FAILED, string.Empty, errorCode, errorMessage, NO_FAILED_PAGES); + + /// + /// Gets a value indicating whether the whole file was read. + /// + public bool IsSuccess => this.Outcome is FileExtractionOutcome.SUCCESS; + + /// + /// Gets a value indicating whether the content may be handed to the AI, i.e. the extraction + /// either succeeded or lost only parts of the file. + /// + public bool HasUsableContent => this.Outcome is FileExtractionOutcome.SUCCESS or FileExtractionOutcome.PARTIAL; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs index e503b793..b4bc81ba 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs @@ -5,36 +5,60 @@ namespace AIStudio.Tools.Services; public sealed partial class RustService { - public async Task ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false) + /// + /// How long one file extraction may take. + /// + /// + /// Reading a large file from a slow network share is legitimately slow, so this is well above + /// the default HTTP client timeout. It still bounds the operation, because an unbounded read + /// would keep the caller waiting forever. + /// + private static readonly TimeSpan EXTRACTION_TIMEOUT = TimeSpan.FromMinutes(10); + + public async Task ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false) { var streamId = Guid.NewGuid().ToString(); var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}"; - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - if (!response.IsSuccessStatusCode) - { - var responseBody = await response.Content.ReadAsStringAsync(); - this.logger?.LogError( - "Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'", - response.StatusCode, - response.ReasonPhrase, - path, - responseBody); - return string.Empty; - } + using var timeoutTokenSource = new CancellationTokenSource(EXTRACTION_TIMEOUT); + var cancellationToken = timeoutTokenSource.Token; var resultBuilder = new StringBuilder(); + var failedPages = new List(); + var hasPartialFailure = false; + var failureCode = FileExtractionErrorCode.NONE; + string? failureMessage = null; try { - await using var stream = await response.Content.ReadAsStreamAsync(); + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + using var response = await this.extractionHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + this.logger?.LogError( + "Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'", + response.StatusCode, + response.ReasonPhrase, + path, + responseBody); + + return FileExtractionResult.Failed(FileExtractionErrorCode.REQUEST_FAILED, $"The runtime answered with the status {(int)response.StatusCode} ({response.ReasonPhrase})."); + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var reader = new StreamReader(stream); var chunkCount = 0; - while (!reader.EndOfStream && chunkCount < maxChunks) + while (chunkCount < maxChunks) { - var line = await reader.ReadLineAsync(); + // We read line by line instead of checking EndOfStream: the latter blocks on a + // network stream and cannot be cancelled, which would defeat the timeout above. + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null) + break; + if (string.IsNullOrWhiteSpace(line)) continue; @@ -46,34 +70,64 @@ public sealed partial class RustService try { var sseEvent = JsonSerializer.Deserialize(jsonContent); - if (sseEvent is not null) - { - var processedEvent = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages); - if (processedEvent.Error is not null) - { - this.logger?.LogError( - "The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, message='{Message}'", - path, - processedEvent.Error.ParsedCode, - processedEvent.Error.PageNumber, - processedEvent.Error.IsPartialFailure, - processedEvent.Error.Message); - } - else if (processedEvent.Content is not null) - resultBuilder.AppendLine(processedEvent.Content); + if (sseEvent is null) + continue; - chunkCount++; + var processedEvent = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages); + if (processedEvent.Error is not null) + { + var error = processedEvent.Error; + this.logger?.LogError( + "The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, message='{Message}'", + path, + error.ParsedCode, + error.PageNumber, + error.IsPartialFailure, + error.Message); + + // + // A partial failure costs us one part of the file, e.g. a single PDF page, + // but keeps the rest usable. Any other failure means what we collected is + // not the document the user picked, so we must not pass it on as content. + // + if (error.IsPartialFailure) + { + hasPartialFailure = true; + if (error.PageNumber is { } pageNumber) + failedPages.Add(pageNumber); + } + else if (failureCode is FileExtractionErrorCode.NONE) + { + failureCode = error.ParsedCode; + failureMessage = error.Message; + } } + else if (processedEvent.Content is not null) + resultBuilder.AppendLine(processedEvent.Content); + + chunkCount++; } - catch (JsonException) + catch (JsonException e) { - this.logger?.LogError("Failed to deserialize SSE event: {JsonContent}", jsonContent); + this.logger?.LogError(e, "Failed to deserialize SSE event while reading '{Path}': {JsonContent}", path, jsonContent); + + if (failureCode is FileExtractionErrorCode.NONE) + { + failureCode = FileExtractionErrorCode.INVALID_RESPONSE; + failureMessage = "The runtime sent a response the app was not able to read."; + } } } } - catch(Exception e) + catch (OperationCanceledException) when (timeoutTokenSource.IsCancellationRequested) + { + this.logger?.LogError("Reading the file '{Path}' timed out after {Timeout}.", path, EXTRACTION_TIMEOUT); + return FileExtractionResult.Failed(FileExtractionErrorCode.TIMEOUT, $"Reading the file timed out after {EXTRACTION_TIMEOUT.TotalMinutes:0} minutes."); + } + catch (Exception e) { this.logger?.LogError(e, "Error reading file data from stream: {Path}", path); + return FileExtractionResult.Failed(FileExtractionErrorCode.INTERNAL, e.Message); } finally { @@ -81,7 +135,25 @@ public sealed partial class RustService if (!string.IsNullOrWhiteSpace(finalContentChunk)) resultBuilder.AppendLine(finalContentChunk); } - - return resultBuilder.ToString(); + + if (failureCode is not FileExtractionErrorCode.NONE) + return FileExtractionResult.Failed(failureCode, failureMessage); + + var content = resultBuilder.ToString(); + + // + // Nothing failed, yet nothing came out either. We report this as a failure as well: + // handing an empty document to the AI looks like a file without content, and the user + // would never learn that reading the file did not work. + // + if (string.IsNullOrWhiteSpace(content)) + { + this.logger?.LogWarning("Reading the file '{Path}' produced no content at all.", path); + return FileExtractionResult.Failed(FileExtractionErrorCode.NO_CONTENT, "Reading the file produced no content."); + } + + return hasPartialFailure + ? FileExtractionResult.Partial(content, failedPages) + : FileExtractionResult.Success(content); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 6e979bb1..d9e72ff1 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -17,6 +17,19 @@ public sealed partial class RustService : BackgroundService private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(RustService).Namespace, nameof(RustService)); private readonly HttpClient http; + + /// + /// A dedicated client for file extraction. + /// + /// + /// Extraction needs its own client because is a client-wide + /// setting which also covers reading the streamed response body. A per-request cancellation + /// token can only shorten that limit, never extend it. Reading a large file from a slow + /// network share legitimately exceeds the default limit, so this client has no timeout of its + /// own and the extraction bounds each request itself. + /// + private readonly HttpClient extractionHttp; + private readonly SemaphoreSlim fileDialogLock = new(1, 1); private readonly SemaphoreSlim userLanguageLock = new(1, 1); private readonly SemaphoreSlim userNameLock = new(1, 1); @@ -42,26 +55,37 @@ public sealed partial class RustService : BackgroundService { this.apiPort = apiPort; this.certificateFingerprint = certificateFingerprint; + + // The default timeout of HttpClient, kept explicit so the difference to the + // extraction client below is visible: + this.http = CreateHttpClient(apiPort, certificateFingerprint, TimeSpan.FromSeconds(100)); + this.extractionHttp = CreateHttpClient(apiPort, certificateFingerprint, Timeout.InfiniteTimeSpan); + } + + private static HttpClient CreateHttpClient(string apiPort, string certificateFingerprint, TimeSpan timeout) + { var certificateValidationHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, certificate, _, _) => { if(certificate is null) return false; - + var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); return currentCertificateFingerprint == certificateFingerprint; }, }; - - this.http = new HttpClient(certificateValidationHandler) + + var client = new HttpClient(certificateValidationHandler) { BaseAddress = new Uri($"https://127.0.0.1:{apiPort}"), DefaultRequestVersion = Version.Parse("2.0"), DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher, + Timeout = timeout, }; - - this.http.DefaultRequestHeaders.AddApiToken(); + + client.DefaultRequestHeaders.AddApiToken(); + return client; } public void SetLogger(ILogger logService) diff --git a/app/MindWork AI Studio/Tools/UserFile.cs b/app/MindWork AI Studio/Tools/UserFile.cs index 14fc0fb4..5002cd03 100644 --- a/app/MindWork AI Studio/Tools/UserFile.cs +++ b/app/MindWork AI Studio/Tools/UserFile.cs @@ -46,6 +46,6 @@ public static class UserFile } var fileContent = await rustService.ReadArbitraryFileData(filePath, int.MaxValue); - return fileContent; + return fileContent.Content; } } \ No newline at end of file