2026-10-21 13:52:26 +02:00
using AIStudio.Settings ;
2026-10-18 12:06:15 +02:00
using AIStudio.Tools.Media ;
2026-08-11 20:14:21 +02:00
namespace AIStudio.Assistants.BatchProcessing ;
public partial class AssistantBatchProcessing
{
private string? ValidateInputDirectory ( string directory )
{
if ( string . IsNullOrWhiteSpace ( directory ))
return T ( "Please select the folder that contains the documents you want to process." );
if (! Directory . Exists ( directory ))
return T ( "The selected folder does not exist." );
return null ;
}
2026-10-17 19:12:43 +02:00
private string? ValidateFilePatterns ( string patterns ) => FileNamePatterns . Validate ( patterns );
2026-08-11 20:14:21 +02:00
private string? ValidateCsvFileName ( string fileName )
{
if ( string . IsNullOrWhiteSpace ( fileName ))
return null ;
if ( fileName . Trim (). IndexOfAny ( Path . GetInvalidFileNameChars ()) >= 0 )
return T ( "Please provide a file name without a path, e.g., my-results.csv" );
return null ;
}
private string? ValidateCustomCsvSeparator ( string separator )
{
if ( this . outputMode is not BatchProcessingOutputMode . TABLE_ONLY
|| this . csvSeparator is not BatchProcessingCsvSeparator . CUSTOM )
return null ;
if (! BatchProcessingCsvSeparatorExtensions . IsValidCustomSeparator ( separator ))
return T ( "Please enter exactly one punctuation or symbol character. Letters, numbers, spaces, quotation marks, and line breaks cannot be used as CSV separators." );
return null ;
}
private string? ValidateFreePrompt ( string prompt )
{
if ( this . promptSource is BatchProcessingPromptSource . FREE_PROMPT && string . IsNullOrWhiteSpace ( prompt ))
return T ( "Please describe what the AI should do with each document." );
return null ;
}
/// <summary>
/// Validates the instruction sources which have no input field of their own.
/// </summary>
private string? ValidateInstructionSource () => this . promptSource switch
{
BatchProcessingPromptSource . POLICY when this . ConfiguredPolicyIsMissing => T ( "The configured default policy no longer exists. Please select another document analysis policy." ),
BatchProcessingPromptSource . POLICY when this . selectedPolicy is null => T ( "Please select a document analysis policy." ),
BatchProcessingPromptSource . FILE_IMPORT when ! string . IsNullOrWhiteSpace ( this . promptFileLoadIssue ) => this . promptFileLoadIssue ,
BatchProcessingPromptSource . FILE_IMPORT when string . IsNullOrWhiteSpace ( this . importedPrompt ) => T ( "Please select the file which contains your instructions." ),
_ => null ,
};
private string? ValidatingProviderWithBatchState ( AIStudio . Settings . Provider provider )
{
if ( this . isProcessingBatch )
return null ;
return this . ValidatingProvider ( provider );
}
private string ResolveOutputDirectory ()
{
if ( string . IsNullOrWhiteSpace ( this . outputDirectory ))
return Path . Join ( this . inputDirectory , DEFAULT_OUTPUT_DIRECTORY_NAME );
return this . outputDirectory ;
}
private IReadOnlyList < string > FindInputFiles ( string resolvedOutputDirectory )
{
2026-10-17 19:12:43 +02:00
var selection = new FolderFileSelection ( FileNamePatterns . Parse ( this . filePatterns ), FileExclusions . Parse ( this . excludePatterns ), this . includeSubdirectories , this . includeHiddenFilesAndFolders , this . maxFileSizeMegabytes );
2026-10-21 15:51:22 +02:00
var outputFolder = new BatchProcessingOutputFolder ( this . inputDirectory , resolvedOutputDirectory , this . ResolveResultsFileName ());
2026-08-11 20:14:21 +02:00
var files = new SortedSet < string >( StringComparer . OrdinalIgnoreCase );
2026-10-17 19:12:43 +02:00
this . numTooLargeFiles = 0 ;
2026-08-11 20:14:21 +02:00
2026-10-17 19:12:43 +02:00
foreach ( var file in selection . EnumerateCandidates (
this . inputDirectory ,
2026-10-21 15:51:22 +02:00
includeDirectory : outputFolder . Enters ,
2026-10-17 19:12:43 +02:00
reportInaccessibleDirectory : ( directory , exception ) =>
throw new IOException ( $"Could not access directory '{directory}'." , exception )))
2026-08-11 20:14:21 +02:00
{
2026-10-17 19:12:43 +02:00
var normalizedFile = Path . GetFullPath ( file );
2026-10-21 15:51:22 +02:00
if (! selection . Selects ( normalizedFile ) || IsTranscriptArtifact ( normalizedFile ) || outputFolder . IsOutputOfRun ( normalizedFile ))
2026-10-17 19:12:43 +02:00
continue ;
2026-08-11 20:14:21 +02:00
2026-10-17 19:12:43 +02:00
//
// Left out without a status of its own: the file never becomes part of the run. The
// line below the file list tells the user, and the log names the limit:
//
if ( selection . IsTooLarge ( new FileInfo ( normalizedFile )))
{
this . numTooLargeFiles ++;
continue ;
2026-08-11 20:14:21 +02:00
}
2026-10-17 19:12:43 +02:00
files . Add ( normalizedFile );
2026-08-11 20:14:21 +02:00
}
2026-10-17 19:12:43 +02:00
if ( this . numTooLargeFiles > 0 )
this . Logger . LogInformation ( "Left out {NumTooLargeFiles} matching file(s) in '{InputDirectory}' from the batch run because they are larger than {MaxFileSize}." , this . numTooLargeFiles , this . inputDirectory , selection . MaxFileSizeBytes . FileSize ());
2026-08-11 20:14:21 +02:00
return [.. files ];
}
/// <summary>
/// Checks for persistent or interrupted media transcript artifacts. They
/// always live beside their source file, independently of the output folder.
/// </summary>
private static bool IsTranscriptArtifact ( string filePath )
{
2026-10-18 12:06:15 +02:00
return MediaSidecarTranscripts . IsArtifactPath ( filePath );
2026-08-11 20:14:21 +02:00
}
/// <summary>
/// Validates the form, finds the documents, and creates the output folder.
/// </summary>
/// <returns>The output folder and the documents, or <c>null</c> when the run must not start.</returns>
private async Task <( string ResolvedOutputDirectory , IReadOnlyList < string > Files )?> PrepareRunAsync ()
{
await this . Form !. Validate ();
2026-10-17 19:12:43 +02:00
//
// The exclusion list sits in the expert settings, which are folded away by default. Unfold
2026-10-27 19:57:04 +01:00
// them, so the user sees the message next to the list. Only the editor knows about an invalid
// draft, since it passes on valid lists only:
2026-10-17 19:12:43 +02:00
//
2026-10-27 19:57:04 +01:00
if ( this . exclusionEditor is { IsValid : false })
2026-10-17 19:12:43 +02:00
this . showExpertSettings = true ;
2026-08-11 20:14:21 +02:00
var instructionIssue = this . ValidateInstructionSource ();
if ( instructionIssue is not null )
{
this . AddInputIssue ( instructionIssue );
return null ;
}
if (! this . InputIsValid )
return null ;
var resolvedOutputDirectory = this . ResolveOutputDirectory ();
IReadOnlyList < string > files ;
try
{
files = this . FindInputFiles ( resolvedOutputDirectory );
}
catch ( Exception e )
{
this . Logger . LogError ( e , "Was not able to enumerate batch input files in '{InputDirectory}'." , this . inputDirectory );
this . AddInputIssue ( string . Format ( T ( "Was not able to read the input folder: {0}" ), e . Message ));
return null ;
}
if ( files . Count == 0 )
{
2026-10-17 19:12:43 +02:00
this . AddInputIssue ( this . numTooLargeFiles > 0
? string . Format ( T ( "Every matching file in the selected folder is larger than {0}. Raise the size limit to process them." ), FolderFileSelection . MegabytesToBytes ( this . maxFileSizeMegabytes ). FileSize ())
: T ( "No matching files were found in the selected folder." ));
2026-08-11 20:14:21 +02:00
return null ;
}
var requiresTranscription = files . Any ( file => IsTranscribableMedia ( file ) && ! HasReusableTranscript ( file ));
if ( requiresTranscription && ! this . MediaTranscriptionService . HasUsableTranscriptionProvider )
{
this . AddInputIssue ( T ( "The selected files include audio or video without an existing transcript, but no usable transcription provider is configured. Configure one in the transcription settings or remove the media patterns." ));
return null ;
}
2026-10-21 13:52:26 +02:00
//
// A model that cannot read images would get the text of the request alone, without the
2026-10-22 10:57:04 +02:00
// image, and answer about nothing. Such images fail one by one, see LoadImageAsync, while the
2026-10-21 13:52:26 +02:00
// documents next to them are processed. A run of nothing but images does not start at all:
//
if ( files . All ( IsImage ) && ! this . ProviderSettings . SupportsImageInput ())
{
this . AddInputIssue ( T ( "All selected files are images, but the selected model cannot read images. Select a model with image support." ));
return null ;
}
2026-08-11 20:14:21 +02:00
try
{
Directory . CreateDirectory ( resolvedOutputDirectory );
}
catch ( Exception e )
{
this . Logger . LogError ( e , "Was not able to create the batch output folder '{OutputDirectory}'." , resolvedOutputDirectory );
this . AddInputIssue ( string . Format ( T ( "Was not able to create the output folder: {0}" ), e . Message ));
return null ;
}
return ( resolvedOutputDirectory , files );
}
}