Added a notice when a file's content does not match its extension

This commit is contained in:
Thorsten Sommer 2026-08-10 14:55:06 +02:00
parent 7f05dfdbab
commit 22ab70bb09
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
10 changed files with 133 additions and 10 deletions

View File

@ -730,6 +730,13 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
// The file was read correctly, but its extension lies about what it contains:
if (extraction.HasExtensionMismatch)
{
this.Logger.LogWarning("The document '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
}
var fileContent = extraction.Content;
sb.AppendLine($"""

View File

@ -396,6 +396,13 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
// The file was read correctly, but its extension lies about what it contains:
if (extraction.HasExtensionMismatch)
{
this.Logger.LogWarning("The document '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
}
var fileContent = extraction.Content;
sb.AppendLine($"""

View File

@ -339,6 +339,13 @@ public sealed class ContentText : IContent
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
// The file was read correctly, but its extension lies about what it contains:
if (extraction.HasExtensionMismatch)
{
LOGGER.LogWarning("The file attachment '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
}
documentBlocks.AppendLine();
documentBlocks.AppendLine("---------------------------------------");
documentBlocks.AppendLine($"File path: {document.FilePath}");

View File

@ -18,6 +18,13 @@ public sealed class ContentStreamErrorDetails
[JsonPropertyName("page_number")]
public int? PageNumber { get; init; }
/// <summary>
/// The format the runtime identified by looking at the content, e.g. when it contradicts the
/// file extension.
/// </summary>
[JsonPropertyName("detected_format")]
public string? DetectedFormat { get; init; }
/// <summary>
/// Gets the parsed error code.
/// </summary>
@ -35,4 +42,14 @@ public sealed class ContentStreamErrorDetails
/// </summary>
[JsonIgnore]
public bool IsPartialFailure => this.ParsedCode is FileExtractionErrorCode.PAGE_EXTRACTION_FAILED;
/// <summary>
/// Gets a value indicating whether this is a notice rather than a failure.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonIgnore]
public bool IsNotice => this.ParsedCode is FileExtractionErrorCode.EXTENSION_MISMATCH;
}

View File

@ -36,6 +36,22 @@ public enum FileExtractionErrorCode
PDF_ENCRYPTED,
PAGE_EXTRACTION_FAILED,
NO_TEXT_EXTRACTED,
/// <summary>
/// The content does not match the file extension. This is a notice, not a failure: the file
/// was read according to its content.
/// </summary>
EXTENSION_MISMATCH,
/// <summary>
/// The file was read as text, but its bytes are not text.
/// </summary>
NOT_TEXT_CONTENT,
/// <summary>
/// The file is an executable, no matter what its extension claims.
/// </summary>
EXECUTABLE_REJECTED,
UNSUPPORTED,
INTERNAL,

View File

@ -13,15 +13,16 @@ namespace AIStudio.Tools;
/// <param name="ErrorCode">Why the extraction failed or lost parts of the file.</param>
/// <param name="ErrorMessage">The technical failure description, meant for logs and diagnostics.</param>
/// <param name="FailedPages">The pages which could not be read, when known.</param>
public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome, string Content, FileExtractionErrorCode ErrorCode, string? ErrorMessage, IReadOnlyList<int> FailedPages)
/// <param name="DetectedFormat">The format the runtime identified by looking at the content, when it is worth naming.</param>
public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome, string Content, FileExtractionErrorCode ErrorCode, string? ErrorMessage, IReadOnlyList<int> 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<int> failedPages) => new(FileExtractionOutcome.PARTIAL, content, FileExtractionErrorCode.PAGE_EXTRACTION_FAILED, null, failedPages);
public static FileExtractionResult Partial(string content, IReadOnlyList<int> 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);
/// <summary>
/// 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.
/// </summary>
public bool HasUsableContent => this.Outcome is FileExtractionOutcome.SUCCESS or FileExtractionOutcome.PARTIAL;
/// <summary>
/// Gets a value indicating whether the file was read, but its content did not match its file
/// extension.
/// </summary>
/// <remarks>
/// On a readable file, only the mismatch notice names a detected format, which is why no
/// separate flag is needed here.
/// </remarks>
public bool HasExtensionMismatch => this.HasUsableContent && this.DetectedFormat is not null;
}

View File

@ -20,7 +20,29 @@ internal static class FileExtractionResultExtensions
/// <param name="result">The extraction result.</param>
/// <param name="fileName">The name of the file, as shown to the user.</param>
/// <returns>The localized message.</returns>
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);
}
/// <summary>
/// Gets the localized message for a file whose content does not match its file extension.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="result">The extraction result.</param>
/// <param name="fileName">The name of the file, as shown to the user.</param>
/// <returns>The localized message.</returns>
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);
/// <summary>
/// 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."),

View File

@ -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");

View File

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

View File

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