mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 19:52:10 +00:00
Added a notice when a file's content does not match its extension
This commit is contained in:
parent
7f05dfdbab
commit
22ab70bb09
@ -730,6 +730,13 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
|||||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
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;
|
var fileContent = extraction.Content;
|
||||||
sb.AppendLine($"""
|
sb.AppendLine($"""
|
||||||
|
|
||||||
|
|||||||
@ -396,6 +396,13 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
|||||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
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;
|
var fileContent = extraction.Content;
|
||||||
sb.AppendLine($"""
|
sb.AppendLine($"""
|
||||||
|
|
||||||
|
|||||||
@ -339,6 +339,13 @@ public sealed class ContentText : IContent
|
|||||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
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("---------------------------------------");
|
documentBlocks.AppendLine("---------------------------------------");
|
||||||
documentBlocks.AppendLine($"File path: {document.FilePath}");
|
documentBlocks.AppendLine($"File path: {document.FilePath}");
|
||||||
|
|||||||
@ -18,6 +18,13 @@ public sealed class ContentStreamErrorDetails
|
|||||||
[JsonPropertyName("page_number")]
|
[JsonPropertyName("page_number")]
|
||||||
public int? PageNumber { get; init; }
|
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>
|
/// <summary>
|
||||||
/// Gets the parsed error code.
|
/// Gets the parsed error code.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -35,4 +42,14 @@ public sealed class ContentStreamErrorDetails
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool IsPartialFailure => this.ParsedCode is FileExtractionErrorCode.PAGE_EXTRACTION_FAILED;
|
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;
|
||||||
}
|
}
|
||||||
@ -36,6 +36,22 @@ public enum FileExtractionErrorCode
|
|||||||
PDF_ENCRYPTED,
|
PDF_ENCRYPTED,
|
||||||
PAGE_EXTRACTION_FAILED,
|
PAGE_EXTRACTION_FAILED,
|
||||||
NO_TEXT_EXTRACTED,
|
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,
|
UNSUPPORTED,
|
||||||
INTERNAL,
|
INTERNAL,
|
||||||
|
|
||||||
|
|||||||
@ -13,15 +13,16 @@ namespace AIStudio.Tools;
|
|||||||
/// <param name="ErrorCode">Why the extraction failed or lost parts of the file.</param>
|
/// <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="ErrorMessage">The technical failure description, meant for logs and diagnostics.</param>
|
||||||
/// <param name="FailedPages">The pages which could not be read, when known.</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 = [];
|
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>
|
/// <summary>
|
||||||
/// Gets a value indicating whether the whole file was read.
|
/// 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.
|
/// either succeeded or lost only parts of the file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasUsableContent => this.Outcome is FileExtractionOutcome.SUCCESS or FileExtractionOutcome.PARTIAL;
|
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;
|
||||||
}
|
}
|
||||||
@ -20,7 +20,29 @@ internal static class FileExtractionResultExtensions
|
|||||||
/// <param name="result">The extraction result.</param>
|
/// <param name="result">The extraction result.</param>
|
||||||
/// <param name="fileName">The name of the file, as shown to the user.</param>
|
/// <param name="fileName">The name of the file, as shown to the user.</param>
|
||||||
/// <returns>The localized message.</returns>
|
/// <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>
|
/// <summary>
|
||||||
/// Gets the localized message which explains why a file could not be read.
|
/// 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.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_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.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.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."),
|
FileExtractionErrorCode.UNSUPPORTED => TB("The file type of '{0}' is not supported, so the file was not sent."),
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,10 @@ public static class FileTypes
|
|||||||
public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx");
|
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 WORD = FileTypeFilter.Composite("Word", ["odt"], MS_WORD);
|
||||||
public static readonly FileTypeFilter EXCEL = FileTypeFilter.Leaf("Excel", "xls", "xlsx");
|
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 MAIL = FileTypeFilter.Leaf(TB("Mail"), "eml", "msg", "mbox");
|
||||||
public static readonly FileTypeFilter LATEX = FileTypeFilter.Leaf("LaTeX", "tex", "bib", "sty", "cls", "log");
|
public static readonly FileTypeFilter LATEX = FileTypeFilter.Leaf("LaTeX", "tex", "bib", "sty", "cls", "log");
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ public sealed partial class RustService
|
|||||||
var hasPartialFailure = false;
|
var hasPartialFailure = false;
|
||||||
var failureCode = FileExtractionErrorCode.NONE;
|
var failureCode = FileExtractionErrorCode.NONE;
|
||||||
string? failureMessage = null;
|
string? failureMessage = null;
|
||||||
|
string? detectedFormat = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -77,12 +78,32 @@ public sealed partial class RustService
|
|||||||
if (processedEvent.Error is not null)
|
if (processedEvent.Error is not null)
|
||||||
{
|
{
|
||||||
var error = processedEvent.Error;
|
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(
|
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,
|
path,
|
||||||
error.ParsedCode,
|
error.ParsedCode,
|
||||||
error.PageNumber,
|
error.PageNumber,
|
||||||
error.IsPartialFailure,
|
error.IsPartialFailure,
|
||||||
|
error.DetectedFormat,
|
||||||
error.Message);
|
error.Message);
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -100,6 +121,7 @@ public sealed partial class RustService
|
|||||||
{
|
{
|
||||||
failureCode = error.ParsedCode;
|
failureCode = error.ParsedCode;
|
||||||
failureMessage = error.Message;
|
failureMessage = error.Message;
|
||||||
|
detectedFormat = error.DetectedFormat;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (processedEvent.Content is not null)
|
else if (processedEvent.Content is not null)
|
||||||
@ -137,7 +159,7 @@ public sealed partial class RustService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failureCode is not FileExtractionErrorCode.NONE)
|
if (failureCode is not FileExtractionErrorCode.NONE)
|
||||||
return FileExtractionResult.Failed(failureCode, failureMessage);
|
return FileExtractionResult.Failed(failureCode, failureMessage, detectedFormat);
|
||||||
|
|
||||||
var content = resultBuilder.ToString();
|
var content = resultBuilder.ToString();
|
||||||
|
|
||||||
@ -153,7 +175,7 @@ public sealed partial class RustService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return hasPartialFailure
|
return hasPartialFailure
|
||||||
? FileExtractionResult.Partial(content, failedPages)
|
? FileExtractionResult.Partial(content, failedPages, detectedFormat)
|
||||||
: FileExtractionResult.Success(content);
|
: FileExtractionResult.Success(content, detectedFormat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,6 +73,13 @@ public static class UserFile
|
|||||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, result.ToPartialUserMessage(fileName)));
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user