Added structured extraction errors to the content stream handling

This commit is contained in:
Thorsten Sommer 2026-08-10 08:26:41 +02:00
parent 47059352f1
commit 4790439f9e
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
7 changed files with 139 additions and 22 deletions

View File

@ -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; }
/// <summary>
/// The page the failure belongs to, when the failure affects a single page only.
/// </summary>
[JsonPropertyName("page_number")]
public int? PageNumber { get; init; }
/// <summary>
/// Gets the parsed error code.
/// </summary>
/// <remarks>
/// Codes this version does not know map to <see cref="FileExtractionErrorCode.UNKNOWN"/>
/// 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.
/// </remarks>
[JsonIgnore]
public FileExtractionErrorCode ParsedCode => Enum.TryParse<FileExtractionErrorCode>(this.Code, ignoreCase: true, out var parsedCode) ? parsedCode : FileExtractionErrorCode.UNKNOWN;
/// <summary>
/// Gets a value indicating whether this failure affects one part of the file only, while the
/// remaining content is still usable.
/// </summary>
[JsonIgnore]
public bool IsPartialFailure => this.ParsedCode is FileExtractionErrorCode.PAGE_EXTRACTION_FAILED;
}

View File

@ -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; }
}

View File

@ -23,6 +23,7 @@ public sealed class ContentStreamMetadataJsonConverter : JsonConverter<ContentSt
"Presentation" => JsonSerializer.Deserialize<ContentStreamPresentationMetadata?>(rawText, options),
"Image" => JsonSerializer.Deserialize<ContentStreamImageMetadata?>(rawText, options),
"Document" => JsonSerializer.Deserialize<ContentStreamDocumentMetadata?>(rawText, options),
"Error" => JsonSerializer.Deserialize<ContentStreamErrorMetadata?>(rawText, options),
_ => null
};

View File

@ -0,0 +1,23 @@
namespace AIStudio.Tools;
/// <summary>
/// The outcome of processing one content stream event: either content to append, or a reported
/// failure.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="Content">The content to append, or null when this event carries none.</param>
/// <param name="Error">The reported failure, or null when the event was processed successfully.</param>
public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error)
{
/// <summary>
/// An event which neither produced content nor reported a failure.
/// </summary>
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);
}

View File

@ -8,7 +8,7 @@ public static class ContentStreamSseHandler
private static readonly ConcurrentDictionary<string, List<ContentStreamPptxImageData>> CHUNKED_IMAGES = new();
private static readonly ConcurrentDictionary<string, SlideManager> SLIDE_MANAGERS = new();
public static string? ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true)
public static ContentStreamProcessedEvent ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true)
{
switch (sseEvent)
{
@ -16,15 +16,15 @@ 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;
@ -37,11 +37,11 @@ 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(
@ -50,17 +50,25 @@ public static class ContentStreamSseHandler
);
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;
}
}

View File

@ -0,0 +1,26 @@
namespace AIStudio.Tools;
/// <summary>
/// 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.
/// </summary>
public enum FileExtractionErrorCode
{
/// <summary>
/// A code this version does not know, e.g. from a newer runtime.
/// </summary>
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,
}

View File

@ -48,9 +48,19 @@ public sealed partial class RustService
var sseEvent = JsonSerializer.Deserialize<ContentStreamSseEvent>(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++;
}