From 30340ecbcbff78ad68a8c64628f6211ae674a32a Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 11 Aug 2026 19:37:02 +0200 Subject: [PATCH] Throttle batch processing with random delays --- .../AssistantBatchProcessing.razor | 28 +++++++++ .../AssistantBatchProcessing.razor.Delay.cs | 58 +++++++++++++++++++ .../AssistantBatchProcessing.razor.Run.cs | 16 ++++- .../AssistantBatchProcessing.razor.Session.cs | 9 +++ .../AssistantBatchProcessing.razor.cs | 9 +++ .../Assistants/I18N/allTexts.lua | 42 ++++++++++++++ .../SettingsDialogBatchProcessing.razor | 11 ++++ .../SettingsDialogBatchProcessing.razor.cs | 16 +++++ .../Plugins/configuration/plugin.lua | 5 ++ .../Settings/DataModel/DataBatchProcessing.cs | 8 +++ .../Settings/ManagedConfiguration.Parsing.cs | 10 +++- .../Tools/PluginSystem/PluginConfiguration.cs | 5 ++ 12 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Delay.cs diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor index 0ba9d801..0ef4092d 100644 --- a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor +++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor @@ -148,6 +148,34 @@ else @T("We always write a semicolon-separated log named log.csv, which lists every document with its processing time, the model, the status, and the details of any error. When you start another run on the same output folder, we ask you whether to continue that run: documents which failed or are missing in the log are then processed again. When no output folder is selected, everything is written to the subfolder 'ai-results' within the input folder.") + + @T("Processing pace") + + +@if (MinimumDelayIsManaged) +{ + + @(string.Format(T("Your organization requires a pause of at least {0} seconds between files."), this.ManagedMinimumDelaySeconds)) + +} +else +{ + +} + + + + + @T("Before the next file starts, AI Studio waits for a random number of whole seconds from this interval. The minimum is always 6 seconds and the maximum is 300 seconds (5 minutes). Restored files and the end of a run do not add another pause.") + + +@if (this.pauseBeforeNextFileSeconds > 0) +{ + + @(string.Format(T("Waiting {0} seconds before starting the next file."), this.pauseBeforeNextFileSeconds)) + +} + @if (this.fileResults.Count > 0) diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Delay.cs b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Delay.cs new file mode 100644 index 00000000..0b1b8876 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Delay.cs @@ -0,0 +1,58 @@ +using AIStudio.Settings; +using AIStudio.Settings.DataModel; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Assistants.BatchProcessing; + +public partial class AssistantBatchProcessing +{ + [Inject] + private ThreadSafeRandom Rng { get; init; } = null!; + + private static bool MinimumDelayIsManaged => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.MinimumDelaySeconds, out var meta) + && meta.ManagedMode is not null; + + private int ManagedMinimumDelaySeconds => Math.Clamp(this.SettingsManager.ConfigurationData.BatchProcessing.MinimumDelaySeconds, + DataBatchProcessing.MIN_DELAY_SECONDS, + DataBatchProcessing.MAX_DELAY_SECONDS); + + private int EffectiveMinimumDelaySeconds => MinimumDelayIsManaged ? this.ManagedMinimumDelaySeconds + : Math.Clamp(this.minimumDelaySeconds, DataBatchProcessing.MIN_DELAY_SECONDS, DataBatchProcessing.MAX_DELAY_SECONDS); + + private (int Minimum, int Maximum) GetEffectiveDelayRange() + { + var minimum = this.EffectiveMinimumDelaySeconds; + var maximum = Math.Clamp(this.maximumDelaySeconds, minimum, DataBatchProcessing.MAX_DELAY_SECONDS); + return (minimum, maximum); + } + + /// + /// Waits for a random, inclusive duration before the next file starts. + /// + private async Task WaitBeforeNextFileAsync(int minimumSeconds, int maximumSeconds, CancellationToken token) + { + if (token.IsCancellationRequested) + return; + + // ThreadSafeRandom is the application-wide singleton. Batch runs must + // not create private Random instances because several runs may execute + // concurrently in different assistant sessions. + this.pauseBeforeNextFileSeconds = this.Rng.Next(minimumSeconds, maximumSeconds + 1); + this.Logger.LogInformation("Batch processing waits {DelaySeconds} seconds before starting the next file.", this.pauseBeforeNextFileSeconds); + + await this.CheckpointAssistantSession(); + await this.RefreshAssistantUIAsync(); + + try + { + await Task.Delay(TimeSpan.FromSeconds(this.pauseBeforeNextFileSeconds), token); + } + finally + { + this.pauseBeforeNextFileSeconds = 0; + await this.CheckpointAssistantSession(); + await this.RefreshAssistantUIAsync(); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Run.cs b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Run.cs index b5f1cf15..60e21b8d 100644 --- a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Run.cs +++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Run.cs @@ -41,6 +41,7 @@ public partial class AssistantBatchProcessing this.usedResultFileNames.Clear(); this.hasReportedWriteFailure = false; this.numProcessedFiles = 0; + this.pauseBeforeNextFileSeconds = 0; foreach (var file in files) { var relativePath = Path.GetRelativePath(this.inputDirectory, file); @@ -81,13 +82,16 @@ public partial class AssistantBatchProcessing { this.isProcessingBatch = true; var stopwatch = Stopwatch.StartNew(); + var delayRange = this.GetEffectiveDelayRange(); this.Logger.LogInformation( - "Batch processing started. InputDirectory='{InputDirectory}', OutputDirectory='{OutputDirectory}', TotalFiles={TotalFiles}, RestoredFiles={RestoredFiles}, Model='{Model}'.", + "Batch processing started. InputDirectory='{InputDirectory}', OutputDirectory='{OutputDirectory}', TotalFiles={TotalFiles}, RestoredFiles={RestoredFiles}, Model='{Model}', MinimumDelaySeconds={MinimumDelaySeconds}, MaximumDelaySeconds={MaximumDelaySeconds}.", this.inputDirectory, resolvedOutputDirectory, this.fileResults.Count, this.fileResults.Count(fileResult => fileResult.Status is BatchProcessingFileStatus.DONE), - this.ProviderSettings.Model); + this.ProviderSettings.Model, + delayRange.Minimum, + delayRange.Maximum); // We use the cancellation token of the assistant base class, which // creates it before it calls us and disposes it after we returned. @@ -97,8 +101,10 @@ public partial class AssistantBatchProcessing try { - foreach (var fileResult in this.fileResults) + for (var index = 0; index < this.fileResults.Count; index++) { + var fileResult = this.fileResults[index]; + // Restored from the log of a previous run: if (fileResult.Status is BatchProcessingFileStatus.DONE) continue; @@ -120,6 +126,10 @@ public partial class AssistantBatchProcessing await this.WriteAggregatedResultsAsync(resolvedOutputDirectory); await this.CheckpointAssistantSession(); await this.RefreshAssistantUIAsync(); + + var anotherFileIsWaiting = this.fileResults.Skip(index + 1).Any(nextFile => nextFile.Status is not BatchProcessingFileStatus.DONE); + if (anotherFileIsWaiting) + await this.WaitBeforeNextFileAsync(delayRange.Minimum, delayRange.Maximum, token); } } finally diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Session.cs b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Session.cs index a7d00e40..301959c1 100644 --- a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Session.cs +++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Session.cs @@ -20,11 +20,14 @@ public partial class AssistantBatchProcessing private static readonly AssistantSessionStateKey CSV_FILE_NAME_STATE_KEY = new(nameof(csvFileName)); private static readonly AssistantSessionStateKey CSV_SEPARATOR_STATE_KEY = new(nameof(csvSeparator)); private static readonly AssistantSessionStateKey CUSTOM_CSV_SEPARATOR_STATE_KEY = new(nameof(customCsvSeparator)); + private static readonly AssistantSessionStateKey MINIMUM_DELAY_SECONDS_STATE_KEY = new(nameof(minimumDelaySeconds)); + private static readonly AssistantSessionStateKey MAXIMUM_DELAY_SECONDS_STATE_KEY = new(nameof(maximumDelaySeconds)); private static readonly AssistantSessionStateKey> FILE_RESULTS_STATE_KEY = new(nameof(fileResults)); private static readonly AssistantSessionStateKey> USED_RESULT_FILE_NAMES_STATE_KEY = new(nameof(usedResultFileNames)); private static readonly AssistantSessionStateKey IS_PROCESSING_BATCH_STATE_KEY = new(nameof(isProcessingBatch)); private static readonly AssistantSessionStateKey HAS_REPORTED_WRITE_FAILURE_STATE_KEY = new(nameof(hasReportedWriteFailure)); private static readonly AssistantSessionStateKey NUM_PROCESSED_FILES_STATE_KEY = new(nameof(numProcessedFiles)); + private static readonly AssistantSessionStateKey PAUSE_BEFORE_NEXT_FILE_SECONDS_STATE_KEY = new(nameof(pauseBeforeNextFileSeconds)); /// protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) @@ -44,11 +47,14 @@ public partial class AssistantBatchProcessing state.Set(CSV_FILE_NAME_STATE_KEY, this.csvFileName); state.Set(CSV_SEPARATOR_STATE_KEY, this.csvSeparator); state.Set(CUSTOM_CSV_SEPARATOR_STATE_KEY, this.customCsvSeparator); + state.Set(MINIMUM_DELAY_SECONDS_STATE_KEY, this.minimumDelaySeconds); + state.Set(MAXIMUM_DELAY_SECONDS_STATE_KEY, this.maximumDelaySeconds); state.SetList(FILE_RESULTS_STATE_KEY, this.fileResults.Select(CloneFileResult)); state.SetHashSet(USED_RESULT_FILE_NAMES_STATE_KEY, this.usedResultFileNames); state.Set(IS_PROCESSING_BATCH_STATE_KEY, this.isProcessingBatch); state.Set(HAS_REPORTED_WRITE_FAILURE_STATE_KEY, this.hasReportedWriteFailure); state.Set(NUM_PROCESSED_FILES_STATE_KEY, this.numProcessedFiles); + state.Set(PAUSE_BEFORE_NEXT_FILE_SECONDS_STATE_KEY, this.pauseBeforeNextFileSeconds); } /// @@ -69,6 +75,8 @@ public partial class AssistantBatchProcessing state.Restore(CSV_FILE_NAME_STATE_KEY, value => this.csvFileName = value); state.Restore(CSV_SEPARATOR_STATE_KEY, value => this.csvSeparator = value); state.Restore(CUSTOM_CSV_SEPARATOR_STATE_KEY, value => this.customCsvSeparator = value); + state.Restore(MINIMUM_DELAY_SECONDS_STATE_KEY, value => this.minimumDelaySeconds = value); + state.Restore(MAXIMUM_DELAY_SECONDS_STATE_KEY, value => this.maximumDelaySeconds = value); state.Restore(FILE_RESULTS_STATE_KEY, values => { this.fileResults.Clear(); @@ -78,6 +86,7 @@ public partial class AssistantBatchProcessing state.Restore(IS_PROCESSING_BATCH_STATE_KEY, value => this.isProcessingBatch = value); state.Restore(HAS_REPORTED_WRITE_FAILURE_STATE_KEY, value => this.hasReportedWriteFailure = value); state.Restore(NUM_PROCESSED_FILES_STATE_KEY, value => this.numProcessedFiles = value); + state.Restore(PAUSE_BEFORE_NEXT_FILE_SECONDS_STATE_KEY, value => this.pauseBeforeNextFileSeconds = value); } private static BatchProcessingFileResult CloneFileResult(BatchProcessingFileResult source) diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.cs b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.cs index 9c6d031a..1811e6ab 100644 --- a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.cs +++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.cs @@ -59,6 +59,7 @@ public partial class AssistantBatchProcessing : AssistantBaseCore fileResults = []; private readonly HashSet usedResultFileNames = new(StringComparer.OrdinalIgnoreCase); private bool isProcessingBatch; private bool hasReportedWriteFailure; private int numProcessedFiles; + private int pauseBeforeNextFileSeconds; /// /// The header of the column of the results table that holds the AI answer. @@ -161,6 +165,8 @@ public partial class AssistantBatchProcessing : AssistantBaseCore + @T("Processing pace") + @if (this.MinimumDelayIsManaged) + { + @(string.Format(T("Your organization requires a pause of at least {0} seconds between files. Users can configure only the upper limit."), this.ManagedMinimumDelaySeconds)) + } + else + { + + } + + @T("AI selection") diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor.cs index 34d1f600..eef566c6 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Assistants.BatchProcessing; using AIStudio.Settings; +using AIStudio.Settings.DataModel; namespace AIStudio.Dialogs.Settings; @@ -7,6 +8,21 @@ public partial class SettingsDialogBatchProcessing : SettingsDialogBase { private bool DefaultsDisabled() => !this.SettingsManager.ConfigurationData.BatchProcessing.PreselectOptions; + private bool MinimumDelayIsManaged => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.MinimumDelaySeconds, out var meta) + && meta.ManagedMode is not null; + + private int ManagedMinimumDelaySeconds => Math.Clamp( + this.SettingsManager.ConfigurationData.BatchProcessing.MinimumDelaySeconds, + DataBatchProcessing.MIN_DELAY_SECONDS, + DataBatchProcessing.MAX_DELAY_SECONDS); + + private int EffectiveMinimumDelaySeconds => this.MinimumDelayIsManaged + ? this.ManagedMinimumDelaySeconds + : Math.Clamp( + this.SettingsManager.ConfigurationData.BatchProcessing.MinimumDelaySeconds, + DataBatchProcessing.MIN_DELAY_SECONDS, + DataBatchProcessing.MAX_DELAY_SECONDS); + private bool FreePromptImportDisabled() => this.DefaultsDisabled() || ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.FreePrompt, out var meta) && meta.IsLocked; diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 2361ee52..6851b529 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -429,6 +429,11 @@ CONFIG["SETTINGS"] = {} -- CONFIG["SETTINGS"]["DataBatchProcessing.CsvSeparator"] = "SEMICOLON" -- CONFIG["SETTINGS"]["DataBatchProcessing.CustomCsvSeparator"] = "^" -- +-- Enforce the lower end of the random pause between files for the organization. +-- The value must be between 6 and 300 seconds. Users can configure only the upper +-- end of the interval while this setting is managed by a configuration plugin. +-- CONFIG["SETTINGS"]["DataBatchProcessing.MinimumDelaySeconds"] = 12 +-- -- Configure the minimum provider confidence and the default provider. -- Allowed confidence values are: NONE, UNTRUSTED, UNKNOWN, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH -- A policy can require a higher minimum confidence; the stricter level wins. diff --git a/app/MindWork AI Studio/Settings/DataModel/DataBatchProcessing.cs b/app/MindWork AI Studio/Settings/DataModel/DataBatchProcessing.cs index e256c1e3..6400ecf3 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataBatchProcessing.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataBatchProcessing.cs @@ -12,6 +12,10 @@ namespace AIStudio.Settings.DataModel; public sealed class DataBatchProcessing(Expression>? configSelection = null) { public const string DEFAULT_FILE_PATTERNS = "*.pdf;*.docx;*.pptx;*.xlsx;*.md;*.txt;*.mp3;*.wav;*.wave;*.aac;*.flac;*.ogg;*.opus;*.m4a;*.m4b;*.wma;*.alac;*.aif;*.aiff;*.caf;*.mp4;*.m4v;*.avi;*.mkv;*.mov;*.wmv;*.flv;*.webm"; + public const int MIN_DELAY_SECONDS = 6; + public const int MAX_DELAY_SECONDS = 300; + public const int DEFAULT_MIN_DELAY_SECONDS = 6; + public const int DEFAULT_MAX_DELAY_SECONDS = 10; /// /// Initializes an unmanaged Batch Processing settings instance. @@ -48,6 +52,10 @@ public sealed class DataBatchProcessing(Expression value.CustomCsvSeparator, string.Empty); + public int MinimumDelaySeconds { get; set; } = ManagedConfiguration.Register(configSelection, value => value.MinimumDelaySeconds, DEFAULT_MIN_DELAY_SECONDS); + + public int MaximumDelaySeconds { get; set; } = DEFAULT_MAX_DELAY_SECONDS; + public ConfidenceLevel MinimumProviderConfidence { get; set; } = ManagedConfiguration.Register(configSelection, value => value.MinimumProviderConfidence, ConfidenceLevel.NONE); public string PreselectedProvider { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedProvider, string.Empty); diff --git a/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs b/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs index ebd3f284..db07d95e 100644 --- a/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs +++ b/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs @@ -83,6 +83,7 @@ public static partial class ManagedConfiguration /// The expression to select the property within the configuration class. /// When true, the method will not apply any changes, but only check if the configuration can be read. /// An unused parameter to help with type inference. You might ignore it when calling the method. + /// An optional validator for rejecting parsed values outside the setting's supported range. /// The type of the configuration class. /// The type of the property within the configuration class. /// True when the configuration was successfully processed, otherwise false. @@ -92,7 +93,8 @@ public static partial class ManagedConfiguration Guid configPluginId, LuaTable settings, bool dryRun, - ISpanParsable? _ = null) + ISpanParsable? _ = null, + Func? validator = null) where TValue : struct, ISpanParsable { // @@ -113,7 +115,8 @@ public static partial class ManagedConfiguration if (configuredLuaValue.Type is LuaValueType.String && configuredLuaValue.TryRead(out var configuredLuaValueText)) { // Step 3 -- try to parse the string as the target type: - if (TValue.TryParse(configuredLuaValueText, CultureInfo.InvariantCulture, out var configuredParsedValue)) + if (TValue.TryParse(configuredLuaValueText, CultureInfo.InvariantCulture, out var configuredParsedValue) + && (validator?.Invoke(configuredParsedValue) ?? true)) { configuredValue = configuredParsedValue; successful = true; @@ -121,7 +124,8 @@ public static partial class ManagedConfiguration } // Step 2b -- try to read the Lua value: - if(configuredLuaValue.TryRead(out var configuredLuaValueInstance)) + if(configuredLuaValue.TryRead(out var configuredLuaValueInstance) + && (validator?.Invoke(configuredLuaValueInstance) ?? true)) { configuredValue = configuredLuaValueInstance; successful = true; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 0d083e13..aaaeab95 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -352,6 +352,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultColumnHeader, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CsvSeparator, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CustomCsvSeparator, this.Id, settingsTable, dryRun); + + var minimumDelayIsValid = ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.MinimumDelaySeconds, this.Id, settingsTable, dryRun, validator: value => value is >= DataBatchProcessing.MIN_DELAY_SECONDS and <= DataBatchProcessing.MAX_DELAY_SECONDS); + if (!minimumDelayIsValid && settingsTable.TryGetValue("DataBatchProcessing.MinimumDelaySeconds", out _)) + LOG.LogWarning("The Batch Processing minimum delay configured by plugin {ConfigPluginId} must be between {MinimumDelaySeconds} and {MaximumDelaySeconds} seconds.", this.Id, DataBatchProcessing.MIN_DELAY_SECONDS, DataBatchProcessing.MAX_DELAY_SECONDS); + ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);