From 22ab70bb099e5eb25a743b920b5d36e24cda91d0 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 10 Aug 2026 14:55:06 +0200 Subject: [PATCH] Added a notice when a file's content does not match its extension --- .../DocumentAnalysisAssistant.razor.cs | 7 +++++ .../SlideBuilder/SlideAssistant.razor.cs | 7 +++++ app/MindWork AI Studio/Chat/ContentText.cs | 7 +++++ .../Tools/ContentStreamErrorDetails.cs | 17 +++++++++++ .../Tools/FileExtractionErrorCode.cs | 16 ++++++++++ .../Tools/FileExtractionResult.cs | 19 +++++++++--- .../Tools/FileExtractionResultExtensions.cs | 28 ++++++++++++++++- .../Tools/Rust/FileTypes.cs | 5 +++- .../Tools/Services/RustService.Retrieval.cs | 30 ++++++++++++++++--- app/MindWork AI Studio/Tools/UserFile.cs | 7 +++++ 10 files changed, 133 insertions(+), 10 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index 06208e6f..22c71381 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -730,6 +730,13 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore + /// The format the runtime identified by looking at the content, e.g. when it contradicts the + /// file extension. + /// + [JsonPropertyName("detected_format")] + public string? DetectedFormat { get; init; } + /// /// Gets the parsed error code. /// @@ -35,4 +42,14 @@ public sealed class ContentStreamErrorDetails /// [JsonIgnore] public bool IsPartialFailure => this.ParsedCode is FileExtractionErrorCode.PAGE_EXTRACTION_FAILED; + + /// + /// Gets a value indicating whether this is a notice rather than a failure. + /// + /// + /// A notice tells the user something worth knowing about the file, while the content itself + /// was read completely. It must therefore never degrade the outcome of an extraction. + /// + [JsonIgnore] + public bool IsNotice => this.ParsedCode is FileExtractionErrorCode.EXTENSION_MISMATCH; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs b/app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs index ba752ce4..b87af1bd 100644 --- a/app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs +++ b/app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs @@ -36,6 +36,22 @@ public enum FileExtractionErrorCode PDF_ENCRYPTED, PAGE_EXTRACTION_FAILED, NO_TEXT_EXTRACTED, + + /// + /// The content does not match the file extension. This is a notice, not a failure: the file + /// was read according to its content. + /// + EXTENSION_MISMATCH, + + /// + /// The file was read as text, but its bytes are not text. + /// + NOT_TEXT_CONTENT, + + /// + /// The file is an executable, no matter what its extension claims. + /// + EXECUTABLE_REJECTED, UNSUPPORTED, INTERNAL, diff --git a/app/MindWork AI Studio/Tools/FileExtractionResult.cs b/app/MindWork AI Studio/Tools/FileExtractionResult.cs index c8f4f74a..bbee8874 100644 --- a/app/MindWork AI Studio/Tools/FileExtractionResult.cs +++ b/app/MindWork AI Studio/Tools/FileExtractionResult.cs @@ -13,15 +13,16 @@ namespace AIStudio.Tools; /// 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) +/// The format the runtime identified by looking at the content, when it is worth naming. +public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome, string Content, FileExtractionErrorCode ErrorCode, string? ErrorMessage, IReadOnlyList FailedPages, string? DetectedFormat) { 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 Success(string content, string? detectedFormat = null) => new(FileExtractionOutcome.SUCCESS, content, FileExtractionErrorCode.NONE, null, NO_FAILED_PAGES, detectedFormat); - public static FileExtractionResult Partial(string content, IReadOnlyList failedPages) => new(FileExtractionOutcome.PARTIAL, content, FileExtractionErrorCode.PAGE_EXTRACTION_FAILED, null, failedPages); + public static FileExtractionResult Partial(string content, IReadOnlyList failedPages, string? detectedFormat = null) => new(FileExtractionOutcome.PARTIAL, content, FileExtractionErrorCode.PAGE_EXTRACTION_FAILED, null, failedPages, detectedFormat); - public static FileExtractionResult Failed(FileExtractionErrorCode errorCode, string? errorMessage) => new(FileExtractionOutcome.FAILED, string.Empty, errorCode, errorMessage, NO_FAILED_PAGES); + public static FileExtractionResult Failed(FileExtractionErrorCode errorCode, string? errorMessage, string? detectedFormat = null) => new(FileExtractionOutcome.FAILED, string.Empty, errorCode, errorMessage, NO_FAILED_PAGES, detectedFormat); /// /// Gets a value indicating whether the whole file was read. @@ -33,4 +34,14 @@ public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome /// either succeeded or lost only parts of the file. /// public bool HasUsableContent => this.Outcome is FileExtractionOutcome.SUCCESS or FileExtractionOutcome.PARTIAL; + + /// + /// Gets a value indicating whether the file was read, but its content did not match its file + /// extension. + /// + /// + /// On a readable file, only the mismatch notice names a detected format, which is why no + /// separate flag is needed here. + /// + public bool HasExtensionMismatch => this.HasUsableContent && this.DetectedFormat is not null; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs b/app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs index 65a47697..acfa22f1 100644 --- a/app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs +++ b/app/MindWork AI Studio/Tools/FileExtractionResultExtensions.cs @@ -20,7 +20,29 @@ internal static class FileExtractionResultExtensions /// The extraction result. /// The name of the file, as shown to the user. /// The localized message. - internal static string ToUserMessage(this FileExtractionResult result, string fileName) => result.ErrorCode.ToUserMessage(fileName); + internal static string ToUserMessage(this FileExtractionResult result, string fileName) + { + // When we know what the file really is, naming it beats a generic "not supported": + if (result.ErrorCode is FileExtractionErrorCode.UNSUPPORTED && result.DetectedFormat is not null) + return string.Format(TB("The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent."), fileName, result.DetectedFormat); + + return result.ErrorCode.ToUserMessage(fileName); + } + + /// + /// Gets the localized message for a file whose content does not match its file extension. + /// + /// + /// This is a notice, not a failure: the file was read according to its content. We still tell + /// the user, because a wrong extension is a real problem for every other program as well. + /// + /// The extraction result. + /// The name of the file, as shown to the user. + /// The localized message. + internal static string ToExtensionMismatchUserMessage(this FileExtractionResult result, string fileName) => string.Format( + TB("The file '{0}' is actually a {1} and was read as such. Please correct its file extension."), + fileName, + result.DetectedFormat); /// /// Gets the localized message which explains why a file could not be read. @@ -61,6 +83,10 @@ internal static class FileExtractionResultExtensions FileExtractionErrorCode.PANDOC_UNAVAILABLE => TB("Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent."), FileExtractionErrorCode.NO_TEXT_EXTRACTED => TB("No text could be read from the file '{0}', so it was not sent. The file might consist of scanned images without a text layer."), FileExtractionErrorCode.NO_CONTENT => TB("The file '{0}' did not provide any content and was not sent."), + + FileExtractionErrorCode.NOT_TEXT_CONTENT => TB("The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension."), + + FileExtractionErrorCode.EXECUTABLE_REJECTED => TB("The file '{0}' is an executable program and was not sent, regardless of its file extension."), FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("The file type of '{0}' could not be determined, so the file was not sent."), FileExtractionErrorCode.UNSUPPORTED => TB("The file type of '{0}' is not supported, so the file was not sent."), diff --git a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs index 51cc0c1e..78f4f85b 100644 --- a/app/MindWork AI Studio/Tools/Rust/FileTypes.cs +++ b/app/MindWork AI Studio/Tools/Rust/FileTypes.cs @@ -54,7 +54,10 @@ public static class FileTypes public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx"); public static readonly FileTypeFilter WORD = FileTypeFilter.Composite("Word", ["odt"], MS_WORD); public static readonly FileTypeFilter EXCEL = FileTypeFilter.Leaf("Excel", "xls", "xlsx"); - public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "ppt", "pptx", "odp"); + + // The legacy binary ".ppt" is missing on purpose: AI Studio has no reader for it, so offering + // it would only let users attach a file which cannot be read. + public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "pptx", "odp"); public static readonly FileTypeFilter MAIL = FileTypeFilter.Leaf(TB("Mail"), "eml", "msg", "mbox"); public static readonly FileTypeFilter LATEX = FileTypeFilter.Leaf("LaTeX", "tex", "bib", "sty", "cls", "log"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs index b4bc81ba..3b8a6837 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs @@ -28,6 +28,7 @@ public sealed partial class RustService var hasPartialFailure = false; var failureCode = FileExtractionErrorCode.NONE; string? failureMessage = null; + string? detectedFormat = null; try { @@ -77,12 +78,32 @@ public sealed partial class RustService if (processedEvent.Error is not null) { var error = processedEvent.Error; + + // + // A notice is not a failure: the file was read completely, we only learned + // something about it worth telling the user. It must not change the outcome. + // + if (error.IsNotice) + { + this.logger?.LogInformation( + "The runtime reported a notice while reading '{Path}': code={ErrorCode}, detectedFormat='{DetectedFormat}', message='{Message}'", + path, + error.ParsedCode, + error.DetectedFormat, + error.Message); + + detectedFormat ??= error.DetectedFormat; + chunkCount++; + continue; + } + this.logger?.LogError( - "The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, message='{Message}'", + "The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, detectedFormat='{DetectedFormat}', message='{Message}'", path, error.ParsedCode, error.PageNumber, error.IsPartialFailure, + error.DetectedFormat, error.Message); // @@ -100,6 +121,7 @@ public sealed partial class RustService { failureCode = error.ParsedCode; failureMessage = error.Message; + detectedFormat = error.DetectedFormat; } } else if (processedEvent.Content is not null) @@ -137,7 +159,7 @@ public sealed partial class RustService } if (failureCode is not FileExtractionErrorCode.NONE) - return FileExtractionResult.Failed(failureCode, failureMessage); + return FileExtractionResult.Failed(failureCode, failureMessage, detectedFormat); var content = resultBuilder.ToString(); @@ -153,7 +175,7 @@ public sealed partial class RustService } return hasPartialFailure - ? FileExtractionResult.Partial(content, failedPages) - : FileExtractionResult.Success(content); + ? FileExtractionResult.Partial(content, failedPages, detectedFormat) + : FileExtractionResult.Success(content, detectedFormat); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/UserFile.cs b/app/MindWork AI Studio/Tools/UserFile.cs index 6dc73ded..051cc77d 100644 --- a/app/MindWork AI Studio/Tools/UserFile.cs +++ b/app/MindWork AI Studio/Tools/UserFile.cs @@ -73,6 +73,13 @@ public static class UserFile await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, result.ToPartialUserMessage(fileName))); } + // The file was read correctly, but its extension lies about what it contains: + if (result.HasExtensionMismatch) + { + LOGGER.LogWarning("The file '{FilePath}' is actually a '{DetectedFormat}'.", filePath, result.DetectedFormat); + await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, result.ToExtensionMismatchUserMessage(fileName))); + } + return result; } } \ No newline at end of file