diff --git a/app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs new file mode 100644 index 00000000..fc3d11d7 --- /dev/null +++ b/app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools; + +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable ClassNeverInstantiated.Global +public sealed class ContentStreamErrorDetails +{ + [JsonPropertyName("code")] + public string? Code { get; init; } + + [JsonPropertyName("message")] + public string? Message { get; init; } + + /// + /// The page the failure belongs to, when the failure affects a single page only. + /// + [JsonPropertyName("page_number")] + public int? PageNumber { get; init; } + + /// + /// Gets the parsed error code. + /// + /// + /// Codes this version does not know map to + /// instead of failing the deserialization. A failed deserialization would turn the reported + /// error back into empty file content, which is exactly what we want to avoid here. + /// + [JsonIgnore] + public FileExtractionErrorCode ParsedCode => Enum.TryParse(this.Code, ignoreCase: true, out var parsedCode) ? parsedCode : FileExtractionErrorCode.UNKNOWN; + + /// + /// Gets a value indicating whether this failure affects one part of the file only, while the + /// remaining content is still usable. + /// + [JsonIgnore] + public bool IsPartialFailure => this.ParsedCode is FileExtractionErrorCode.PAGE_EXTRACTION_FAILED; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs new file mode 100644 index 00000000..d32f24f9 --- /dev/null +++ b/app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools; + +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable ClassNeverInstantiated.Global +public sealed class ContentStreamErrorMetadata : ContentStreamSseMetadata +{ + [JsonPropertyName("Error")] + public ContentStreamErrorDetails? Error { get; init; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamMetadataJsonConverter.cs b/app/MindWork AI Studio/Tools/ContentStreamMetadataJsonConverter.cs index e3308c78..68dee19e 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamMetadataJsonConverter.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamMetadataJsonConverter.cs @@ -23,7 +23,8 @@ public sealed class ContentStreamMetadataJsonConverter : JsonConverter JsonSerializer.Deserialize(rawText, options), "Image" => JsonSerializer.Deserialize(rawText, options), "Document" => JsonSerializer.Deserialize(rawText, options), - + "Error" => JsonSerializer.Deserialize(rawText, options), + _ => null }; } diff --git a/app/MindWork AI Studio/Tools/ContentStreamProcessedEvent.cs b/app/MindWork AI Studio/Tools/ContentStreamProcessedEvent.cs new file mode 100644 index 00000000..726306b3 --- /dev/null +++ b/app/MindWork AI Studio/Tools/ContentStreamProcessedEvent.cs @@ -0,0 +1,23 @@ +namespace AIStudio.Tools; + +/// +/// The outcome of processing one content stream event: either content to append, or a reported +/// failure. +/// +/// +/// Content and error are kept apart on purpose. A reported failure must never be appended as +/// content, because that would hand the failure to the AI as if it were part of the document. +/// +/// The content to append, or null when this event carries none. +/// The reported failure, or null when the event was processed successfully. +public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error) +{ + /// + /// An event which neither produced content nor reported a failure. + /// + public static readonly ContentStreamProcessedEvent NOTHING = new(null, null); + + public static ContentStreamProcessedEvent FromContent(string? content) => new(content, null); + + public static ContentStreamProcessedEvent FromError(ContentStreamErrorDetails? error) => new(null, error); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs b/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs index 247d3ebf..37d354af 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamSseHandler.cs @@ -8,7 +8,7 @@ public static class ContentStreamSseHandler private static readonly ConcurrentDictionary> CHUNKED_IMAGES = new(); private static readonly ConcurrentDictionary SLIDE_MANAGERS = new(); - public static string? ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true) + public static ContentStreamProcessedEvent ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true) { switch (sseEvent) { @@ -16,16 +16,16 @@ public static class ContentStreamSseHandler switch (sseEvent.Metadata) { case ContentStreamTextMetadata: - return sseEvent.Content; - + return ContentStreamProcessedEvent.FromContent(sseEvent.Content); + case ContentStreamPdfMetadata pdfMetadata: var pageNumber = pdfMetadata.Pdf?.PageNumber ?? 0; - return $""" + return ContentStreamProcessedEvent.FromContent($""" # Page {pageNumber} {sseEvent.Content} - - """; - + + """); + case ContentStreamSpreadsheetMetadata spreadsheetMetadata: var sheetName = spreadsheetMetadata.Spreadsheet?.SheetName; var rowNumber = spreadsheetMetadata.Spreadsheet?.RowNumber; @@ -37,30 +37,38 @@ public static class ContentStreamSseHandler } spreadSheetResult.Append(sseEvent.Content); - return spreadSheetResult.ToString(); - + return ContentStreamProcessedEvent.FromContent(spreadSheetResult.ToString()); + case ContentStreamDocumentMetadata: case ContentStreamImageMetadata: - return sseEvent.Content; + return ContentStreamProcessedEvent.FromContent(sseEvent.Content); case ContentStreamPresentationMetadata presentationMetadata: var slideManager = SLIDE_MANAGERS.GetOrAdd( sseEvent.StreamId!, _ => new() ); - + slideManager.AddSlide(presentationMetadata, sseEvent.Content, extractImages); - return null; - + return ContentStreamProcessedEvent.NOTHING; + + // + // The runtime reported a failure. It must not contribute any content: an empty + // or partial document would otherwise be handed to the AI as if it were the + // real file content. + // + case ContentStreamErrorMetadata errorMetadata: + return ContentStreamProcessedEvent.FromError(errorMetadata.Error); + default: - return sseEvent.Content; + return ContentStreamProcessedEvent.FromContent(sseEvent.Content); } - + case { Content: not null, Metadata: null }: - return sseEvent.Content; - + return ContentStreamProcessedEvent.FromContent(sseEvent.Content); + default: - return null; + return ContentStreamProcessedEvent.NOTHING; } } diff --git a/app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs b/app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs new file mode 100644 index 00000000..80888cec --- /dev/null +++ b/app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs @@ -0,0 +1,26 @@ +namespace AIStudio.Tools; + +/// +/// Why reading a file failed. The Rust runtime reports these codes as part of the content +/// stream, so the app can tell the user what happened instead of showing an empty document. +/// +public enum FileExtractionErrorCode +{ + /// + /// A code this version does not know, e.g. from a newer runtime. + /// + UNKNOWN, + + INVALID_REQUEST, + FILE_NOT_FOUND, + FILE_NOT_READABLE, + FORMAT_DETECTION_FAILED, + NOT_A_VALID_PDF, + NOT_A_VALID_SPREADSHEET, + PDFIUM_UNAVAILABLE, + PDF_ENCRYPTED, + PAGE_EXTRACTION_FAILED, + NO_TEXT_EXTRACTED, + UNSUPPORTED, + INTERNAL, +} \ 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 4a3f59d5..e503b793 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs @@ -48,9 +48,19 @@ public sealed partial class RustService var sseEvent = JsonSerializer.Deserialize(jsonContent); if (sseEvent is not null) { - var content = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages); - if (content is not null) - resultBuilder.AppendLine(content); + 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); chunkCount++; }