Fixed empty file content being sent to the AI when reading a file fails

This commit is contained in:
Thorsten Sommer 2026-08-10 08:46:05 +02:00
parent 8d91b1cda8
commit cf4661d1ad
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
3 changed files with 109 additions and 17 deletions

View File

@ -14,6 +14,7 @@ namespace AIStudio.Chat;
public sealed class ContentText : IContent public sealed class ContentText : IContent
{ {
private static readonly ILogger<ContentText> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ContentText>(); private static readonly ILogger<ContentText> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ContentText>();
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText)); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText));
/// <summary> /// <summary>
@ -266,11 +267,17 @@ public sealed class ContentText : IContent
// Get the list of existing documents: // Get the list of existing documents:
var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList(); var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList();
// Log warning for missing files: //
// Report missing files. We tell the user about them instead of only logging: on a
// network drive, a file which is temporarily unreachable looks exactly like a deleted
// one, and silently dropping it would let the AI answer without that document.
//
var missingDocuments = normalizedAttachments.Except(existingDocuments).Where(x => x.Type is FileAttachmentType.DOCUMENT).ToList(); var missingDocuments = normalizedAttachments.Except(existingDocuments).Where(x => x.Type is FileAttachmentType.DOCUMENT).ToList();
if (missingDocuments.Count > 0)
foreach (var missingDocument in missingDocuments) foreach (var missingDocument in missingDocuments)
{
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath); LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.FindInPage, string.Format(TB("The file '{0}' is currently not available and was not sent."), missingDocument.FileName)));
}
// Only proceed if there are existing, allowed documents: // Only proceed if there are existing, allowed documents:
if (existingDocuments.Count > 0) if (existingDocuments.Count > 0)
@ -284,8 +291,12 @@ public sealed class ContentText : IContent
LOGGER.LogWarning("File attachments could not be processed because the Pandoc version check failed."); LOGGER.LogWarning("File attachments could not be processed because the Pandoc version check failed.");
else else
{ {
sb.AppendLine(); //
sb.AppendLine("The following files are attached to this message:"); // The document blocks are collected separately, so we only announce attached
// files when at least one of them could actually be read. Announcing files we
// then hand over as empty blocks makes the AI answer about an empty document.
//
var documentBlocks = new StringBuilder();
foreach(var document in existingDocuments) foreach(var document in existingDocuments)
{ {
if (document.IsForbidden) if (document.IsForbidden)
@ -294,13 +305,38 @@ public sealed class ContentText : IContent
continue; continue;
} }
var extraction = await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue);
if (!extraction.HasUsableContent)
{
LOGGER.LogError("Reading the file attachment '{FilePath}' failed and it will not be sent: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
continue;
}
//
// The file is usable, but we lost parts of it. The user has to know which
// parts are missing, because the answer will be based on the rest.
//
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
{
LOGGER.LogWarning("Parts of the file attachment '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
documentBlocks.AppendLine();
documentBlocks.AppendLine("---------------------------------------");
documentBlocks.AppendLine($"File path: {document.FilePath}");
documentBlocks.AppendLine("File content:");
documentBlocks.AppendLine("````");
documentBlocks.AppendLine(extraction.Content);
documentBlocks.AppendLine("````");
}
if (documentBlocks.Length > 0)
{
sb.AppendLine(); sb.AppendLine();
sb.AppendLine("---------------------------------------"); sb.AppendLine("The following files are attached to this message:");
sb.AppendLine($"File path: {document.FilePath}"); sb.Append(documentBlocks);
sb.AppendLine("File content:");
sb.AppendLine("````");
sb.AppendLine((await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue)).Content);
sb.AppendLine("````");
} }
var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true }); var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true });

View File

@ -0,0 +1,55 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools;
/// <summary>
/// Translates the stable failure codes of a file extraction into user-facing text.
/// </summary>
/// <remarks>
/// The message which travels with a result is technical: it comes from the runtime, names the
/// library which failed, and belongs into the log. The texts here are the counterpart for the
/// user, and they name what the user can act on, such as an unavailable network drive.
/// </remarks>
internal static class FileExtractionResultExtensions
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileExtractionResultExtensions).Namespace, nameof(FileExtractionResultExtensions));
/// <summary>
/// Gets the localized message which explains why a file could not be read.
/// </summary>
/// <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) => string.Format(ToUserMessageFormat(result.ErrorCode), fileName);
/// <summary>
/// Gets the localized message for a file which was read, but lost some of its pages.
/// </summary>
/// <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 ToPartialUserMessage(this FileExtractionResult result, string fileName)
{
if (result.FailedPages.Count == 0)
return string.Format(TB("Parts of the file '{0}' could not be read. The remaining content was sent."), fileName);
return string.Format(TB("The pages {1} of the file '{0}' could not be read. The remaining content was sent."), fileName, string.Join(", ", result.FailedPages));
}
private static string ToUserMessageFormat(FileExtractionErrorCode code) => code switch
{
FileExtractionErrorCode.FILE_NOT_FOUND => TB("The file '{0}' does not exist anymore and was not sent."),
FileExtractionErrorCode.FILE_NOT_READABLE => TB("The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file."),
FileExtractionErrorCode.TIMEOUT => TB("Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted."),
FileExtractionErrorCode.NOT_A_VALID_PDF => TB("The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely."),
FileExtractionErrorCode.NOT_A_VALID_SPREADSHEET => TB("The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."),
FileExtractionErrorCode.PDF_ENCRYPTED => TB("The file '{0}' is protected and could not be opened, so it was not sent."),
FileExtractionErrorCode.PDFIUM_UNAVAILABLE => TB("AI Studio was not able to start its PDF engine, so the file '{0}' 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.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."),
_ => TB("The file '{0}' could not be read and was not sent."),
};
}

View File

@ -117,6 +117,7 @@ public sealed partial class RustService : BackgroundService
public override void Dispose() public override void Dispose()
{ {
this.http.Dispose(); this.http.Dispose();
this.extractionHttp.Dispose();
this.userLanguageLock.Dispose(); this.userLanguageLock.Dispose();
this.userNameLock.Dispose(); this.userNameLock.Dispose();
base.Dispose(); base.Dispose();