Added transcription of audio and video files to local data sources (#1081)

Co-authored-by: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com>
This commit is contained in:
Thorsten SommerandPaul Koudelka authored and GitHub committed 2026-10-18 12:06:15 +02:00
1 parent 8642ce2d81
commit 752f7d828a
82 files changed
+3231 -302

No files matched your search

@@ -1,7 +1,4 @@
using System.Text;
using AIStudio.Tools.Media; using AIStudio.Tools.Media;
using AIStudio.Tools.Rust;
namespace AIStudio.Assistants.BatchProcessing; namespace AIStudio.Assistants.BatchProcessing;
@@ -69,108 +66,37 @@ public partial class AssistantBatchProcessing
private async Task<string?> LoadMediaTranscriptAsync(BatchProcessingFileResult fileResult, CancellationToken token) private async Task<string?> LoadMediaTranscriptAsync(BatchProcessingFileResult fileResult, CancellationToken token)
{ {
var transcriptFilePath = GetTranscriptFilePath(fileResult.FilePath); var provider = this.MediaTranscriptionService.GetUsableProvider();
if (File.Exists(transcriptFilePath)) var transcript = await PersistentMediaTranscript.ReadOrCreateAsync(MediaSidecarTranscripts.INSTANCE, fileResult.FilePath, provider, this.MediaTranscriptionService.TranscribeAsync, this.Logger, token);
{ if (transcript.Status is PersistentMediaTranscriptStatus.CANCELLED)
try
{
var existingTranscript = await File.ReadAllTextAsync(transcriptFilePath, token);
if (!string.IsNullOrWhiteSpace(existingTranscript))
{
this.Logger.LogInformation("Reusing the existing batch transcript '{TranscriptFilePath}' for media file '{MediaFilePath}'.", transcriptFilePath, fileResult.FilePath);
return existingTranscript;
}
this.Logger.LogWarning("The existing batch transcript '{TranscriptFilePath}' for media file '{MediaFilePath}' is empty and will be replaced.", transcriptFilePath, fileResult.FilePath);
}
catch (OperationCanceledException)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled."));
return null;
}
catch (Exception e)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, string.Format(T("Was not able to read the existing transcript: {0}"), e.Message), e);
return null;
}
}
if (!this.MediaTranscriptionService.HasUsableTranscriptionProvider)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("No usable transcription provider is configured."));
return null;
}
var transcription = await this.MediaTranscriptionService.TranscribeAsync(fileResult.FilePath, token);
if (transcription.Status is MediaTranscriptionResultStatus.CANCELLED)
{ {
this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled.")); this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled."));
return null; return null;
} }
if (transcription.Status is not MediaTranscriptionResultStatus.SUCCEEDED) if (transcript.Status is not PersistentMediaTranscriptStatus.READY)
{ {
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, transcription.UserMessage); this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, transcript.UserMessage);
return null; return null;
} }
if (string.IsNullOrWhiteSpace(transcription.Text))
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The transcription provider returned an empty transcript."));
return null;
}
return await this.StoreMediaTranscriptAsync(fileResult, transcriptFilePath, transcription.Text);
}
private async Task<string?> StoreMediaTranscriptAsync(BatchProcessingFileResult fileResult, string transcriptFilePath, string transcript)
{
var tempFilePath = transcriptFilePath + ".tmp";
try try
{ {
// Complete the small persistence step even if cancellation arrived return await File.ReadAllTextAsync(transcript.TranscriptPath, token);
// after transcription, so the expensive provider result can be }
// reused when the interrupted batch is continued. catch (OperationCanceledException)
await File.WriteAllTextAsync(tempFilePath, transcript, new UTF8Encoding(false), CancellationToken.None); {
File.Move(tempFilePath, transcriptFilePath, true); this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled."));
this.Logger.LogInformation("Stored the batch transcript '{TranscriptFilePath}' next to media file '{MediaFilePath}'.", transcriptFilePath, fileResult.FilePath); return null;
return transcript;
} }
catch (Exception e) catch (Exception e)
{ {
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, string.Format(T("Was not able to store the transcript next to the media file: {0}"), e.Message), e); this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, string.Format(T("Was not able to read the existing transcript: {0}"), e.Message), e);
return null; return null;
} }
finally
{
try
{
if (File.Exists(tempFilePath))
File.Delete(tempFilePath);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "Was not able to remove the temporary batch transcript '{TempFilePath}'.", tempFilePath);
}
}
} }
private static bool IsTranscribableMedia(string filePath) => FileTypes.IsAllowedPath(filePath, FileTypes.AUDIO, FileTypes.VIDEO); private static bool IsTranscribableMedia(string filePath) => PersistentMediaTranscript.IsMediaPath(filePath);
private static string GetTranscriptFilePath(string mediaFilePath) => mediaFilePath + TRANSCRIPT_FILE_SUFFIX; private static bool HasReusableTranscript(string mediaFilePath) => MediaSidecarTranscripts.HasReusableTranscript(mediaFilePath);
private static bool HasReusableTranscript(string mediaFilePath)
{
var transcriptFilePath = GetTranscriptFilePath(mediaFilePath);
try
{
return File.Exists(transcriptFilePath) && new FileInfo(transcriptFilePath).Length > 0;
}
catch
{
// The concrete read error is reported when the affected file is
// processed. Here we only decide whether a provider is required.
return File.Exists(transcriptFilePath);
}
}
} }
@@ -1,3 +1,4 @@
using AIStudio.Tools.Media;
namespace AIStudio.Assistants.BatchProcessing; namespace AIStudio.Assistants.BatchProcessing;
@@ -160,8 +161,7 @@ public partial class AssistantBatchProcessing
/// </summary> /// </summary>
private static bool IsTranscriptArtifact(string filePath) private static bool IsTranscriptArtifact(string filePath)
{ {
var fileName = Path.GetFileName(filePath); return MediaSidecarTranscripts.IsArtifactPath(filePath);
return fileName.EndsWith(TRANSCRIPT_FILE_SUFFIX, StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(TRANSCRIPT_FILE_SUFFIX + ".tmp", StringComparison.OrdinalIgnoreCase);
} }
/// <summary> /// <summary>
@@ -19,7 +19,6 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
private const string DEFAULT_RESULTS_FILENAME = "batch-results.csv"; private const string DEFAULT_RESULTS_FILENAME = "batch-results.csv";
private const string CSV_EXTENSION = ".csv"; private const string CSV_EXTENSION = ".csv";
private const string RESULT_FILE_SUFFIX = "_result"; private const string RESULT_FILE_SUFFIX = "_result";
private const string TRANSCRIPT_FILE_SUFFIX = ".transcript.md";
private const string TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; private const string TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
private const char LOG_SEPARATOR = ';'; private const char LOG_SEPARATOR = ';';
@@ -343,9 +343,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T4242312602"] = "Send to .
-- Copy result -- Copy result
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T83711157"] = "Copy result" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T83711157"] = "Copy result"
-- The transcription provider returned an empty transcript.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1080540822"] = "The transcription provider returned an empty transcript."
-- Hide Expert Settings -- Hide Expert Settings
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1108876344"] = "Hide Expert Settings" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1108876344"] = "Hide Expert Settings"
@@ -460,9 +457,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Tools for this batch run -- Tools for this batch run
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2247412388"] = "Tools for this batch run" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2247412388"] = "Tools for this batch run"
-- No usable transcription provider is configured.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2282521655"] = "No usable transcription provider is configured."
-- Was not able to create the output folder: {0} -- Was not able to create the output folder: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Was not able to create the output folder: {0}" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Was not able to create the output folder: {0}"
@@ -580,9 +574,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- {0} of {1} files processed -- {0} of {1} files processed
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3648144402"] = "{0} of {1} files processed" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3648144402"] = "{0} of {1} files processed"
-- Was not able to store the transcript next to the media file: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3691287653"] = "Was not able to store the transcript next to the media file: {0}"
-- Time -- Time
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3756319748"] = "Time" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3756319748"] = "Time"
@@ -3958,6 +3949,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2373663376"] = "Good to kno
-- Data folder -- Data folder
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Data folder" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Data folder"
-- The transcripts of the audio and video files in your local data sources.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T260303721"] = "The transcripts of the audio and video files in your local data sources."
-- Explanation -- Explanation
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Explanation" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Explanation"
@@ -4036,9 +4030,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107
-- No, I will choose another embedding -- No, I will choose another embedding
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1246976418"] = "No, I will choose another embedding" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1246976418"] = "No, I will choose another embedding"
-- No, I will choose other providers or files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1811383874"] = "No, I will choose other providers or files"
-- Warning: Your transcription provider '{0}' is not self-hosted. Audio and video files this data source selects are sent to it to be transcribed, which can cost money. AI Studio keeps every transcript, so an unchanged recording is sent only once. MindWork AI Studio has no control over what that third party does with the recordings after they are sent.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T200667320"] = "Warning: Your transcription provider '{0}' is not self-hosted. Audio and video files this data source selects are sent to it to be transcribed, which can cost money. AI Studio keeps every transcript, so an unchanged recording is sent only once. MindWork AI Studio has no control over what that third party does with the recordings after they are sent."
-- Every mail of this mailbox in the selected folder and period, together with the text of its attachments, -- Every mail of this mailbox in the selected folder and period, together with the text of its attachments,
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2046044636"] = "Every mail of this mailbox in the selected folder and period, together with the text of its attachments," UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2046044636"] = "Every mail of this mailbox in the selected folder and period, together with the text of its attachments,"
-- Yes, please send my data to the external providers
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2239851837"] = "Yes, please send my data to the external providers"
-- The data source '{0}' -- The data source '{0}'
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2503488371"] = "The data source '{0}'" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2503488371"] = "The data source '{0}'"
@@ -6775,6 +6778,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T286958948"]
-- Token limit -- Token limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"] = "Token limit" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"] = "Token limit"
-- Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3017942214"] = "Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3119708878"] = "Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings."
-- Please enter 0 or a positive overlap length. -- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length."
@@ -6799,12 +6808,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T529871858"]
-- Select the base directory -- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T599406584"] = "Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider."
-- Yes, index hidden files and folders as well -- Yes, index hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Yes, index hidden files and folders as well" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Yes, index hidden files and folders as well"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T761052435"] = "Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file."
-- The data source token limit must not be larger than the embedding provider token limit ({0}). -- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})."
@@ -6940,9 +6949,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1108876344"] = "
-- Optional expert settings for how this data source is split before embedding. -- Optional expert settings for how this data source is split before embedding.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "Optional expert settings for how this data source is split before embedding." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "Optional expert settings for how this data source is split before embedding."
-- Select a file for this data source. The content of this file will be processed for the data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1190880267"] = "Select a file for this data source. The content of this file will be processed for the data source."
-- Default tokenizer -- Default tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Default tokenizer" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Default tokenizer"
@@ -6976,6 +6982,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T236253137"] = "R
-- Please enter a token limit of at least 1. -- Please enter a token limit of at least 1.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "Please enter a token limit of at least 1." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "Please enter a token limit of at least 1."
-- This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2468760910"] = "This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Add -- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Add" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Add"
@@ -7003,6 +7012,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3361153305"] = "
-- Select the file -- Select the file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Select the file" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Select the file"
-- This audio or video file is skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T4026150982"] = "This audio or video file is skipped until you choose a transcription provider in the settings."
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider. -- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider."
@@ -7021,6 +7033,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T900713019"] = "C
-- Selected file path for this data source -- Selected file path for this data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T939749563"] = "Selected file path for this data source" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T939749563"] = "Selected file path for this data source"
-- Select a file for this data source. The content of this file will be processed for the data source. An audio or video file is transcribed by your transcription provider first.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T966073786"] = "Select a file for this data source. The content of this file will be processed for the data source. An audio or video file is transcribed by your transcription provider first."
-- Token overlap -- Token overlap
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T981382809"] = "Token overlap" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T981382809"] = "Token overlap"
@@ -10153,15 +10168,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1143368054"] = "Other cause"
-- Current file: {0} -- Current file: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Current file: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Current file: {0}"
-- Transcription provider not allowed
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1283933457"] = "Transcription provider not allowed"
-- Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now. -- Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1297391911"] = "Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1297391911"] = "Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now."
-- File {0} of {1} is being indexed: block {2}, page {3}. -- File {0} of {1} is being indexed: block {2}, page {3}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "File {0} of {1} is being indexed: block {2}, page {3}." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "File {0} of {1} is being indexed: block {2}, page {3}."
-- These image, audio, or video files match the data source patterns, but they cannot currently be added to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1302021733"] = "These image, audio, or video files match the data source patterns, but they cannot currently be added to the index."
-- Mail -- Mail
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1399880782"] = "Mail" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1399880782"] = "Mail"
@@ -10192,6 +10207,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1616414701"] = "File {0} of {1} i
-- Skipped: {0} -- Skipped: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1775451753"] = "Skipped: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1775451753"] = "Skipped: {0}"
-- These audio or video files match the data source patterns, but your transcription provider does not meet the confidence level this data source requires. Choose another transcription provider, or change the confidence level of the data source.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1811387825"] = "These audio or video files match the data source patterns, but your transcription provider does not meet the confidence level this data source requires. Choose another transcription provider, or change the confidence level of the data source."
-- Tried again during the next run -- Tried again during the next run
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Tried again during the next run" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Tried again during the next run"
@@ -10264,6 +10282,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3628102488"] = "{0} files ({1})"
-- No local data source has been queued for embedding yet. -- No local data source has been queued for embedding yet.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3774205531"] = "No local data source has been queued for embedding yet." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3774205531"] = "No local data source has been queued for embedding yet."
-- These audio or video files match the data source patterns, but no transcription provider is configured that AI Studio may use. Once you choose one in the settings, AI Studio transcribes the files and adds their text to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3775894071"] = "These audio or video files match the data source patterns, but no transcription provider is configured that AI Studio may use. Once you choose one in the settings, AI Studio transcribes the files and adds their text to the index."
-- Change the settings of the mailbox -- Change the settings of the mailbox
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3788376715"] = "Change the settings of the mailbox" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3788376715"] = "Change the settings of the mailbox"
@@ -10282,9 +10303,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T419343388"] = "Remove them from t
-- Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing. -- Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4205079539"] = "Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4205079539"] = "Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing."
-- These image files match the data source patterns, but images cannot currently be added to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T422032787"] = "These image files match the data source patterns, but images cannot currently be added to the index."
-- Current mail: {0} -- Current mail: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4242653147"] = "Current mail: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4242653147"] = "Current mail: {0}"
-- No transcription provider
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4253595107"] = "No transcription provider"
-- Skipped files: {0}. Open a group below to see why and what you can do. -- Skipped files: {0}. Open a group below to see why and what you can do.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4287145754"] = "Skipped files: {0}. Open a group below to see why and what you can do." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4287145754"] = "Skipped files: {0}. Open a group below to see why and what you can do."
@@ -10303,6 +10330,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T819555436"] = "Failed mails: {0}"
-- Skipped -- Skipped
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Skipped" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Skipped"
-- Open the transcription settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T905182399"] = "Open the transcription settings"
-- Indexed: {0} -- Indexed: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T933775466"] = "Indexed: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T933775466"] = "Indexed: {0}"
@@ -12328,6 +12358,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T677355172"] = "Un
-- Not a readable spreadsheet -- Not a readable spreadsheet
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1175970425"] = "Not a readable spreadsheet" UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1175970425"] = "Not a readable spreadsheet"
-- The media file '{0}' could not be processed, so it was not indexed. AI Studio reads it again as soon as the file changes.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1193172614"] = "The media file '{0}' could not be processed, so it was not indexed. AI Studio reads it again as soon as the file changes."
-- Media file could not be processed
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1302965532"] = "Media file could not be processed"
-- Not a text file -- Not a text file
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1465212038"] = "Not a text file" UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1465212038"] = "Not a text file"
@@ -12562,6 +12598,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAIL::MAILBOXPROVIDERTEMPLATEEXTENSIONS::T4026
-- Edit Mailbox -- Edit Mailbox
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAILBOXEDITING::T3250445637"] = "Edit Mailbox" UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAILBOXEDITING::T3250445637"] = "Edit Mailbox"
-- The transcription provider returned an empty transcript.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T1080540822"] = "The transcription provider returned an empty transcript."
-- No usable transcription provider is configured.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2282521655"] = "No usable transcription provider is configured."
-- Was not able to read the existing transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2397111152"] = "Was not able to read the existing transcript: {0}"
-- The media file changed while it was being transcribed, so its transcript was not kept.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2455890169"] = "The media file changed while it was being transcribed, so its transcript was not kept."
-- The media file could not be read.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T3826117761"] = "The media file could not be read."
-- Was not able to store the transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T920159834"] = "Was not able to store the transcript: {0}"
-- AI Studio couldn't install Pandoc because the archive was not found. -- AI Studio couldn't install Pandoc because the archive was not found.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found." UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found."
@@ -13633,6 +13687,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T1225
-- Some mails could not be indexed. The list below says which ones and why. -- Some mails could not be indexed. The list below says which ones and why.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360744026"] = "Some mails could not be indexed. The list below says which ones and why." UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360744026"] = "Some mails could not be indexed. The list below says which ones and why."
-- The media file '{0}' could not be processed, so it was not indexed. Reason: {1} AI Studio reads it again as soon as the file changes.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2967004571"] = "The media file '{0}' could not be processed, so it was not indexed. Reason: {1} AI Studio reads it again as soon as the file changes."
-- The mail '{0}' could not be indexed. AI Studio tries again during the next sync. -- The mail '{0}' could not be indexed. AI Studio tries again during the next sync.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "The mail '{0}' could not be indexed. AI Studio tries again during the next sync." UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "The mail '{0}' could not be indexed. AI Studio tries again during the next sync."
@@ -14443,8 +14500,8 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2222598306"
-- Please select your security policy. -- Please select your security policy.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Please select your security policy." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Please select your security policy."
-- The selected file is not a supported document or readable text file. -- Please acknowledge that you are aware of the cloud transcription implications.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2728355006"] = "The selected file is not a supported document or readable text file." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2818048408"] = "Please acknowledge that you are aware of the cloud transcription implications."
-- Please test the connection before saving. -- Please test the connection before saving.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"] = "Please test the connection before saving." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"] = "Please test the connection before saving."
@@ -14491,6 +14548,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T490875633"]
-- The file path must not be empty. Please select a file. -- The file path must not be empty. Please select a file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T878007824"] = "The file path must not be empty. Please select a file." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T878007824"] = "The file path must not be empty. Please select a file."
-- The selected file is not a supported document, readable text file, audio file, or video file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T90753429"] = "The selected file is not a supported document, readable text file, audio file, or video file."
-- Please enter the secret necessary for authentication. -- Please enter the secret necessary for authentication.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T968385876"] = "Please enter the secret necessary for authentication." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T968385876"] = "Please enter the secret necessary for authentication."
@@ -147,7 +147,7 @@ internal sealed partial class VisualBriefingBuildOrchestrator
VisualBriefingBuildStage.SOURCE_PREPARATION, VisualBriefingBuildStage.SOURCE_PREPARATION,
"A briefing source is no longer reachable.", "A briefing source is no longer reachable.",
$"Source {source.SourceId:D} failed the reachability check."); $"Source {source.SourceId:D} failed the reachability check.");
var sourceHash = await VisualBriefingHashing.ComputeFileAsync(source.Path, token); var sourceHash = await FileContentHash.ComputeAsync(source.Path, token);
var transcriptHash = string.Empty; var transcriptHash = string.Empty;
if (source.IsMedia) if (source.IsMedia)
{ {
@@ -23,25 +23,6 @@ internal static class VisualBriefingHashing
/// <returns>The lowercase hexadecimal hash.</returns> /// <returns>The lowercase hexadecimal hash.</returns>
internal static string ComputeSections(params string?[] values) => Compute(string.Join('\u001e', values.Select(value => value ?? string.Empty))); internal static string ComputeSections(params string?[] values) => Compute(string.Join('\u001e', values.Select(value => value ?? string.Empty)));
/// <summary>
/// Computes a lowercase SHA-256 hash for a file without loading it fully into memory.
/// </summary>
/// <param name="path">The file path.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The lowercase hexadecimal hash.</returns>
internal static async Task<string> ComputeFileAsync(string path, CancellationToken token)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
65_536,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, token));
}
/// <summary> /// <summary>
/// Returns canonical JSON for one value, with ordinally sorted object properties. /// Returns canonical JSON for one value, with ordinally sorted object properties.
/// </summary> /// </summary>
@@ -41,7 +41,7 @@ internal sealed class VisualBriefingSourcePreparationService(VisualBriefingStore
var info = new FileInfo(source.Path); var info = new FileInfo(source.Path);
totalBytes += info.Length; totalBytes += info.Length;
var sourceHash = await VisualBriefingHashing.ComputeFileAsync(source.Path, token); var sourceHash = await FileContentHash.ComputeAsync(source.Path, token);
var transcriptHash = string.Empty; var transcriptHash = string.Empty;
if (source.IsMedia) if (source.IsMedia)
@@ -83,6 +83,7 @@ public partial class DataFolders : MSGComponentBase
DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."), DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."),
DataBackupEntryKind.DATA_SOURCE_INDEX => T("The index of your local data sources: which files AI Studio embedded, and their text."), DataBackupEntryKind.DATA_SOURCE_INDEX => T("The index of your local data sources: which files AI Studio embedded, and their text."),
DataBackupEntryKind.VECTOR_STORES => T("The embeddings of your local data sources."), DataBackupEntryKind.VECTOR_STORES => T("The embeddings of your local data sources."),
DataBackupEntryKind.MEDIA_TRANSCRIPTS => T("The transcripts of the audio and video files in your local data sources."),
DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS => T("Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs."), DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS => T("Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs."),
DataBackupEntryKind.BUILT_IN_PLUGINS => T("Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself."), DataBackupEntryKind.BUILT_IN_PLUGINS => T("Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself."),
@@ -1,9 +1,18 @@
@inherits MSGComponentBase @inherits MSGComponentBase
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="mb-3"> <MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="mb-3">
<MudJustifiedText Typo="Typo.body2"> @if (this.SendsEmbeddingsToCloud)
@this.WarningText {
</MudJustifiedText> <MudJustifiedText Typo="Typo.body2">
@this.WarningText
</MudJustifiedText>
}
@if (this.SendsMediaToCloudTranscription)
{
<MudJustifiedText Typo="Typo.body2" Class="@(this.SendsEmbeddingsToCloud ? "mt-2" : string.Empty)">
@this.TranscriptionWarningText
</MudJustifiedText>
}
</MudAlert> </MudAlert>
<MudTextSwitch Value="@this.UserAcknowledged" ValueChanged="@this.UserAcknowledgedChanged" Label="@T("I confirm that I have read and understood the above")" LabelOn="@T("Yes, please send my data to the external embedding provider")" LabelOff="@T("No, I will choose another embedding")" Validation="@this.Validation"/> <MudTextSwitch Value="@this.UserAcknowledged" ValueChanged="@this.UserAcknowledgedChanged" Label="@T("I confirm that I have read and understood the above")" LabelOn="@this.LabelOn" LabelOff="@this.LabelOff" Validation="@this.Validation"/>
@@ -4,6 +4,10 @@ using Microsoft.AspNetCore.Components;
namespace AIStudio.Components; namespace AIStudio.Components;
/// <summary>
/// Warns that a data source sends its content to providers which are not self-hosted: its text to
/// the embedding provider, and its audio and video files to the transcription provider.
/// </summary>
public partial class DataSourceCloudEmbeddingWarning : MSGComponentBase public partial class DataSourceCloudEmbeddingWarning : MSGComponentBase
{ {
[Parameter] [Parameter]
@@ -21,6 +25,33 @@ public partial class DataSourceCloudEmbeddingWarning : MSGComponentBase
[Parameter] [Parameter]
public Func<bool, string?> Validation { get; set; } = _ => null; public Func<bool, string?> Validation { get; set; } = _ => null;
/// <summary>
/// Whether the content goes to an embedding provider which is not self-hosted.
/// </summary>
[Parameter]
public bool SendsEmbeddingsToCloud { get; set; } = true;
/// <summary>
/// The name of the transcription provider which is not self-hosted and receives the audio and
/// video files of the data source, or empty when no media file leaves the computer.
/// </summary>
[Parameter]
public string CloudTranscriptionProvider { get; set; } = string.Empty;
private bool SendsMediaToCloudTranscription => !string.IsNullOrWhiteSpace(this.CloudTranscriptionProvider);
private string LabelOn => this.SendsMediaToCloudTranscription
? T("Yes, please send my data to the external providers")
: T("Yes, please send my data to the external embedding provider");
private string LabelOff => this.SendsMediaToCloudTranscription
? T("No, I will choose other providers or files")
: T("No, I will choose another embedding");
private string TranscriptionWarningText => string.Format(
T("Warning: Your transcription provider '{0}' is not self-hosted. Audio and video files this data source selects are sent to it to be transcribed, which can cost money. AI Studio keeps every transcript, so an unchanged recording is sent only once. MindWork AI Studio has no control over what that third party does with the recordings after they are sent."),
this.CloudTranscriptionProvider);
private string WarningText private string WarningText
{ {
get get
@@ -90,7 +90,7 @@
Class="mb-3" Class="mb-3"
Immediate="@true" Immediate="@true"
Validation="@this.dataSourceValidation.ValidateFilePatterns" Validation="@this.dataSourceValidation.ValidateFilePatterns"
HelperText="@T("Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.")" HelperText="@T("Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.")"
Adornment="Adornment.Start" Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterAlt" AdornmentIcon="@Icons.Material.Filled.FilterAlt"
AdornmentColor="Color.Info" AdornmentColor="Color.Info"
@@ -117,20 +117,17 @@
} }
</MudSelect> </MudSelect>
@if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId)) @if (this.CurrentCloudProcessing is not CloudProcessing.NONE)
{ {
if (this.SelectedCloudEmbedding) <DataSourceCloudEmbeddingWarning DataSourceType="DataSourceType.LOCAL_DIRECTORY" SourcePath="@this.dataPath" SendsEmbeddingsToCloud="@this.SelectedCloudEmbedding" CloudTranscriptionProvider="@this.CloudTranscriptionProviderName" @bind-UserAcknowledged="@this.UserAcknowledgedCloudProcessing" Validation="@this.dataSourceValidation.ValidateUserAcknowledgedCloudEmbedding"/>
{
<DataSourceCloudEmbeddingWarning DataSourceType="DataSourceType.LOCAL_DIRECTORY" SourcePath="@this.dataPath" @bind-UserAcknowledged="@this.dataUserAcknowledgedCloudEmbedding" Validation="@this.dataSourceValidation.ValidateUserAcknowledgedCloudEmbedding"/>
}
else
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
@T("The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.")
</MudJustifiedText>
}
} }
else if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
@T("The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.")
</MudJustifiedText>
}
<ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/> <ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
<MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel"> <MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
@@ -141,7 +138,14 @@
</MudSelectItem> </MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (this.MediaTranscriptionHint is { } mediaTranscriptionHint)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="@true" Class="mb-3">
@mediaTranscriptionHint
</MudAlert>
}
<MudStack Class="mb-3"> <MudStack Class="mb-3">
<MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune"> <MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune">
@(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings")) @(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings"))
@@ -42,6 +42,9 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
[Inject] [Inject]
private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!; private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!;
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new(); private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private readonly DataSourceValidation dataSourceValidation; private readonly DataSourceValidation dataSourceValidation;
@@ -59,7 +62,7 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
private string dataId = Guid.NewGuid().ToString(); private string dataId = Guid.NewGuid().ToString();
private string dataName = string.Empty; private string dataName = string.Empty;
private string dataDescription = string.Empty; private string dataDescription = string.Empty;
private bool dataUserAcknowledgedCloudEmbedding; private CloudProcessing acknowledgedCloudProcessing;
private string dataEmbeddingId = string.Empty; private string dataEmbeddingId = string.Empty;
private string dataPath = string.Empty; private string dataPath = string.Empty;
private string dataFilePatterns = DataSourceLocalDirectory.DEFAULT_FILE_PATTERNS; private string dataFilePatterns = DataSourceLocalDirectory.DEFAULT_FILE_PATTERNS;
@@ -88,6 +91,7 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
this.dataSourceValidation = new() this.dataSourceValidation = new()
{ {
GetSelectedCloudEmbedding = () => this.SelectedCloudEmbedding, GetSelectedCloudEmbedding = () => this.SelectedCloudEmbedding,
GetSelectedCloudTranscription = () => this.SelectedCloudTranscription,
GetSelectedEmbeddingProvider = () => this.SelectedEmbedding, GetSelectedEmbeddingProvider = () => this.SelectedEmbedding,
GetConfidenceLevel = () => this.dataConfidenceLevel, GetConfidenceLevel = () => this.dataConfidenceLevel,
GetSettingsManager = () => this.SettingsManager, GetSettingsManager = () => this.SettingsManager,
@@ -162,6 +166,42 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
private bool SelectedCloudEmbedding => this.SelectedEmbedding is { IsSelfHosted: false }; private bool SelectedCloudEmbedding => this.SelectedEmbedding is { IsSelfHosted: false };
/// <summary>
/// Whether the file patterns may select audio or video files, which the data source transcribes.
/// </summary>
private bool SelectsMedia => FileNamePatterns.MaySelectMedia(FileNamePatterns.Parse(this.dataFilePatterns));
/// <summary>
/// The transcription provider which may hear the audio and video files of this data source, or null.
/// </summary>
private TranscriptionProvider? AllowedTranscriptionProvider => this.MediaTranscriptionService.GetUsableProvider(this.CreateDataSource());
private bool SelectedCloudTranscription => this.SelectsMedia && this.AllowedTranscriptionProvider is { IsSelfHosted: false };
private string CloudTranscriptionProviderName => this.SelectedCloudTranscription ? this.AllowedTranscriptionProvider?.Name ?? string.Empty : string.Empty;
private CloudProcessing CurrentCloudProcessing => (this.SelectedCloudEmbedding ? CloudProcessing.EMBEDDING : CloudProcessing.NONE) | (this.SelectedCloudTranscription ? CloudProcessing.TRANSCRIPTION : CloudProcessing.NONE);
/// <remarks>
/// The acknowledgment holds for the providers it was given for, cf. CloudProcessingExtensions.Covers.
/// </remarks>
private bool UserAcknowledgedCloudProcessing
{
get => this.acknowledgedCloudProcessing.Covers(this.CurrentCloudProcessing);
set => this.acknowledgedCloudProcessing = value ? this.CurrentCloudProcessing : CloudProcessing.NONE;
}
/// <summary>
/// Why audio and video files which the patterns select would not be indexed, or null when they would.
/// </summary>
private string? MediaTranscriptionHint => !this.SelectsMedia
? null
: this.MediaTranscriptionService.GetUsableProvider() is null
? T("Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings.")
: this.AllowedTranscriptionProvider is null
? T("Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.")
: null;
private bool CanChangeSource => !this.IsEditing || !this.LockSource; private bool CanChangeSource => !this.IsEditing || !this.LockSource;
private IEnumerable<ConfigurationSelectData<ConfidenceLevel>> ConfidenceLevels => ConfigurationSelectDataFactory.GetDataSourceConfidenceLevelsData(); private IEnumerable<ConfigurationSelectData<ConfidenceLevel>> ConfidenceLevels => ConfigurationSelectDataFactory.GetDataSourceConfidenceLevelsData();
@@ -43,7 +43,7 @@
/> />
<MudJustifiedText Typo="Typo.body1" Class="mb-3"> <MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Select a file for this data source. The content of this file will be processed for the data source.") @T("Select a file for this data source. The content of this file will be processed for the data source. An audio or video file is transcribed by your transcription provider first.")
</MudJustifiedText> </MudJustifiedText>
@if (!this.CanChangeSource) @if (!this.CanChangeSource)
{ {
@@ -90,18 +90,15 @@
} }
</MudSelect> </MudSelect>
@if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId)) @if (this.CurrentCloudProcessing is not CloudProcessing.NONE)
{ {
if (this.SelectedCloudEmbedding) <DataSourceCloudEmbeddingWarning DataSourceType="DataSourceType.LOCAL_FILE" SourcePath="@this.dataFilePath" SendsEmbeddingsToCloud="@this.SelectedCloudEmbedding" CloudTranscriptionProvider="@this.CloudTranscriptionProviderName" @bind-UserAcknowledged="@this.UserAcknowledgedCloudProcessing" Validation="@this.dataSourceValidation.ValidateUserAcknowledgedCloudEmbedding"/>
{ }
<DataSourceCloudEmbeddingWarning DataSourceType="DataSourceType.LOCAL_FILE" SourcePath="@this.dataFilePath" @bind-UserAcknowledged="@this.dataUserAcknowledgedCloudEmbedding" Validation="@this.dataSourceValidation.ValidateUserAcknowledgedCloudEmbedding"/> else if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
} {
else <MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
{ @T("The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.")
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3"> </MudJustifiedText>
@T("The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.")
</MudJustifiedText>
}
} }
<ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/> <ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
@@ -115,6 +112,13 @@
} }
</MudSelect> </MudSelect>
@if (this.MediaTranscriptionHint is { } mediaTranscriptionHint)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="@true" Class="mb-3">
@mediaTranscriptionHint
</MudAlert>
}
<MudStack Class="mb-3"> <MudStack Class="mb-3">
<MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune"> <MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune">
@(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings")) @(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings"))
@@ -2,6 +2,7 @@ using AIStudio.Components;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
using AIStudio.Tools.Validation; using AIStudio.Tools.Validation;
@@ -42,6 +43,9 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
[Inject] [Inject]
private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!; private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!;
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new(); private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private readonly DataSourceValidation dataSourceValidation; private readonly DataSourceValidation dataSourceValidation;
@@ -59,7 +63,7 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
private string dataId = Guid.NewGuid().ToString(); private string dataId = Guid.NewGuid().ToString();
private string dataName = string.Empty; private string dataName = string.Empty;
private string dataDescription = string.Empty; private string dataDescription = string.Empty;
private bool dataUserAcknowledgedCloudEmbedding; private CloudProcessing acknowledgedCloudProcessing;
private string dataEmbeddingId = string.Empty; private string dataEmbeddingId = string.Empty;
private string dataFilePath = string.Empty; private string dataFilePath = string.Empty;
private int dataMaxChunkTokenLength; private int dataMaxChunkTokenLength;
@@ -83,6 +87,7 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
this.dataSourceValidation = new() this.dataSourceValidation = new()
{ {
GetSelectedCloudEmbedding = () => this.SelectedCloudEmbedding, GetSelectedCloudEmbedding = () => this.SelectedCloudEmbedding,
GetSelectedCloudTranscription = () => this.SelectedCloudTranscription,
GetSelectedEmbeddingProvider = () => this.SelectedEmbedding, GetSelectedEmbeddingProvider = () => this.SelectedEmbedding,
GetConfidenceLevel = () => this.dataConfidenceLevel, GetConfidenceLevel = () => this.dataConfidenceLevel,
GetSettingsManager = () => this.SettingsManager, GetSettingsManager = () => this.SettingsManager,
@@ -152,6 +157,42 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
private bool SelectedCloudEmbedding => this.SelectedEmbedding is { IsSelfHosted: false }; private bool SelectedCloudEmbedding => this.SelectedEmbedding is { IsSelfHosted: false };
/// <summary>
/// Whether the selected file is an audio or video file, which the data source transcribes.
/// </summary>
private bool SelectsMedia => PersistentMediaTranscript.IsMediaPath(this.dataFilePath);
/// <summary>
/// The transcription provider which may hear the audio and video files of this data source, or null.
/// </summary>
private TranscriptionProvider? AllowedTranscriptionProvider => this.MediaTranscriptionService.GetUsableProvider(this.CreateDataSource());
private bool SelectedCloudTranscription => this.SelectsMedia && this.AllowedTranscriptionProvider is { IsSelfHosted: false };
private string CloudTranscriptionProviderName => this.SelectedCloudTranscription ? this.AllowedTranscriptionProvider?.Name ?? string.Empty : string.Empty;
private CloudProcessing CurrentCloudProcessing => (this.SelectedCloudEmbedding ? CloudProcessing.EMBEDDING : CloudProcessing.NONE) | (this.SelectedCloudTranscription ? CloudProcessing.TRANSCRIPTION : CloudProcessing.NONE);
/// <remarks>
/// The acknowledgment holds for the providers it was given for, cf. CloudProcessingExtensions.Covers.
/// </remarks>
private bool UserAcknowledgedCloudProcessing
{
get => this.acknowledgedCloudProcessing.Covers(this.CurrentCloudProcessing);
set => this.acknowledgedCloudProcessing = value ? this.CurrentCloudProcessing : CloudProcessing.NONE;
}
/// <summary>
/// Why the selected audio or video file would not be indexed, or null when it would.
/// </summary>
private string? MediaTranscriptionHint => !this.SelectsMedia
? null
: this.MediaTranscriptionService.GetUsableProvider() is null
? T("This audio or video file is skipped until you choose a transcription provider in the settings.")
: this.AllowedTranscriptionProvider is null
? T("This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.")
: null;
private bool CanChangeSource => !this.IsEditing || !this.LockSource; private bool CanChangeSource => !this.IsEditing || !this.LockSource;
private IEnumerable<ConfigurationSelectData<ConfidenceLevel>> ConfidenceLevels => ConfigurationSelectDataFactory.GetDataSourceConfidenceLevelsData(); private IEnumerable<ConfigurationSelectData<ConfidenceLevel>> ConfidenceLevels => ConfigurationSelectDataFactory.GetDataSourceConfidenceLevelsData();
+11 -2
View File
@@ -137,7 +137,7 @@
} }
@if (group.Cause.NeedsProviderSettings) @if (group.Cause.NeedsProviderSettings)
{ {
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" OnClick="@this.OpenEmbeddingProviderSettings"> <MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" OnClick="@this.OpenProviderSettings">
@T("Open the settings") @T("Open the settings")
</MudButton> </MudButton>
} }
@@ -198,9 +198,18 @@
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined"> <MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">
@T("Not added to the index") @T("Not added to the index")
</MudChip> </MudChip>
@if (group.Cause.NeedsDataSourceSettings) @if (group.Cause.NeedsDataSourceSettings || group.Cause.NeedsTranscriptionSettings)
{ {
<MudSpacer/> <MudSpacer/>
}
@if (group.Cause.NeedsTranscriptionSettings)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" OnClick="@this.OpenProviderSettings">
@T("Open the transcription settings")
</MudButton>
}
@if (group.Cause.NeedsDataSourceSettings)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" OnClick="@this.OpenDataSourceSettings"> <MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" OnClick="@this.OpenDataSourceSettings">
@T("Manage the data sources") @T("Manage the data sources")
</MudButton> </MudButton>
@@ -276,7 +276,7 @@ public partial class Embeddings : MSGComponentBase
private sealed record FailureGroup(FailureCause Cause, string EmbeddingProviderName, IReadOnlyList<DataSourceEmbeddingFailure> Failures); private sealed record FailureGroup(FailureCause Cause, string EmbeddingProviderName, IReadOnlyList<DataSourceEmbeddingFailure> Failures);
private sealed record IgnoredFileCause(int Priority, string Title, string Description, string Icon, Color Color, bool NeedsDataSourceSettings); private sealed record IgnoredFileCause(int Priority, string Title, string Description, string Icon, Color Color, bool NeedsDataSourceSettings, bool NeedsTranscriptionSettings);
private sealed record IgnoredFileGroup(IgnoredFileCause Cause, IReadOnlyList<DataSourceEmbeddingIgnoredFile> Files); private sealed record IgnoredFileGroup(IgnoredFileCause Cause, IReadOnlyList<DataSourceEmbeddingIgnoredFile> Files);
@@ -315,8 +315,9 @@ public partial class Embeddings : MSGComponentBase
var hasCauseName = !string.IsNullOrWhiteSpace(causeName); var hasCauseName = !string.IsNullOrWhiteSpace(causeName);
var title = hasCauseName ? causeName : T("Other cause"); var title = hasCauseName ? causeName : T("Other cause");
var showsMessagePerFile = !hasCauseName || failure.ExtractionCode is FileExtractionErrorCode.MEDIA_PROCESSING_FAILED;
return failure.IsPermanent return failure.IsPermanent
? new FailureCause(3, title, Icons.Material.Filled.SkipNext, Color.Default, true, false, !hasCauseName) ? new FailureCause(3, title, Icons.Material.Filled.SkipNext, Color.Default, true, false, showsMessagePerFile)
: new FailureCause(2, title, Icons.Material.Filled.ReportProblem, Color.Warning, false, false, !hasCauseName); : new FailureCause(2, title, Icons.Material.Filled.ReportProblem, Color.Warning, false, false, !hasCauseName);
} }
@@ -342,13 +343,31 @@ public partial class Embeddings : MSGComponentBase
string.Format(T("These files match the data source patterns, but they are larger than its limit of {0}. You can raise the limit in the expert settings of the data source."), this.GetMaxFileSizeText(status)), string.Format(T("These files match the data source patterns, but they are larger than its limit of {0}. You can raise the limit in the expert settings of the data source."), this.GetMaxFileSizeText(status)),
Icons.Material.Filled.Straighten, Icons.Material.Filled.Straighten,
Color.Warning, Color.Warning,
true,
false),
DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING => new IgnoredFileCause(
0,
T("No transcription provider"),
T("These audio or video files match the data source patterns, but no transcription provider is configured that AI Studio may use. Once you choose one in the settings, AI Studio transcribes the files and adds their text to the index."),
Icons.Material.Filled.MicOff,
Color.Warning,
false,
true), true),
DataSourceEmbeddingIgnoreReason.MEDIA_NOT_SUPPORTED => new IgnoredFileCause( DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED => new IgnoredFileCause(
0,
T("Transcription provider not allowed"),
T("These audio or video files match the data source patterns, but your transcription provider does not meet the confidence level this data source requires. Choose another transcription provider, or change the confidence level of the data source."),
Icons.Material.Filled.GppBad,
Color.Warning,
true,
true),
DataSourceEmbeddingIgnoreReason.IMAGE_NOT_SUPPORTED => new IgnoredFileCause(
1, 1,
FileTypes.MEDIA.FilterName, FileTypes.IMAGE.FilterName,
T("These image, audio, or video files match the data source patterns, but they cannot currently be added to the index."), T("These image files match the data source patterns, but images cannot currently be added to the index."),
Icons.Material.Filled.PermMedia, Icons.Material.Filled.ImageNotSupported,
Color.Default, Color.Default,
false,
false), false),
_ => throw new ArgumentOutOfRangeException(nameof(file.Reason), file.Reason, null), _ => throw new ArgumentOutOfRangeException(nameof(file.Reason), file.Reason, null),
}; };
@@ -445,7 +464,9 @@ public partial class Embeddings : MSGComponentBase
{ {
return this.DataSourceEmbeddingService.CanRefreshDataSource(status.DataSourceId) && return this.DataSourceEmbeddingService.CanRefreshDataSource(status.DataSourceId) &&
status is { VectorStoreUnreadable: false, State: not DataSourceEmbeddingState.RUNNING and not DataSourceEmbeddingState.QUEUED } && status is { VectorStoreUnreadable: false, State: not DataSourceEmbeddingState.RUNNING and not DataSourceEmbeddingState.QUEUED } &&
(status.State is DataSourceEmbeddingState.FAILED || status.FailedDocuments > 0); (status.State is DataSourceEmbeddingState.FAILED
|| status.FailedDocuments > 0
|| status.NeedsTranscriptionProvider);
} }
/// <remarks> /// <remarks>
@@ -460,13 +481,13 @@ public partial class Embeddings : MSGComponentBase
} }
/// <summary> /// <summary>
/// Takes the user to the settings, where the embedding providers are configured. /// Takes the user to the settings, where embedding and transcription providers are configured.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Offered only for the failures a setting fixes, such as a rejected API key. Reading what /// Offered only for a problem a provider setting fixes. Reading what went wrong and then
/// went wrong and then having to find the right page is where people give up. /// having to find the right page is where people give up.
/// </remarks> /// </remarks>
private void OpenEmbeddingProviderSettings() => this.NavigationManager.NavigateTo(Routes.SETTINGS); private void OpenProviderSettings() => this.NavigationManager.NavigateTo(Routes.SETTINGS);
private async Task RefreshDataSource(DataSourceEmbeddingStatus status) private async Task RefreshDataSource(DataSourceEmbeddingStatus status)
{ {
@@ -345,9 +345,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T4242312602"] = "Senden an
-- Copy result -- Copy result
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T83711157"] = "Ergebnis kopieren" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T83711157"] = "Ergebnis kopieren"
-- The transcription provider returned an empty transcript.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1080540822"] = "Der Anbieter für Transkriptionen hat eine leere Transkription zurückgegeben."
-- Hide Expert Settings -- Hide Expert Settings
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1108876344"] = "Experten-Einstellungen ausblenden" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1108876344"] = "Experten-Einstellungen ausblenden"
@@ -462,9 +459,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Tools for this batch run -- Tools for this batch run
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2247412388"] = "Werkzeuge für diesen Durchlauf" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2247412388"] = "Werkzeuge für diesen Durchlauf"
-- No usable transcription provider is configured.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2282521655"] = "Es ist kein verwendbarer Anbieter für Transkriptionen konfiguriert."
-- Was not able to create the output folder: {0} -- Was not able to create the output folder: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Der Ausgabeordner konnte nicht erstellt werden: {0}" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Der Ausgabeordner konnte nicht erstellt werden: {0}"
@@ -582,9 +576,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- {0} of {1} files processed -- {0} of {1} files processed
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3648144402"] = "{0} von {1} Dateien verarbeitet" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3648144402"] = "{0} von {1} Dateien verarbeitet"
-- Was not able to store the transcript next to the media file: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3691287653"] = "Das Transkript konnte nicht neben der Mediendatei gespeichert werden: {0}"
-- Time -- Time
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3756319748"] = "Zeit" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3756319748"] = "Zeit"
@@ -3960,6 +3951,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2373663376"] = "Gut zu wiss
-- Data folder -- Data folder
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Datenordner" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Datenordner"
-- The transcripts of the audio and video files in your local data sources.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T260303721"] = "Die Transkripte der Audio- und Videodateien in Ihren lokalen Datenquellen."
-- Explanation -- Explanation
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Erklärung" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Erklärung"
@@ -4038,9 +4032,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107
-- No, I will choose another embedding -- No, I will choose another embedding
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1246976418"] = "Nein, ich wähle eine andere Einbettung aus" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1246976418"] = "Nein, ich wähle eine andere Einbettung aus"
-- No, I will choose other providers or files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1811383874"] = "Nein, ich wähle andere Anbieter oder Dateien aus"
-- Warning: Your transcription provider '{0}' is not self-hosted. Audio and video files this data source selects are sent to it to be transcribed, which can cost money. AI Studio keeps every transcript, so an unchanged recording is sent only once. MindWork AI Studio has no control over what that third party does with the recordings after they are sent.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T200667320"] = "Warnung: Ihr Transkriptionsanbieter „{0}“ ist nicht selbst gehostet. Audio- und Videodateien, die diese Datenquelle auswählt, werden zur Transkription an ihn gesendet, was Geld kosten kann. AI Studio speichert jedes Transkript, sodass eine unveränderte Aufnahme nur einmal gesendet wird. MindWork AI Studio hat keine Kontrolle darüber, was dieser Dritte mit den Aufnahmen nach dem Versand macht."
-- Every mail of this mailbox in the selected folder and period, together with the text of its attachments, -- Every mail of this mailbox in the selected folder and period, together with the text of its attachments,
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2046044636"] = "Jede E-Mail dieses Postfachs im ausgewählten Ordner und Zeitraum, zusammen mit dem Text ihrer Anhänge," UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2046044636"] = "Jede E-Mail dieses Postfachs im ausgewählten Ordner und Zeitraum, zusammen mit dem Text ihrer Anhänge,"
-- Yes, please send my data to the external providers
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2239851837"] = "Ja, bitte senden Sie meine Daten an die externen Anbieter"
-- The data source '{0}' -- The data source '{0}'
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2503488371"] = "Die Datenquelle „{0}“" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2503488371"] = "Die Datenquelle „{0}“"
@@ -6777,6 +6780,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T286958948"]
-- Token limit -- Token limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"] = "Token-Limit" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"] = "Token-Limit"
-- Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3017942214"] = "Audio- und Videodateien, die diese Datenquelle auswählt, werden übersprungen, weil Ihr Transkriptionsanbieter das Vertrauensniveau nicht erfüllt, das diese Datenquelle erfordert."
-- Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3119708878"] = "Audio- und Videodateien, die diese Datenquelle auswählt, werden übersprungen, bis Sie in den Einstellungen einen Transkriptionsanbieter auswählen."
-- Please enter 0 or a positive overlap length. -- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Bitte geben Sie 0 oder eine positive Überlappungslänge ein." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Bitte geben Sie 0 oder eine positive Überlappungslänge ein."
@@ -6801,12 +6810,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T529871858"]
-- Select the base directory -- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Wählen Sie das Stammverzeichnis aus" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Wählen Sie das Stammverzeichnis aus"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T599406584"] = "Welche Dateien sollen indexiert werden? Trennen Sie mehrere Muster durch ein Semikolon, z. B. *.txt;*.pdf;*.docx;.gitignore. Verwenden Sie :documents: für alle Dokumente und lesbaren Textdateien, :text: nur für lesbare Textdateien oder * für alle Dateien. Audio- und Videodateien, z. B. *.mp3 oder *.mp4, werden von Ihrem Transkriptionsanbieter transkribiert."
-- Yes, index hidden files and folders as well -- Yes, index hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Ja, auch versteckte Dateien und Ordner indexieren" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Ja, auch versteckte Dateien und Ordner indexieren"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T761052435"] = "Welche Dateien sollen indexiert werden? Trennen Sie mehrere Muster durch ein Semikolon, z. B. *.txt;*.pdf;*.docx;.gitignore. Verwenden Sie :documents: für alle Dokumente und lesbaren Textdateien, :text: nur für lesbare Textdateien oder * für alle Dateien."
-- The data source token limit must not be larger than the embedding provider token limit ({0}). -- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "Das Token-Limit der Datenquelle darf nicht größer sein als das Token-Limit des Einbettungsanbieters ({0})." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "Das Token-Limit der Datenquelle darf nicht größer sein als das Token-Limit des Einbettungsanbieters ({0})."
@@ -6942,9 +6951,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1108876344"] = "
-- Optional expert settings for how this data source is split before embedding. -- Optional expert settings for how this data source is split before embedding.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "Optionale Experteneinstellungen für die Aufteilung dieser Datenquelle vor dem Einbetten." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "Optionale Experteneinstellungen für die Aufteilung dieser Datenquelle vor dem Einbetten."
-- Select a file for this data source. The content of this file will be processed for the data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1190880267"] = "Wählen Sie eine Datei für diese Datenquelle aus. Der Inhalt dieser Datei wird für die Datenquelle verarbeitet."
-- Default tokenizer -- Default tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Standard-Tokenizer" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Standard-Tokenizer"
@@ -6978,6 +6984,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T236253137"] = "E
-- Please enter a token limit of at least 1. -- Please enter a token limit of at least 1.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "Bitte geben Sie ein Token-Limit von mindestens 1 ein." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "Bitte geben Sie ein Token-Limit von mindestens 1 ein."
-- This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2468760910"] = "Diese Audio- oder Videodatei wird übersprungen, weil Ihr Transkriptionsanbieter das Vertrauensniveau nicht erfüllt, das diese Datenquelle erfordert."
-- Add -- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Hinzufügen" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Hinzufügen"
@@ -7005,6 +7014,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3361153305"] = "
-- Select the file -- Select the file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Datei auswählen" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Datei auswählen"
-- This audio or video file is skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T4026150982"] = "Diese Audio- oder Videodatei wird übersprungen, bis Sie in den Einstellungen einen Transkriptionsanbieter auswählen."
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider. -- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "Damit die KI jederzeit die passenden Daten ermitteln kann, müssen Sie einen Einbettungsanbieter auswählen." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "Damit die KI jederzeit die passenden Daten ermitteln kann, müssen Sie einen Einbettungsanbieter auswählen."
@@ -7023,6 +7035,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T900713019"] = "A
-- Selected file path for this data source -- Selected file path for this data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T939749563"] = "Ausgewählter Dateipfad für diese Datenquelle" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T939749563"] = "Ausgewählter Dateipfad für diese Datenquelle"
-- Select a file for this data source. The content of this file will be processed for the data source. An audio or video file is transcribed by your transcription provider first.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T966073786"] = "Wählen Sie eine Datei für diese Datenquelle aus. Der Inhalt dieser Datei wird für die Datenquelle verarbeitet. Eine Audio- oder Videodatei wird zunächst von Ihrem Transkriptionsanbieter transkribiert."
-- Token overlap -- Token overlap
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T981382809"] = "Token-Überlappung" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T981382809"] = "Token-Überlappung"
@@ -10155,15 +10170,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1143368054"] = "Andere Ursache"
-- Current file: {0} -- Current file: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Aktuelle Datei: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Aktuelle Datei: {0}"
-- Transcription provider not allowed
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1283933457"] = "Transkriptionsanbieter nicht zulässig"
-- Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now. -- Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1297391911"] = "Postfächer werden alle 16 Minuten synchronisiert, solange die automatische Aktualisierung aktiviert ist. Jede E-Mail wird nur einmal eingebettet: Wird sie in einen anderen Ordner verschoben, merkt sich AI Studio lediglich, wo sie sich jetzt befindet." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1297391911"] = "Postfächer werden alle 16 Minuten synchronisiert, solange die automatische Aktualisierung aktiviert ist. Jede E-Mail wird nur einmal eingebettet: Wird sie in einen anderen Ordner verschoben, merkt sich AI Studio lediglich, wo sie sich jetzt befindet."
-- File {0} of {1} is being indexed: block {2}, page {3}. -- File {0} of {1} is being indexed: block {2}, page {3}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "Datei {0} von {1} wird indexiert: Block {2}, Seite {3}." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "Datei {0} von {1} wird indexiert: Block {2}, Seite {3}."
-- These image, audio, or video files match the data source patterns, but they cannot currently be added to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1302021733"] = "Diese Bild-, Audio- oder Videodateien entsprechen den Mustern der Datenquelle, können derzeit jedoch nicht zum Index hinzugefügt werden."
-- Mail -- Mail
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1399880782"] = "E-Mail" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1399880782"] = "E-Mail"
@@ -10194,6 +10209,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1616414701"] = "Datei {0} von {1}
-- Skipped: {0} -- Skipped: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1775451753"] = "Übersprungen: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1775451753"] = "Übersprungen: {0}"
-- These audio or video files match the data source patterns, but your transcription provider does not meet the confidence level this data source requires. Choose another transcription provider, or change the confidence level of the data source.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1811387825"] = "Diese Audio- oder Videodateien entsprechen den Mustern der Datenquelle, aber Ihr Transkriptionsanbieter erfüllt nicht das Vertrauensniveau, das die Datenquelle erfordert. Wählen Sie einen anderen Transkriptionsanbieter oder ändern Sie das Vertrauensniveau der Datenquelle."
-- Tried again during the next run -- Tried again during the next run
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Beim nächsten Durchlauf erneut versucht" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Beim nächsten Durchlauf erneut versucht"
@@ -10266,6 +10284,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3628102488"] = "{0} Dateien ({1})
-- No local data source has been queued for embedding yet. -- No local data source has been queued for embedding yet.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3774205531"] = "Es wurde noch keine lokale Datenquelle für die Einbettung in die Warteschlange aufgenommen." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3774205531"] = "Es wurde noch keine lokale Datenquelle für die Einbettung in die Warteschlange aufgenommen."
-- These audio or video files match the data source patterns, but no transcription provider is configured that AI Studio may use. Once you choose one in the settings, AI Studio transcribes the files and adds their text to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3775894071"] = "Diese Audio- oder Videodateien entsprechen den Mustern der Datenquelle, aber es ist kein Transkriptionsanbieter konfiguriert, den AI Studio verwenden darf. Sobald Sie in den Einstellungen einen Anbieter auswählen, transkribiert AI Studio die Dateien und fügt ihren Text zum Index hinzu."
-- Change the settings of the mailbox -- Change the settings of the mailbox
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3788376715"] = "Einstellungen des Postfachs ändern" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3788376715"] = "Einstellungen des Postfachs ändern"
@@ -10284,9 +10305,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T419343388"] = "Aus dem Index entf
-- Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing. -- Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4205079539"] = "Noch nicht vollständig synchronisiert. AI Studio synchronisiert das Postfach nach und nach, beginnend mit den neuesten E-Mails. Ältere E-Mails fehlen noch." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4205079539"] = "Noch nicht vollständig synchronisiert. AI Studio synchronisiert das Postfach nach und nach, beginnend mit den neuesten E-Mails. Ältere E-Mails fehlen noch."
-- These image files match the data source patterns, but images cannot currently be added to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T422032787"] = "Diese Bilddateien entsprechen den Mustern der Datenquelle, können derzeit jedoch nicht zum Index hinzugefügt werden."
-- Current mail: {0} -- Current mail: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4242653147"] = "Aktuelle E-Mail: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4242653147"] = "Aktuelle E-Mail: {0}"
-- No transcription provider
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4253595107"] = "Kein Transkriptionsanbieter"
-- Skipped files: {0}. Open a group below to see why and what you can do. -- Skipped files: {0}. Open a group below to see why and what you can do.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4287145754"] = "Übersprungene Dateien: {0}. Öffnen Sie unten eine Gruppe, um zu sehen, warum und was Sie tun können." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4287145754"] = "Übersprungene Dateien: {0}. Öffnen Sie unten eine Gruppe, um zu sehen, warum und was Sie tun können."
@@ -10305,6 +10332,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T819555436"] = "Fehlgeschlagene E-
-- Skipped -- Skipped
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Übersprungen" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Übersprungen"
-- Open the transcription settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T905182399"] = "Transkriptionseinstellungen öffnen"
-- Indexed: {0} -- Indexed: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T933775466"] = "Indexiert: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T933775466"] = "Indexiert: {0}"
@@ -12330,6 +12360,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T677355172"] = "Un
-- Not a readable spreadsheet -- Not a readable spreadsheet
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1175970425"] = "Keine lesbare Tabellenkalkulation" UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1175970425"] = "Keine lesbare Tabellenkalkulation"
-- The media file '{0}' could not be processed, so it was not indexed. AI Studio reads it again as soon as the file changes.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1193172614"] = "Die Mediendatei „{0}“ konnte nicht verarbeitet und daher nicht indexiert werden. AI Studio liest sie erneut ein, sobald sich die Datei ändert."
-- Media file could not be processed
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1302965532"] = "Mediendatei konnte nicht verarbeitet werden"
-- Not a text file -- Not a text file
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1465212038"] = "Keine Textdatei" UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1465212038"] = "Keine Textdatei"
@@ -12564,6 +12600,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAIL::MAILBOXPROVIDERTEMPLATEEXTENSIONS::T4026
-- Edit Mailbox -- Edit Mailbox
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAILBOXEDITING::T3250445637"] = "Postfach bearbeiten" UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAILBOXEDITING::T3250445637"] = "Postfach bearbeiten"
-- The transcription provider returned an empty transcript.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T1080540822"] = "Der Transkriptionsanbieter hat ein leeres Transkript zurückgegeben."
-- No usable transcription provider is configured.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2282521655"] = "Es ist kein nutzbarer Transkriptionsanbieter konfiguriert."
-- Was not able to read the existing transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2397111152"] = "Das vorhandene Transkript konnte nicht gelesen werden: {0}"
-- The media file changed while it was being transcribed, so its transcript was not kept.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2455890169"] = "Die Mediendatei wurde während der Transkription geändert, daher wurde das Transkript verworfen."
-- The media file could not be read.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T3826117761"] = "Die Mediendatei konnte nicht gelesen werden."
-- Was not able to store the transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T920159834"] = "Das Transkript konnte nicht gespeichert werden: {0}"
-- AI Studio couldn't install Pandoc because the archive was not found. -- AI Studio couldn't install Pandoc because the archive was not found.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio konnte Pandoc nicht installieren, da das Archiv nicht gefunden wurde." UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio konnte Pandoc nicht installieren, da das Archiv nicht gefunden wurde."
@@ -13635,6 +13689,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T1225
-- Some mails could not be indexed. The list below says which ones and why. -- Some mails could not be indexed. The list below says which ones and why.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360744026"] = "Einige E-Mails konnten nicht indexiert werden. In der folgenden Liste steht, welche E-Mails betroffen sind und warum." UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360744026"] = "Einige E-Mails konnten nicht indexiert werden. In der folgenden Liste steht, welche E-Mails betroffen sind und warum."
-- The media file '{0}' could not be processed, so it was not indexed. Reason: {1} AI Studio reads it again as soon as the file changes.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2967004571"] = "Die Mediendatei „{0}“ konnte nicht verarbeitet und daher nicht indexiert werden. Grund: {1} AI Studio liest sie erneut ein, sobald sich die Datei ändert."
-- The mail '{0}' could not be indexed. AI Studio tries again during the next sync. -- The mail '{0}' could not be indexed. AI Studio tries again during the next sync.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "Die E-Mail „{0}“ konnte nicht indexiert werden. AI Studio versucht es bei der nächsten Synchronisierung erneut." UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "Die E-Mail „{0}“ konnte nicht indexiert werden. AI Studio versucht es bei der nächsten Synchronisierung erneut."
@@ -14407,7 +14464,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1013354736"
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1120586536"] = "Bitte wählen Sie ein erforderliches Vertrauensniveau des Anbieters aus." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1120586536"] = "Bitte wählen Sie ein erforderliches Vertrauensniveau des Anbieters aus."
-- The connection test failed. Please check the connection settings. -- The connection test failed. Please check the connection settings.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T132896331"] = "Der Verbindungstest ist fehlgeschlagen. Bitte überprüfe die Verbindungseinstellungen." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T132896331"] = "Der Verbindungstest ist fehlgeschlagen. Bitte überprüfen Sie die Verbindungseinstellungen."
-- Please select an embedding provider. -- Please select an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1461238528"] = "Bitte wählen Sie einen Anbieter für die Einbettung aus." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1461238528"] = "Bitte wählen Sie einen Anbieter für die Einbettung aus."
@@ -14416,7 +14473,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1461238528"
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1481612527"] = "Bitte wählen Sie aus, wie die Verbindung zum Server verschlüsselt wird." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1481612527"] = "Bitte wählen Sie aus, wie die Verbindung zum Server verschlüsselt wird."
-- The file does not exist. Please select a valid file. -- The file does not exist. Please select a valid file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T148464502"] = "Die Datei existiert nicht. Bitte wähle eine gültige Datei aus." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T148464502"] = "Die Datei existiert nicht. Bitte wählen Sie eine gültige Datei aus."
-- Please select one retrieval process. -- Please select one retrieval process.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1515898234"] = "Bitte wählen Sie einen Abrufprozess aus." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T1515898234"] = "Bitte wählen Sie einen Abrufprozess aus."
@@ -14445,8 +14502,8 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2222598306"
-- Please select your security policy. -- Please select your security policy.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Bitte wählen Sie Ihre Sicherheitsrichtlinie aus." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Bitte wählen Sie Ihre Sicherheitsrichtlinie aus."
-- The selected file is not a supported document or readable text file. -- Please acknowledge that you are aware of the cloud transcription implications.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2728355006"] = "Die ausgewählte Datei ist weder ein unterstütztes Dokument noch eine lesbare Textdatei." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2818048408"] = "Bitte bestätigen Sie, dass Ihnen die Auswirkungen der Cloud-Transkription bewusst sind."
-- Please test the connection before saving. -- Please test the connection before saving.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"] = "Bitte testen Sie die Verbindung, bevor Sie speichern." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"] = "Bitte testen Sie die Verbindung, bevor Sie speichern."
@@ -14493,6 +14550,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T490875633"]
-- The file path must not be empty. Please select a file. -- The file path must not be empty. Please select a file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T878007824"] = "Der Dateipfad darf nicht leer sein. Bitte wählen Sie eine Datei aus." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T878007824"] = "Der Dateipfad darf nicht leer sein. Bitte wählen Sie eine Datei aus."
-- The selected file is not a supported document, readable text file, audio file, or video file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T90753429"] = "Die ausgewählte Datei ist kein unterstütztes Dokument, keine lesbare Textdatei und keine Audio- oder Videodatei."
-- Please enter the secret necessary for authentication. -- Please enter the secret necessary for authentication.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T968385876"] = "Bitte geben Sie das für die Authentifizierung benötigte Geheimnis ein." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T968385876"] = "Bitte geben Sie das für die Authentifizierung benötigte Geheimnis ein."
@@ -345,9 +345,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T4242312602"] = "Send to .
-- Copy result -- Copy result
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T83711157"] = "Copy result" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T83711157"] = "Copy result"
-- The transcription provider returned an empty transcript.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1080540822"] = "The transcription provider returned an empty transcript."
-- Hide Expert Settings -- Hide Expert Settings
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1108876344"] = "Hide Expert Settings" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1108876344"] = "Hide Expert Settings"
@@ -462,9 +459,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Tools for this batch run -- Tools for this batch run
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2247412388"] = "Tools for this batch run" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2247412388"] = "Tools for this batch run"
-- No usable transcription provider is configured.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2282521655"] = "No usable transcription provider is configured."
-- Was not able to create the output folder: {0} -- Was not able to create the output folder: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Was not able to create the output folder: {0}" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Was not able to create the output folder: {0}"
@@ -582,9 +576,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- {0} of {1} files processed -- {0} of {1} files processed
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3648144402"] = "{0} of {1} files processed" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3648144402"] = "{0} of {1} files processed"
-- Was not able to store the transcript next to the media file: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3691287653"] = "Was not able to store the transcript next to the media file: {0}"
-- Time -- Time
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3756319748"] = "Time" UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3756319748"] = "Time"
@@ -3960,6 +3951,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2373663376"] = "Good to kno
-- Data folder -- Data folder
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Data folder" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Data folder"
-- The transcripts of the audio and video files in your local data sources.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T260303721"] = "The transcripts of the audio and video files in your local data sources."
-- Explanation -- Explanation
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Explanation" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Explanation"
@@ -4038,9 +4032,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107
-- No, I will choose another embedding -- No, I will choose another embedding
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1246976418"] = "No, I will choose another embedding" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1246976418"] = "No, I will choose another embedding"
-- No, I will choose other providers or files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1811383874"] = "No, I will choose other providers or files"
-- Warning: Your transcription provider '{0}' is not self-hosted. Audio and video files this data source selects are sent to it to be transcribed, which can cost money. AI Studio keeps every transcript, so an unchanged recording is sent only once. MindWork AI Studio has no control over what that third party does with the recordings after they are sent.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T200667320"] = "Warning: Your transcription provider '{0}' is not self-hosted. Audio and video files this data source selects are sent to it to be transcribed, which can cost money. AI Studio keeps every transcript, so an unchanged recording is sent only once. MindWork AI Studio has no control over what that third party does with the recordings after they are sent."
-- Every mail of this mailbox in the selected folder and period, together with the text of its attachments, -- Every mail of this mailbox in the selected folder and period, together with the text of its attachments,
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2046044636"] = "Every mail of this mailbox in the selected folder and period, together with the text of its attachments," UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2046044636"] = "Every mail of this mailbox in the selected folder and period, together with the text of its attachments,"
-- Yes, please send my data to the external providers
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2239851837"] = "Yes, please send my data to the external providers"
-- The data source '{0}' -- The data source '{0}'
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2503488371"] = "The data source '{0}'" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T2503488371"] = "The data source '{0}'"
@@ -6777,6 +6780,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T286958948"]
-- Token limit -- Token limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"] = "Token limit" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"] = "Token limit"
-- Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3017942214"] = "Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3119708878"] = "Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings."
-- Please enter 0 or a positive overlap length. -- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length."
@@ -6801,12 +6810,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T529871858"]
-- Select the base directory -- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T599406584"] = "Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider."
-- Yes, index hidden files and folders as well -- Yes, index hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Yes, index hidden files and folders as well" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Yes, index hidden files and folders as well"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T761052435"] = "Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file."
-- The data source token limit must not be larger than the embedding provider token limit ({0}). -- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})."
@@ -6942,9 +6951,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1108876344"] = "
-- Optional expert settings for how this data source is split before embedding. -- Optional expert settings for how this data source is split before embedding.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "Optional expert settings for how this data source is split before embedding." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "Optional expert settings for how this data source is split before embedding."
-- Select a file for this data source. The content of this file will be processed for the data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1190880267"] = "Select a file for this data source. The content of this file will be processed for the data source."
-- Default tokenizer -- Default tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Default tokenizer" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Default tokenizer"
@@ -6978,6 +6984,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T236253137"] = "R
-- Please enter a token limit of at least 1. -- Please enter a token limit of at least 1.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "Please enter a token limit of at least 1." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "Please enter a token limit of at least 1."
-- This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2468760910"] = "This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Add -- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Add" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Add"
@@ -7005,6 +7014,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3361153305"] = "
-- Select the file -- Select the file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Select the file" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Select the file"
-- This audio or video file is skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T4026150982"] = "This audio or video file is skipped until you choose a transcription provider in the settings."
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider. -- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider."
@@ -7023,6 +7035,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T900713019"] = "C
-- Selected file path for this data source -- Selected file path for this data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T939749563"] = "Selected file path for this data source" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T939749563"] = "Selected file path for this data source"
-- Select a file for this data source. The content of this file will be processed for the data source. An audio or video file is transcribed by your transcription provider first.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T966073786"] = "Select a file for this data source. The content of this file will be processed for the data source. An audio or video file is transcribed by your transcription provider first."
-- Token overlap -- Token overlap
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T981382809"] = "Token overlap" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T981382809"] = "Token overlap"
@@ -10155,15 +10170,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1143368054"] = "Other cause"
-- Current file: {0} -- Current file: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Current file: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Current file: {0}"
-- Transcription provider not allowed
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1283933457"] = "Transcription provider not allowed"
-- Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now. -- Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1297391911"] = "Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1297391911"] = "Mailboxes are synced every 16 minutes while the automatic refresh is on. Each mail is embedded only once: when it moves to another folder, AI Studio merely notes where it lies now."
-- File {0} of {1} is being indexed: block {2}, page {3}. -- File {0} of {1} is being indexed: block {2}, page {3}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "File {0} of {1} is being indexed: block {2}, page {3}." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "File {0} of {1} is being indexed: block {2}, page {3}."
-- These image, audio, or video files match the data source patterns, but they cannot currently be added to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1302021733"] = "These image, audio, or video files match the data source patterns, but they cannot currently be added to the index."
-- Mail -- Mail
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1399880782"] = "Mail" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1399880782"] = "Mail"
@@ -10194,6 +10209,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1616414701"] = "File {0} of {1} i
-- Skipped: {0} -- Skipped: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1775451753"] = "Skipped: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1775451753"] = "Skipped: {0}"
-- These audio or video files match the data source patterns, but your transcription provider does not meet the confidence level this data source requires. Choose another transcription provider, or change the confidence level of the data source.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1811387825"] = "These audio or video files match the data source patterns, but your transcription provider does not meet the confidence level this data source requires. Choose another transcription provider, or change the confidence level of the data source."
-- Tried again during the next run -- Tried again during the next run
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Tried again during the next run" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Tried again during the next run"
@@ -10266,6 +10284,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3628102488"] = "{0} files ({1})"
-- No local data source has been queued for embedding yet. -- No local data source has been queued for embedding yet.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3774205531"] = "No local data source has been queued for embedding yet." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3774205531"] = "No local data source has been queued for embedding yet."
-- These audio or video files match the data source patterns, but no transcription provider is configured that AI Studio may use. Once you choose one in the settings, AI Studio transcribes the files and adds their text to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3775894071"] = "These audio or video files match the data source patterns, but no transcription provider is configured that AI Studio may use. Once you choose one in the settings, AI Studio transcribes the files and adds their text to the index."
-- Change the settings of the mailbox -- Change the settings of the mailbox
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3788376715"] = "Change the settings of the mailbox" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3788376715"] = "Change the settings of the mailbox"
@@ -10284,9 +10305,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T419343388"] = "Remove them from t
-- Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing. -- Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4205079539"] = "Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4205079539"] = "Not synced completely yet. AI Studio works through the mailbox piece by piece, the newest mails first, and older mails are still missing."
-- These image files match the data source patterns, but images cannot currently be added to the index.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T422032787"] = "These image files match the data source patterns, but images cannot currently be added to the index."
-- Current mail: {0} -- Current mail: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4242653147"] = "Current mail: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4242653147"] = "Current mail: {0}"
-- No transcription provider
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4253595107"] = "No transcription provider"
-- Skipped files: {0}. Open a group below to see why and what you can do. -- Skipped files: {0}. Open a group below to see why and what you can do.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4287145754"] = "Skipped files: {0}. Open a group below to see why and what you can do." UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T4287145754"] = "Skipped files: {0}. Open a group below to see why and what you can do."
@@ -10305,6 +10332,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T819555436"] = "Failed mails: {0}"
-- Skipped -- Skipped
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Skipped" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Skipped"
-- Open the transcription settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T905182399"] = "Open the transcription settings"
-- Indexed: {0} -- Indexed: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T933775466"] = "Indexed: {0}" UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T933775466"] = "Indexed: {0}"
@@ -12330,6 +12360,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXPORTFORMATEXTENSIONS::T677355172"] = "Un
-- Not a readable spreadsheet -- Not a readable spreadsheet
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1175970425"] = "Not a readable spreadsheet" UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1175970425"] = "Not a readable spreadsheet"
-- The media file '{0}' could not be processed, so it was not indexed. AI Studio reads it again as soon as the file changes.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1193172614"] = "The media file '{0}' could not be processed, so it was not indexed. AI Studio reads it again as soon as the file changes."
-- Media file could not be processed
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1302965532"] = "Media file could not be processed"
-- Not a text file -- Not a text file
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1465212038"] = "Not a text file" UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONERRORCODEEXTENSIONS::T1465212038"] = "Not a text file"
@@ -12564,6 +12600,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAIL::MAILBOXPROVIDERTEMPLATEEXTENSIONS::T4026
-- Edit Mailbox -- Edit Mailbox
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAILBOXEDITING::T3250445637"] = "Edit Mailbox" UI_TEXT_CONTENT["AISTUDIO::TOOLS::MAILBOXEDITING::T3250445637"] = "Edit Mailbox"
-- The transcription provider returned an empty transcript.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T1080540822"] = "The transcription provider returned an empty transcript."
-- No usable transcription provider is configured.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2282521655"] = "No usable transcription provider is configured."
-- Was not able to read the existing transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2397111152"] = "Was not able to read the existing transcript: {0}"
-- The media file changed while it was being transcribed, so its transcript was not kept.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T2455890169"] = "The media file changed while it was being transcribed, so its transcript was not kept."
-- The media file could not be read.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T3826117761"] = "The media file could not be read."
-- Was not able to store the transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::MEDIA::PERSISTENTMEDIATRANSCRIPT::T920159834"] = "Was not able to store the transcript: {0}"
-- AI Studio couldn't install Pandoc because the archive was not found. -- AI Studio couldn't install Pandoc because the archive was not found.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found." UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found."
@@ -13635,6 +13689,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T1225
-- Some mails could not be indexed. The list below says which ones and why. -- Some mails could not be indexed. The list below says which ones and why.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360744026"] = "Some mails could not be indexed. The list below says which ones and why." UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360744026"] = "Some mails could not be indexed. The list below says which ones and why."
-- The media file '{0}' could not be processed, so it was not indexed. Reason: {1} AI Studio reads it again as soon as the file changes.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2967004571"] = "The media file '{0}' could not be processed, so it was not indexed. Reason: {1} AI Studio reads it again as soon as the file changes."
-- The mail '{0}' could not be indexed. AI Studio tries again during the next sync. -- The mail '{0}' could not be indexed. AI Studio tries again during the next sync.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "The mail '{0}' could not be indexed. AI Studio tries again during the next sync." UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "The mail '{0}' could not be indexed. AI Studio tries again during the next sync."
@@ -14445,8 +14502,8 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2222598306"
-- Please select your security policy. -- Please select your security policy.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Please select your security policy." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Please select your security policy."
-- The selected file is not a supported document or readable text file. -- Please acknowledge that you are aware of the cloud transcription implications.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2728355006"] = "The selected file is not a supported document or readable text file." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2818048408"] = "Please acknowledge that you are aware of the cloud transcription implications."
-- Please test the connection before saving. -- Please test the connection before saving.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"] = "Please test the connection before saving." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"] = "Please test the connection before saving."
@@ -14493,6 +14550,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T490875633"]
-- The file path must not be empty. Please select a file. -- The file path must not be empty. Please select a file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T878007824"] = "The file path must not be empty. Please select a file." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T878007824"] = "The file path must not be empty. Please select a file."
-- The selected file is not a supported document, readable text file, audio file, or video file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T90753429"] = "The selected file is not a supported document, readable text file, audio file, or video file."
-- Please enter the secret necessary for authentication. -- Please enter the secret necessary for authentication.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T968385876"] = "Please enter the secret necessary for authentication." UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T968385876"] = "Please enter the secret necessary for authentication."
@@ -635,6 +635,30 @@ public abstract class BaseProvider : IProvider, ISecretId
} }
} }
/// <summary>
/// Reads which parameter of the request a provider blamed in the body of a failed response.
/// </summary>
/// <remarks>
/// OpenAI and the APIs compatible with it name it in the field 'param' of their error object.
/// </remarks>
/// <param name="responseBody">The body of the failed response.</param>
/// <returns>The name of the parameter, or null when the body names none.</returns>
private static string? ReadProviderErrorParameter(string responseBody)
{
if (string.IsNullOrWhiteSpace(responseBody))
return null;
try
{
using var document = JsonDocument.Parse(responseBody);
return TryGetErrorElement(document.RootElement, out var errorElement) ? TryGetString(errorElement, "param") : null;
}
catch (JsonException)
{
return null;
}
}
private static bool TryGetErrorElement(JsonElement root, out JsonElement errorElement) private static bool TryGetErrorElement(JsonElement root, out JsonElement errorElement)
{ {
if (root.ValueKind is JsonValueKind.Object && if (root.ValueKind is JsonValueKind.Object &&
@@ -1473,7 +1497,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if (!string.IsNullOrWhiteSpace(classifiedMessage)) if (!string.IsNullOrWhiteSpace(classifiedMessage))
return classifiedMessage; return classifiedMessage;
if (statusCode is HttpStatusCode.BadRequest && responseBody.Contains("not decode", StringComparison.OrdinalIgnoreCase)) if (IsUndecodableFormatFailure(statusCode, responseBody))
return string.Format(TB("The provider '{0}' was not able to read the audio file. It probably does not support the WebM/Opus format which AI Studio sends. Please contact the provider about it."), this.InstanceName); return string.Format(TB("The provider '{0}' was not able to read the audio file. It probably does not support the WebM/Opus format which AI Studio sends. Please contact the provider about it."), this.InstanceName);
var providerMessage = ReadProviderErrorMessage(responseBody); var providerMessage = ReadProviderErrorMessage(responseBody);
@@ -1483,6 +1507,33 @@ public abstract class BaseProvider : IProvider, ISecretId
return string.Empty; return string.Empty;
} }
private static bool IsUndecodableFormatFailure(HttpStatusCode statusCode, string responseBody) => statusCode is HttpStatusCode.BadRequest && responseBody.Contains("not decode", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a transcription provider refused the media file itself rather than the request.
/// </summary>
/// <remarks>
/// The provider gives the same answer for the same file every time, so sending it again only
/// repeats the upload. That holds for a file which is too large or of a type the provider does
/// not accept, and for a bad request which names the file as the faulty parameter, as OpenAI and
/// the APIs compatible with it do, e.g., for a damaged recording. A provider which cannot decode
/// WebM/Opus at all is the exception: it refuses every file the same way, and the user fixes
/// that by choosing another provider, not by changing the file. Everything else, such as rate
/// limits, keys, models, and outages, may pass.
/// </remarks>
/// <param name="statusCode">The status code the provider answered with.</param>
/// <param name="responseBody">The body the provider answered with.</param>
/// <returns>True, when the provider would refuse the unchanged file again.</returns>
internal static bool IsMediaFileRejection(HttpStatusCode statusCode, string responseBody)
{
if (statusCode is HttpStatusCode.RequestEntityTooLarge or HttpStatusCode.UnsupportedMediaType)
return true;
return statusCode is HttpStatusCode.BadRequest
&& !IsUndecodableFormatFailure(statusCode, responseBody)
&& string.Equals(ReadProviderErrorParameter(responseBody), "file", StringComparison.OrdinalIgnoreCase);
}
protected async Task<TranscriptionResult> PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default) protected async Task<TranscriptionResult> PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default)
{ {
try try
@@ -1561,7 +1612,10 @@ public abstract class BaseProvider : IProvider, ISecretId
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
this.logger.LogError("Transcription request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); this.logger.LogError("Transcription request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody);
return TranscriptionResult.Failure(this.GetTranscriptionFailureUserMessage(response.StatusCode, responseBody)); var failureMessage = this.GetTranscriptionFailureUserMessage(response.StatusCode, responseBody);
return IsMediaFileRejection(response.StatusCode, responseBody)
? TranscriptionResult.Rejection(failureMessage)
: TranscriptionResult.Failure(failureMessage);
} }
var transcriptionResponse = JsonSerializer.Deserialize<TranscriptionResponse>(responseBody, JSON_SERIALIZER_OPTIONS); var transcriptionResponse = JsonSerializer.Deserialize<TranscriptionResponse>(responseBody, JSON_SERIALIZER_OPTIONS);
@@ -1582,7 +1636,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if (this.IsTimeoutException(e, token)) if (this.IsTimeoutException(e, token))
await this.SendTimeoutError("transcribing audio"); await this.SendTimeoutError("transcribing audio");
this.logger.LogError("Failed to perform transcription request: '{Message}'.", e.Message); this.logger.LogError(e, "Failed to perform transcription request: '{Message}'.", e.Message);
return TranscriptionResult.Failure(); return TranscriptionResult.Failure();
} }
} }
@@ -1,8 +1,17 @@
namespace AIStudio.Provider; namespace AIStudio.Provider;
public sealed record TranscriptionResult(bool Success, string Text, string ErrorMessage = "") /// <summary>
/// What a transcription provider answered.
/// </summary>
/// <param name="Success">Whether the provider returned a transcript.</param>
/// <param name="Text">The transcript.</param>
/// <param name="ErrorMessage">Why the transcription failed, for the user, when it is known.</param>
/// <param name="FileRejected">Whether the provider refused the media file itself, so that it would refuse the same file again.</param>
public sealed record TranscriptionResult(bool Success, string Text, string ErrorMessage = "", bool FileRejected = false)
{ {
public static TranscriptionResult FromText(string text) => new(true, text); public static TranscriptionResult FromText(string text) => new(true, text);
public static TranscriptionResult Failure(string errorMessage = "") => new(false, string.Empty, errorMessage); public static TranscriptionResult Failure(string errorMessage = "") => new(false, string.Empty, errorMessage);
public static TranscriptionResult Rejection(string errorMessage) => new(false, string.Empty, errorMessage, true);
} }
@@ -53,6 +53,14 @@ public static class DataSourceSecurityTrustExtensions
return provider.UsedLLMProvider.GetConfidence(settingsManager).Level; return provider.UsedLLMProvider.GetConfidence(settingsManager).Level;
} }
public static ConfidenceLevel GetConfidenceLevel(this TranscriptionProvider provider, SettingsManager settingsManager)
{
if (provider == TranscriptionProvider.NONE)
return ConfidenceLevel.NONE;
return provider.UsedLLMProvider.GetConfidence(settingsManager).Level;
}
public static ConfidenceLevel GetConfidenceLevel(this IProvider provider, SettingsManager settingsManager) public static ConfidenceLevel GetConfidenceLevel(this IProvider provider, SettingsManager settingsManager)
{ {
if (provider is NoProvider) if (provider is NoProvider)
@@ -46,6 +46,7 @@ public static class DataBackupContents
new(DataBackupLocation.DATA_DIRECTORY, "visualBriefings/", DataBackupEntryKind.VISUAL_BRIEFINGS), new(DataBackupLocation.DATA_DIRECTORY, "visualBriefings/", DataBackupEntryKind.VISUAL_BRIEFINGS),
new(DataBackupLocation.DATA_DIRECTORY, "databases/sqlite/", DataBackupEntryKind.DATA_SOURCE_INDEX), new(DataBackupLocation.DATA_DIRECTORY, "databases/sqlite/", DataBackupEntryKind.DATA_SOURCE_INDEX),
new(DataBackupLocation.DATA_DIRECTORY, "databases/vector_database/stores/", DataBackupEntryKind.VECTOR_STORES), new(DataBackupLocation.DATA_DIRECTORY, "databases/vector_database/stores/", DataBackupEntryKind.VECTOR_STORES),
new(DataBackupLocation.DATA_DIRECTORY, "mediaTranscripts/", DataBackupEntryKind.MEDIA_TRANSCRIPTS),
new(DataBackupLocation.CONFIG_DIRECTORY, "settings.v*.json", DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS), new(DataBackupLocation.CONFIG_DIRECTORY, "settings.v*.json", DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS),
new(DataBackupLocation.DATA_DIRECTORY, "plugins/.internal/", DataBackupEntryKind.BUILT_IN_PLUGINS), new(DataBackupLocation.DATA_DIRECTORY, "plugins/.internal/", DataBackupEntryKind.BUILT_IN_PLUGINS),
@@ -18,7 +18,20 @@ public sealed record DataBackupEntry(DataBackupLocation Location, string Relativ
or DataBackupEntryKind.TOKENIZERS or DataBackupEntryKind.TOKENIZERS
or DataBackupEntryKind.VISUAL_BRIEFINGS or DataBackupEntryKind.VISUAL_BRIEFINGS
or DataBackupEntryKind.DATA_SOURCE_INDEX or DataBackupEntryKind.DATA_SOURCE_INDEX
or DataBackupEntryKind.VECTOR_STORES; or DataBackupEntryKind.VECTOR_STORES
or DataBackupEntryKind.MEDIA_TRANSCRIPTS;
/// <summary>
/// Whether a backup takes the entry from the copies it makes while the indexing pauses, rather than from the data folder.
/// </summary>
/// <remarks>
/// The indexing writes to these entries, and they refer to each other: the index knows which
/// transcript belongs to which file. A backup therefore copies them together while nothing
/// writes, cf. DataBackupService.CopyIndexesAsync, and a restore replaces them only together.
/// </remarks>
public bool ComesFromIndexCopies => this.Kind is DataBackupEntryKind.DATA_SOURCE_INDEX
or DataBackupEntryKind.VECTOR_STORES
or DataBackupEntryKind.MEDIA_TRANSCRIPTS;
/// <summary> /// <summary>
/// Whether the entry is a folder rather than a single file. /// Whether the entry is a folder rather than a single file.
@@ -45,6 +45,11 @@ public enum DataBackupEntryKind
/// </summary> /// </summary>
VECTOR_STORES, VECTOR_STORES,
/// <summary>
/// The transcripts of the audio and video files of the data sources. Making them again costs money.
/// </summary>
MEDIA_TRANSCRIPTS,
/// <summary> /// <summary>
/// The copies of the settings which every version of AI Studio keeps for itself. /// The copies of the settings which every version of AI Studio keeps for itself.
/// </summary> /// </summary>
@@ -9,7 +9,7 @@ namespace AIStudio.Tools.DataBackup;
/// <param name="CreatedAtUtc">When the backup was written.</param> /// <param name="CreatedAtUtc">When the backup was written.</param>
/// <param name="SourceDataDirectory">The data folder the backup was made from, for whoever wonders where it came from.</param> /// <param name="SourceDataDirectory">The data folder the backup was made from, for whoever wonders where it came from.</param>
/// <param name="SourceConfigDirectory">The configuration folder the backup was made from.</param> /// <param name="SourceConfigDirectory">The configuration folder the backup was made from.</param>
/// <param name="IncludesDataSourceIndexes">Whether the backup holds the indexes of the data sources. Without them, a restore keeps the indexes there are.</param> /// <param name="IncludesDataSourceIndexes">Whether the backup holds the indexes of the data sources and the transcripts of their media files. Without them, a restore keeps the indexes and transcripts there are.</param>
/// <param name="Entries">Every file of the backup, the manifest aside.</param> /// <param name="Entries">Every file of the backup, the manifest aside.</param>
public sealed record DataBackupManifest( public sealed record DataBackupManifest(
int FormatVersion, int FormatVersion,
@@ -101,7 +101,7 @@ public static class DataBackupWriter
var files = new List<BackupFile>(); var files = new List<BackupFile>();
foreach (var entry in DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded && entry.Location is DataBackupLocation.DATA_DIRECTORY)) foreach (var entry in DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded && entry.Location is DataBackupLocation.DATA_DIRECTORY))
{ {
var root = entry.Kind is DataBackupEntryKind.DATA_SOURCE_INDEX or DataBackupEntryKind.VECTOR_STORES var root = entry.ComesFromIndexCopies
? sources.IndexCopiesDirectory ? sources.IndexCopiesDirectory
: sources.DataDirectory; : sources.DataDirectory;
@@ -147,15 +147,16 @@ public static class DataRestorePreparer
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// An element the backup holds nothing for stays as it is, since nothing tells whether it was empty /// An element the backup holds nothing for stays as it is, since nothing tells whether it was empty
/// or whether the version of AI Studio which made the backup knew it at all. The indexes are /// or whether the version of AI Studio which made the backup knew it at all. The indexes and the
/// replaced only when the backup holds both of them, cf. DataBackupManifest.IncludesDataSourceIndexes. /// media transcripts are replaced only when the backup holds the indexes, cf.
/// DataBackupManifest.IncludesDataSourceIndexes and DataBackupEntry.ComesFromIndexCopies.
/// </remarks> /// </remarks>
private static List<DataRestoreElement> DetermineElements(DataBackupManifest manifest, DataRestoreTarget target) private static List<DataRestoreElement> DetermineElements(DataBackupManifest manifest, DataRestoreTarget target)
{ {
var elements = new List<DataRestoreElement>(); var elements = new List<DataRestoreElement>();
foreach (var entry in DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded)) foreach (var entry in DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded))
{ {
if (entry.Kind is DataBackupEntryKind.DATA_SOURCE_INDEX or DataBackupEntryKind.VECTOR_STORES && !manifest.IncludesDataSourceIndexes) if (entry.ComesFromIndexCopies && !manifest.IncludesDataSourceIndexes)
continue; continue;
var entryName = DataBackupFormat.EntryName(entry.Location, entry.RelativePath); var entryName = DataBackupFormat.EntryName(entry.Location, entry.RelativePath);
@@ -11,4 +11,5 @@ public sealed record EmbeddingStateFile(
DateTimeOffset CreationUtc, DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc, DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc, DateTimeOffset EmbeddedAtUtc,
int ChunkCount); int ChunkCount,
string? TranscriptKey = null);
@@ -26,6 +26,8 @@ internal sealed class EmbeddingStateFileEntity
public int ChunkCount { get; set; } public int ChunkCount { get; set; }
public string? TranscriptKey { get; set; }
public EmbeddingStateDataSourceEntity? DataSource { get; set; } public EmbeddingStateDataSourceEntity? DataSource { get; set; }
public List<EmbeddingStateChunkEntity> Chunks { get; set; } = []; public List<EmbeddingStateChunkEntity> Chunks { get; set; } = [];
@@ -80,6 +80,7 @@ internal sealed class IndexStoreDbContext(DbContextOptions<IndexStoreDbContext>
entity.Property(file => file.LastWriteUtc).HasColumnName("last_write_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired(); entity.Property(file => file.LastWriteUtc).HasColumnName("last_write_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.EmbeddedAtUtc).HasColumnName("embedded_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired(); entity.Property(file => file.EmbeddedAtUtc).HasColumnName("embedded_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.ChunkCount).HasColumnName("chunk_count"); entity.Property(file => file.ChunkCount).HasColumnName("chunk_count");
entity.Property(file => file.TranscriptKey).HasColumnName("transcript_key");
entity.HasIndex(file => file.DataSourceId).HasDatabaseName("idx_embedded_files_data_source"); entity.HasIndex(file => file.DataSourceId).HasDatabaseName("idx_embedded_files_data_source");
entity.HasIndex(file => file.AbsolutePath).HasDatabaseName("idx_embedded_files_absolute_path"); entity.HasIndex(file => file.AbsolutePath).HasDatabaseName("idx_embedded_files_absolute_path");
@@ -11,6 +11,7 @@ internal static class IndexStoreSchemaMigrator
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropFileConfidenceLevel))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropFileConfidenceLevel))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropDataSourceName))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropDataSourceName))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.MailboxIndex))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.MailboxIndex))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.MediaTranscriptKey))]
public static async Task MigrateAsync(IndexStoreDbContext context, CancellationToken token) public static async Task MigrateAsync(IndexStoreDbContext context, CancellationToken token)
{ {
await context.Database.MigrateAsync(token); await context.Database.MigrateAsync(token);
@@ -0,0 +1,35 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
/// <summary>
/// Adds the key of the transcript to the indexed files, for audio and video files which a data source transcribed.
/// </summary>
/// <remarks>
/// The transcripts lie below the data directory under the hash of the content of their media file.
/// The index has to know which transcript belongs to which file, so that AI Studio can remove a
/// transcript once its file is gone or changed. Every other file keeps NULL.
/// </remarks>
[DbContext(typeof(IndexStoreDbContext))]
[Migration("20261018000000_MediaTranscriptKey")]
public partial class MediaTranscriptKey : Migration
{
/// <remarks>
/// Both directions go through raw SQL, like DropDataSourceName: the SQLite provider answers
/// DropColumn by rebuilding the table, and dropping the old embedded_files table would let the
/// cascade of the foreign key in embedding_chunks take every chunk with it. Adding a nullable
/// column is native in SQLite and leaves the rows alone.
/// </remarks>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("ALTER TABLE embedded_files ADD COLUMN transcript_key TEXT NULL;");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("ALTER TABLE embedded_files DROP COLUMN transcript_key;");
}
}
@@ -119,6 +119,10 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("relative_path"); .HasColumnName("relative_path");
entity.Property<string>("TranscriptKey")
.HasColumnType("TEXT")
.HasColumnName("transcript_key");
entity.HasKey("ParentFileId"); entity.HasKey("ParentFileId");
entity.HasIndex("AbsolutePath") entity.HasIndex("AbsolutePath")
@@ -148,7 +148,8 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
file.FileSize, file.FileSize,
file.LastWriteUtc, file.LastWriteUtc,
file.EmbeddedAtUtc, file.EmbeddedAtUtc,
file.ChunkCount); file.ChunkCount,
file.TranscriptKey);
} }
var permanentFailures = await context.PermanentIndexingFailures var permanentFailures = await context.PermanentIndexingFailures
@@ -690,6 +691,7 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
fileEntity.LastWriteUtc = file.LastWriteUtc; fileEntity.LastWriteUtc = file.LastWriteUtc;
fileEntity.EmbeddedAtUtc = file.EmbeddedAtUtc; fileEntity.EmbeddedAtUtc = file.EmbeddedAtUtc;
fileEntity.ChunkCount = file.ChunkCount; fileEntity.ChunkCount = file.ChunkCount;
fileEntity.TranscriptKey = file.TranscriptKey;
} }
private static void ApplyPermanentFailure(IndexingFailureEntity failureEntity, string dataSourceId, PermanentIndexingFailure failure) private static void ApplyPermanentFailure(IndexingFailureEntity failureEntity, string dataSourceId, PermanentIndexingFailure failure)
@@ -0,0 +1,28 @@
using System.Security.Cryptography;
namespace AIStudio.Tools;
/// <summary>
/// Computes the hash of the content of a file.
/// </summary>
internal static class FileContentHash
{
/// <summary>
/// Computes a lowercase SHA-256 hash for a file without loading it fully into memory.
/// </summary>
/// <param name="path">The file path.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The lowercase hexadecimal hash.</returns>
internal static async Task<string> ComputeAsync(string path, CancellationToken token)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
65_536,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, token));
}
}
@@ -91,6 +91,12 @@ public enum FileExtractionErrorCode
/// </summary> /// </summary>
NO_CONTENT, NO_CONTENT,
/// <summary>
/// A selected audio or video file could not be prepared for transcription for a reason which
/// lies in the file itself, such as a damaged container or an unsupported audio codec.
/// </summary>
MEDIA_PROCESSING_FAILED,
/// <summary> /// <summary>
/// The caller no longer needs the content, e.g. because the user closed the dialog which /// The caller no longer needs the content, e.g. because the user closed the dialog which
/// asked for it. This is not a failure: nobody has to be told about it, which is why there /// asked for it. This is not a failure: nobody has to be told about it, which is why there
@@ -37,6 +37,7 @@ internal static class FileExtractionErrorCodeExtensions
FileExtractionErrorCode.FORMAT_DETECTION_FAILED => true, FileExtractionErrorCode.FORMAT_DETECTION_FAILED => true,
FileExtractionErrorCode.EXECUTABLE_REJECTED => true, FileExtractionErrorCode.EXECUTABLE_REJECTED => true,
FileExtractionErrorCode.UNSUPPORTED => true, FileExtractionErrorCode.UNSUPPORTED => true,
FileExtractionErrorCode.MEDIA_PROCESSING_FAILED => true,
// Pages holding nothing but images are one of the recurring cases here, and that is a // Pages holding nothing but images are one of the recurring cases here, and that is a
// property of the document, not of the environment: // property of the document, not of the environment:
@@ -71,6 +72,7 @@ internal static class FileExtractionErrorCodeExtensions
FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("Unknown file type"), FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("Unknown file type"),
FileExtractionErrorCode.EXECUTABLE_REJECTED => TB("Executable program"), FileExtractionErrorCode.EXECUTABLE_REJECTED => TB("Executable program"),
FileExtractionErrorCode.UNSUPPORTED => TB("Unsupported file type"), FileExtractionErrorCode.UNSUPPORTED => TB("Unsupported file type"),
FileExtractionErrorCode.MEDIA_PROCESSING_FAILED => TB("Media file could not be processed"),
FileExtractionErrorCode.PAGE_EXTRACTION_FAILED => TB("Pages without readable text"), FileExtractionErrorCode.PAGE_EXTRACTION_FAILED => TB("Pages without readable text"),
FileExtractionErrorCode.FILE_NOT_FOUND => TB("File does not exist anymore"), FileExtractionErrorCode.FILE_NOT_FOUND => TB("File does not exist anymore"),
@@ -122,6 +124,7 @@ internal static class FileExtractionErrorCodeExtensions
FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("The file type of '{0}' could not be determined, so the file was not indexed. AI Studio reads it again as soon as the file changes."), FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("The file type of '{0}' could not be determined, so the file was not indexed. AI Studio reads it again as soon as the file changes."),
FileExtractionErrorCode.EXECUTABLE_REJECTED => TB("The file '{0}' is an executable program and was not indexed, regardless of its file extension."), FileExtractionErrorCode.EXECUTABLE_REJECTED => TB("The file '{0}' is an executable program and was not indexed, regardless of its file extension."),
FileExtractionErrorCode.UNSUPPORTED => TB("The file type of '{0}' is not supported, so the file was not indexed. AI Studio reads it again as soon as the file changes."), FileExtractionErrorCode.UNSUPPORTED => TB("The file type of '{0}' is not supported, so the file was not indexed. AI Studio reads it again as soon as the file changes."),
FileExtractionErrorCode.MEDIA_PROCESSING_FAILED => TB("The media file '{0}' could not be processed, so it was not indexed. AI Studio reads it again as soon as the file changes."),
FileExtractionErrorCode.PAGE_EXTRACTION_FAILED => TB("Pages of the file '{0}' could not be read, so it was not indexed. They might contain images only. AI Studio reads it again as soon as the file changes."), FileExtractionErrorCode.PAGE_EXTRACTION_FAILED => TB("Pages of the file '{0}' could not be read, so it was not indexed. They might contain images only. AI Studio reads it again as soon as the file changes."),
// //
@@ -80,6 +80,33 @@ internal static class FileNamePatterns
&& FileSystemName.MatchesSimpleExpression(pattern, fileName, ignoreCase: true)); && FileSystemName.MatchesSimpleExpression(pattern, fileName, ignoreCase: true));
} }
/// <summary>
/// Whether the patterns may select audio or video files, which a data source sends to the transcription provider.
/// </summary>
/// <remarks>
/// Deliberately cautious, since it answers without looking at a single file: a pattern which
/// may match any extension, such as * or meeting*, counts. The selectors never select media.
/// </remarks>
/// <param name="patterns">The parsed patterns.</param>
/// <returns>True when audio or video files may match.</returns>
public static bool MaySelectMedia(IReadOnlyList<string> patterns) => patterns.Any(MaySelectMedia);
private static bool MaySelectMedia(string pattern)
{
if (pattern.StartsWith(SELECTOR_DELIMITER) && pattern.EndsWith(SELECTOR_DELIMITER))
return false;
var extensionStart = pattern.LastIndexOf('.');
if (extensionStart < 0)
return pattern.IndexOfAny(['*', '?']) >= 0;
var extension = pattern[(extensionStart + 1)..];
if (extension.IndexOfAny(['*', '?']) >= 0)
return true;
return FileTypes.IsAllowedPath($"media.{extension}", FileTypes.AUDIO, FileTypes.VIDEO);
}
public static bool AreEquivalent(IReadOnlyCollection<string>? left, IReadOnlyCollection<string>? right) public static bool AreEquivalent(IReadOnlyCollection<string>? left, IReadOnlyCollection<string>? right)
{ {
if (left is null || right is null) if (left is null || right is null)
@@ -0,0 +1,209 @@
using System.Text.RegularExpressions;
using AIStudio.Settings;
namespace AIStudio.Tools.Media;
/// <summary>
/// Keeps the transcripts of the audio and video files of one data source below the data directory.
/// </summary>
/// <remarks>
/// A data source never writes into the folders of the user: they may be read-only, a network share,
/// or the read-only home folder of the Flatpak, and a transcript which cannot be stored would be paid
/// for again with every run. The transcripts live in 'mediaTranscripts/&lt;data source id&gt;/&lt;key&gt;.md'
/// instead, where the key is the SHA-256 hash of the content of the media file. Renaming or moving a
/// recording, or a sync tool which only touches its write time, finds the transcript again. The path
/// follows from the id and the key, so nothing stores it.
/// </remarks>
/// <param name="dataDirectory">The data directory of AI Studio.</param>
/// <param name="dataSourceId">The id of the data source, a GUID.</param>
internal sealed partial class DataSourceTranscriptStore(string dataDirectory, string dataSourceId) : IMediaTranscriptLocation
{
/// <summary>
/// The folder below the data directory which holds the transcripts of all data sources.
/// </summary>
public const string FOLDER_NAME = "mediaTranscripts";
private const string FILE_EXTENSION = ".md";
private readonly string directoryPath = Path.Join(dataDirectory, FOLDER_NAME, NormalizeId(dataSourceId));
/// <summary>
/// The store of a data source in the data directory of the running app.
/// </summary>
/// <param name="dataSourceId">The id of the data source.</param>
/// <returns>The store.</returns>
public static DataSourceTranscriptStore ForDataSource(string dataSourceId) => new(SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet."), dataSourceId);
/// <summary>
/// The store of a data source in the data directory of the running app, or null when there is none to clean up.
/// </summary>
/// <param name="dataSourceId">The id of the data source.</param>
/// <returns>The store, or null while the data directory is not known or for an id which is no GUID.</returns>
public static DataSourceTranscriptStore? TryForDataSource(string dataSourceId) => string.IsNullOrWhiteSpace(SettingsManager.DataDirectory) || !Guid.TryParse(dataSourceId, out _)
? null
: new(SettingsManager.DataDirectory, dataSourceId);
/// <inheritdoc />
/// <remarks>
/// Reads the whole media file to hash it, in pieces. Callers ask only for files which are about
/// to be read anyway because they are new or changed.
/// </remarks>
public async Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token)
{
var key = await FileContentHash.ComputeAsync(mediaPath, token);
return new(this.GetPath(key), key);
}
/// <summary>
/// Whether a text is a key of this store, i.e. a lowercase SHA-256 hash.
/// </summary>
/// <param name="key">The text to check.</param>
/// <returns>True for a key.</returns>
public static bool IsKey(string key) => KeyPattern().IsMatch(key);
/// <summary>
/// The path of the transcript with the given key.
/// </summary>
/// <param name="key">The key of the transcript.</param>
/// <returns>The path, whether or not the transcript exists.</returns>
public string GetPath(string key)
{
if (!IsKey(key))
throw new ArgumentException("The key of a media transcript has to be a lowercase SHA-256 hash.", nameof(key));
return Path.Join(this.directoryPath, key + FILE_EXTENSION);
}
/// <summary>
/// The keys of all transcripts of this data source.
/// </summary>
/// <returns>The keys; empty when the data source has no transcripts.</returns>
public IReadOnlyList<string> ListKeys()
{
return this.EnumerateOwnFiles()
.Select(file => Path.GetFileName(file.FullName))
.Where(fileName => TranscriptFileNamePattern().IsMatch(fileName))
.Select(Path.GetFileNameWithoutExtension)
.OfType<string>()
.ToList();
}
/// <summary>
/// Deletes the transcript with the given key.
/// </summary>
/// <param name="key">The key of the transcript.</param>
/// <returns>True when a transcript was deleted.</returns>
public bool Delete(string key)
{
var file = new FileInfo(this.GetPath(key));
if (!file.Exists || file.Attributes.HasFlag(FileAttributes.ReparsePoint))
return false;
file.Delete();
return true;
}
/// <summary>
/// Deletes the temporary files which an interrupted write left behind.
/// </summary>
/// <remarks>
/// Call it only while nothing writes a transcript of this data source, e.g. at the end of a run.
/// </remarks>
/// <returns>The number of deleted files.</returns>
public int DeleteTemporaryFiles()
{
var deleted = 0;
foreach (var file in this.EnumerateOwnFiles().Where(file => TemporaryFileNamePattern().IsMatch(file.Name)))
{
file.Delete();
deleted++;
}
return deleted;
}
/// <summary>
/// Deletes every transcript of this data source, together with temporary files a crash left behind.
/// </summary>
/// <remarks>
/// Removes only what this store wrote. Anything else in the folder stays, and the folder with it.
/// </remarks>
public void DeleteAll()
{
foreach (var file in this.EnumerateOwnFiles())
file.Delete();
var directory = new DirectoryInfo(this.directoryPath);
if (directory.Exists && !directory.Attributes.HasFlag(FileAttributes.ReparsePoint) && !directory.EnumerateFileSystemInfos().Any())
directory.Delete();
}
/// <summary>
/// Copies the transcripts of all data sources, e.g. for a backup.
/// </summary>
/// <remarks>
/// Copies only transcripts, file by file and in pieces. Temporary files of an interrupted write,
/// links, and anything else in the folders stay behind.
/// </remarks>
/// <param name="dataDirectory">The data directory to copy from.</param>
/// <param name="targetDirectory">The folder which receives the transcripts below 'mediaTranscripts/', in the same layout.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The number of copied transcripts.</returns>
public static async Task<int> CopyAllAsync(string dataDirectory, string targetDirectory, CancellationToken token)
{
var root = new DirectoryInfo(Path.Join(dataDirectory, FOLDER_NAME));
if (!root.Exists || root.Attributes.HasFlag(FileAttributes.ReparsePoint))
return 0;
var copied = 0;
foreach (var dataSourceFolder in root.EnumerateDirectories("*", new EnumerationOptions { RecurseSubdirectories = false, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false }))
{
if (!Guid.TryParse(dataSourceFolder.Name, out _))
continue;
var store = new DataSourceTranscriptStore(dataDirectory, dataSourceFolder.Name);
foreach (var file in store.EnumerateOwnFiles().Where(file => TranscriptFileNamePattern().IsMatch(file.Name)))
{
token.ThrowIfCancellationRequested();
var targetPath = Path.Join(targetDirectory, FOLDER_NAME, dataSourceFolder.Name, file.Name);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await using var source = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var target = new FileStream(targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65_536, FileOptions.Asynchronous);
await source.CopyToAsync(target, token);
copied++;
}
}
return copied;
}
/// <summary>
/// The files this store wrote: transcripts and the temporary files of interrupted writes. Links are left out.
/// </summary>
private IEnumerable<FileInfo> EnumerateOwnFiles()
{
var directory = new DirectoryInfo(this.directoryPath);
if (!directory.Exists || directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
return [];
return directory
.EnumerateFiles("*", new EnumerationOptions { RecurseSubdirectories = false, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })
.Where(file => TranscriptFileNamePattern().IsMatch(file.Name) || TemporaryFileNamePattern().IsMatch(file.Name))
.ToList();
}
private static string NormalizeId(string dataSourceId) => Guid.TryParse(dataSourceId, out var id)
? id.ToString("D")
: throw new ArgumentException("The id of a data source with media transcripts has to be a GUID.", nameof(dataSourceId));
[GeneratedRegex("^[0-9a-f]{64}$")]
private static partial Regex KeyPattern();
[GeneratedRegex(@"^[0-9a-f]{64}\.md$")]
private static partial Regex TranscriptFileNamePattern();
[GeneratedRegex(@"^[0-9a-f]{64}\.md\.[0-9a-f]{32}\.tmp$")]
private static partial Regex TemporaryFileNamePattern();
}
@@ -0,0 +1,19 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// Where the persistent transcript of an audio or video file lives, cf. PersistentMediaTranscript.
/// </summary>
/// <remarks>
/// A location only names the place. Reading and writing the transcript is left to
/// PersistentMediaTranscript, so every location keeps the same rules against paying twice.
/// </remarks>
internal interface IMediaTranscriptLocation
{
/// <summary>
/// Finds the place of the transcript of a media file.
/// </summary>
/// <param name="mediaPath">The audio or video file.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The place of the transcript, whether or not a transcript is there yet.</returns>
Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token);
}
@@ -0,0 +1,107 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// Keeps the transcript of a media file next to it, as '&lt;media file&gt;.transcript.md'.
/// </summary>
/// <remarks>
/// The Batch Processing assistant writes its results into the folder the user chose, and its
/// transcripts belong there as well, where people find and reuse them. Data sources must not write
/// into the folders of the user and keep their transcripts below the data directory instead.
/// </remarks>
internal sealed class MediaSidecarTranscripts : IMediaTranscriptLocation
{
public const string FILE_SUFFIX = ".transcript.md";
public static readonly MediaSidecarTranscripts INSTANCE = new();
/// <inheritdoc />
public Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token)
{
var path = GetFilePath(mediaPath);
return Task.FromResult(new MediaTranscriptSlot(path, path));
}
/// <summary>
/// The path of the transcript next to a media file.
/// </summary>
/// <param name="mediaPath">The audio or video file.</param>
/// <returns>The path of its transcript.</returns>
public static string GetFilePath(string mediaPath) => mediaPath + FILE_SUFFIX;
/// <summary>
/// Whether a file is a transcript next to a media file, or a temporary file left while one was stored.
/// </summary>
/// <param name="path">The path of the file.</param>
/// <returns>True for transcripts and their temporary files.</returns>
public static bool IsArtifactPath(string path)
{
var fileName = Path.GetFileName(path);
if (fileName.EndsWith(FILE_SUFFIX, StringComparison.OrdinalIgnoreCase))
return true;
var suffixIndex = fileName.LastIndexOf(FILE_SUFFIX + ".", StringComparison.OrdinalIgnoreCase);
return suffixIndex >= 0 && fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Whether a file is the transcript of a media file which the selection of a folder data source selects as well.
/// </summary>
/// <remarks>
/// Such a transcript holds the same text the data source gets from transcribing the media file,
/// so indexing it as a document of its own would only duplicate that text. A transcript whose
/// media file is gone, or not selected, stays a document like every other Markdown file.
/// </remarks>
/// <param name="path">The path of the file.</param>
/// <param name="selection">The files the folder data source selects.</param>
/// <returns>True when the data source indexes the media file of this transcript.</returns>
public static bool IsCoveredArtifact(string path, FolderFileSelection selection)
{
if (!TryGetMediaPath(path, out var mediaPath))
return false;
return PersistentMediaTranscript.IsMediaPath(mediaPath) && File.Exists(mediaPath) && selection.Selects(mediaPath);
}
/// <summary>
/// Whether a media file has a transcript next to it, so no transcription provider is needed for it.
/// </summary>
/// <param name="mediaPath">The audio or video file.</param>
/// <returns>True when a transcript which is not empty exists.</returns>
public static bool HasReusableTranscript(string mediaPath)
{
var transcriptFilePath = GetFilePath(mediaPath);
try
{
return File.Exists(transcriptFilePath) && new FileInfo(transcriptFilePath).Length > 0;
}
catch
{
// The concrete read error is reported when the affected file is
// processed. Here we only decide whether a provider is required.
return File.Exists(transcriptFilePath);
}
}
/// <summary>
/// The media file a transcript or one of its temporary files belongs to.
/// </summary>
private static bool TryGetMediaPath(string path, out string mediaPath)
{
var suffixIndex = path.LastIndexOf(FILE_SUFFIX, StringComparison.OrdinalIgnoreCase);
if (suffixIndex <= 0)
{
mediaPath = string.Empty;
return false;
}
var remainder = path[(suffixIndex + FILE_SUFFIX.Length)..];
if (remainder.Length > 0 && !(remainder[0] is '.' && remainder.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)))
{
mediaPath = string.Empty;
return false;
}
mediaPath = path[..suffixIndex];
return true;
}
}
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// The place of the persistent transcript of one audio or video file.
/// </summary>
/// <param name="Path">The absolute path of the Markdown transcript.</param>
/// <param name="Key">What identifies the transcript at its location, e.g. the hash of the media content.</param>
internal sealed record MediaTranscriptSlot(string Path, string Key);
@@ -9,7 +9,8 @@ namespace AIStudio.Tools.Media;
/// <param name="Text">Transcript text for a successful operation.</param> /// <param name="Text">Transcript text for a successful operation.</param>
/// <param name="UserMessage">Localized message suitable for display after a warning or failure.</param> /// <param name="UserMessage">Localized message suitable for display after a warning or failure.</param>
/// <param name="ErrorCode">Optional stable runtime failure category.</param> /// <param name="ErrorCode">Optional stable runtime failure category.</param>
public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Status, string Text, string UserMessage, MediaJobErrorCode? ErrorCode = null) /// <param name="FileRejected">Whether the transcription provider refused the media file itself, so that it would refuse the unchanged file again.</param>
public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Status, string Text, string UserMessage, MediaJobErrorCode? ErrorCode = null, bool FileRejected = false)
{ {
/// <summary>Creates a successful result.</summary> /// <summary>Creates a successful result.</summary>
/// <param name="text">Provider transcript.</param> /// <param name="text">Provider transcript.</param>
@@ -20,6 +21,10 @@ public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Sta
/// <param name="errorCode">Optional runtime error category.</param> /// <param name="errorCode">Optional runtime error category.</param>
public static MediaTranscriptionResult Failed(string userMessage, MediaJobErrorCode? errorCode = null) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, errorCode); public static MediaTranscriptionResult Failed(string userMessage, MediaJobErrorCode? errorCode = null) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, errorCode);
/// <summary>Creates a failed result for a media file which the transcription provider refused.</summary>
/// <param name="userMessage">Localized visible message.</param>
public static MediaTranscriptionResult Rejected(string userMessage) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, FileRejected: true);
/// <summary>Creates a warning result for media without an audible signal.</summary> /// <summary>Creates a warning result for media without an audible signal.</summary>
/// <param name="userMessage">Localized visible warning.</param> /// <param name="userMessage">Localized visible warning.</param>
public static MediaTranscriptionResult NoAudibleSignal(string userMessage) => new( public static MediaTranscriptionResult NoAudibleSignal(string userMessage) => new(
@@ -0,0 +1,213 @@
using System.Text;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Media;
/// <summary>
/// Reads or creates the persistent Markdown transcript of an audio or video file.
/// </summary>
/// <remarks>
/// Every transcript which AI Studio keeps comes from here, so the rules which keep a paid
/// transcription from being paid twice hold for every caller: an existing transcript is used as it
/// is, a transcript which arrived is stored even when the caller cancelled meanwhile, and nothing is
/// stored for a file which changed during its transcription or for a transcription without text.
/// Where a transcript lives is up to the location, e.g. next to the media file or below the data
/// directory.
/// </remarks>
internal static class PersistentMediaTranscript
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PersistentMediaTranscript).Namespace, nameof(PersistentMediaTranscript));
/// <summary>
/// Whether a file is an audio or video file, which holds no text until it is transcribed.
/// </summary>
/// <param name="path">The path of the file.</param>
/// <returns>True for audio and video files.</returns>
public static bool IsMediaPath(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO, FileTypes.VIDEO);
/// <summary>
/// Returns where the transcript of a media file is, transcribing the file only when its location holds no transcript yet.
/// </summary>
/// <param name="location">Where the transcript lives.</param>
/// <param name="mediaPath">The audio or video file.</param>
/// <param name="provider">The transcription provider the caller allows, or null when no provider may hear the file.</param>
/// <param name="transcribe">Transcribes the media file with the given provider, usually MediaTranscriptionService.TranscribeAsync.</param>
/// <param name="logger">The logger of the caller.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>Where the transcript is, or why there is none.</returns>
public static async Task<PersistentMediaTranscriptResult> ReadOrCreateAsync(IMediaTranscriptLocation location, string mediaPath, TranscriptionProvider? provider, Func<string, TranscriptionProvider, CancellationToken, Task<MediaTranscriptionResult>> transcribe, ILogger logger, CancellationToken token)
{
//
// The stamp comes first: a location may read the whole file to find the place of its
// transcript, and a change from then on must keep a transcript out of that place.
//
FileStamp sourceBefore;
MediaTranscriptSlot slot;
try
{
sourceBefore = FileStamp.Read(mediaPath);
slot = await location.ResolveAsync(mediaPath, token);
}
catch (OperationCanceledException)
{
return PersistentMediaTranscriptResult.Cancelled();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not inspect the media file '{MediaFilePath}' before its transcription.", mediaPath);
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.SOURCE_UNREADABLE, null, TB("The media file could not be read."));
}
try
{
if (await HoldsTextAsync(slot.Path, token))
{
logger.LogInformation("Reusing the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Ready(slot, reused: true);
}
}
catch (OperationCanceledException)
{
return PersistentMediaTranscriptResult.Cancelled();
}
catch (Exception exception)
{
//
// Transcribing again would mean paying for a transcript which exists, and storing it
// would most likely fail for the same reason:
//
logger.LogWarning(exception, "Could not read the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPT_UNREADABLE, slot, string.Format(TB("Was not able to read the existing transcript: {0}"), exception.Message));
}
if (provider is null)
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.NO_PROVIDER, slot, TB("No usable transcription provider is configured."));
MediaTranscriptionResult transcription;
try
{
transcription = await transcribe(mediaPath, provider, token);
}
catch (OperationCanceledException)
{
return PersistentMediaTranscriptResult.Cancelled();
}
switch (transcription.Status)
{
case MediaTranscriptionResultStatus.CANCELLED:
return PersistentMediaTranscriptResult.Cancelled();
case MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL:
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.NO_AUDIBLE_SIGNAL, slot, transcription.UserMessage);
case MediaTranscriptionResultStatus.FAILED when transcription.FileRejected:
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.REJECTED, slot, transcription.UserMessage);
case MediaTranscriptionResultStatus.FAILED:
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED, slot, transcription.UserMessage, transcription.ErrorCode);
}
if (string.IsNullOrWhiteSpace(transcription.Text))
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.EMPTY, slot, TB("The transcription provider returned an empty transcript."));
if (!sourceBefore.StillMatches(mediaPath))
return SourceChanged(slot);
var temporaryPath = $"{slot.Path}.{Guid.NewGuid():N}.tmp";
try
{
var directory = Path.GetDirectoryName(slot.Path);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
//
// The transcription is paid for at this point. Storing it is quick, so it is completed
// even when the caller cancelled meanwhile, and the next attempt can use it:
//
await File.WriteAllTextAsync(temporaryPath, transcription.Text, new UTF8Encoding(false), CancellationToken.None);
if (!sourceBefore.StillMatches(mediaPath))
return SourceChanged(slot);
File.Move(temporaryPath, slot.Path, true);
logger.LogInformation("Stored the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Ready(slot, reused: false);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not store the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.STORE_FAILED, slot, string.Format(TB("Was not able to store the transcript: {0}"), exception.Message));
}
finally
{
try
{
if (File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not remove the temporary transcript '{TempFilePath}'.", temporaryPath);
}
}
}
private static PersistentMediaTranscriptResult SourceChanged(MediaTranscriptSlot slot) => PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.SOURCE_CHANGED, slot, TB("The media file changed while it was being transcribed, so its transcript was not kept."));
/// <summary>
/// Whether a transcript exists and holds more than white space.
/// </summary>
/// <remarks>
/// Reads only as far as the first character which is not white space, so a large file is
/// never loaded as a whole.
/// </remarks>
private static async Task<bool> HoldsTextAsync(string path, CancellationToken token)
{
if (!File.Exists(path))
return false;
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var buffer = new char[1024];
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory(), token)) > 0)
{
for (var index = 0; index < read; index++)
if (!char.IsWhiteSpace(buffer[index]))
return true;
}
return false;
}
/// <summary>
/// The size and write time of a media file, to notice a change during its transcription.
/// </summary>
private readonly record struct FileStamp(long Length, DateTime LastWriteTimeUtc)
{
public static FileStamp Read(string path)
{
var file = new FileInfo(path);
file.Refresh();
if (!file.Exists)
throw new FileNotFoundException("The media file does not exist.", path);
return new(file.Length, file.LastWriteTimeUtc);
}
public bool StillMatches(string path)
{
try
{
return Read(path) == this;
}
catch
{
return false;
}
}
}
}
@@ -0,0 +1,26 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Media;
/// <summary>
/// The outcome of a request for a persistent transcript, cf. PersistentMediaTranscript.
/// </summary>
/// <remarks>
/// The transcript itself is not part of it: whoever needs the text reads the file at
/// TranscriptPath, so a transcript reaches the AI by the same path no matter whether it was just
/// made or stored long ago.
/// </remarks>
/// <param name="Status">How the request ended.</param>
/// <param name="TranscriptPath">The path of the transcript; empty when the place could not be determined.</param>
/// <param name="Key">What identifies the transcript at its location; empty when the place could not be determined.</param>
/// <param name="Reused">Whether an existing transcript was used instead of a new transcription.</param>
/// <param name="UserMessage">A localized explanation for every status but READY and CANCELLED.</param>
/// <param name="ErrorCode">Why the media pipeline failed, for TRANSCRIPTION_FAILED, when it is known.</param>
internal sealed record PersistentMediaTranscriptResult(PersistentMediaTranscriptStatus Status, string TranscriptPath, string Key, bool Reused, string UserMessage, MediaJobErrorCode? ErrorCode = null)
{
public static PersistentMediaTranscriptResult Ready(MediaTranscriptSlot slot, bool reused) => new(PersistentMediaTranscriptStatus.READY, slot.Path, slot.Key, reused, string.Empty);
public static PersistentMediaTranscriptResult Cancelled() => new(PersistentMediaTranscriptStatus.CANCELLED, string.Empty, string.Empty, false, string.Empty);
public static PersistentMediaTranscriptResult Unavailable(PersistentMediaTranscriptStatus status, MediaTranscriptSlot? slot, string userMessage, MediaJobErrorCode? errorCode = null) => new(status, slot?.Path ?? string.Empty, slot?.Key ?? string.Empty, false, userMessage, errorCode);
}
@@ -0,0 +1,63 @@
namespace AIStudio.Tools.Media;
/// <summary>
/// How the request for a persistent transcript ended, cf. PersistentMediaTranscript.
/// </summary>
public enum PersistentMediaTranscriptStatus
{
/// <summary>
/// The transcript is stored at its place and holds text.
/// </summary>
READY,
/// <summary>
/// There is no transcript yet, and the caller allows no transcription provider.
/// </summary>
NO_PROVIDER,
/// <summary>
/// The media file could not be read before it was transcribed.
/// </summary>
SOURCE_UNREADABLE,
/// <summary>
/// A transcript exists, but it could not be read.
/// </summary>
TRANSCRIPT_UNREADABLE,
/// <summary>
/// The transcription failed. The error code of the media pipeline tells why, when it is known.
/// </summary>
TRANSCRIPTION_FAILED,
/// <summary>
/// The transcription provider refused the media file itself, e.g., because it is too large
/// or damaged. It would refuse the unchanged file again.
/// </summary>
REJECTED,
/// <summary>
/// The media file holds no audible signal, so there was nothing to transcribe.
/// </summary>
NO_AUDIBLE_SIGNAL,
/// <summary>
/// The transcription provider returned no text. Nothing was stored.
/// </summary>
EMPTY,
/// <summary>
/// The media file changed while it was transcribed. Nothing was stored.
/// </summary>
SOURCE_CHANGED,
/// <summary>
/// The transcript could not be stored at its place.
/// </summary>
STORE_FAILED,
/// <summary>
/// The caller cancelled before a transcript was available.
/// </summary>
CANCELLED,
}
@@ -170,7 +170,7 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
var settingsJson = JsonSerializer.Serialize(settings, SettingsManager.JSON_OPTIONS); var settingsJson = JsonSerializer.Serialize(settings, SettingsManager.JSON_OPTIONS);
var organizationPluginFolders = DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), dataDirectory); var organizationPluginFolders = DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), dataDirectory);
indexCopiesDirectory = await this.CopyIndexesAsync(token); indexCopiesDirectory = await this.CopyIndexesAsync(dataDirectory, token);
var sources = new DataBackupSources(dataDirectory, configDirectory, settingsJson, indexCopiesDirectory, organizationPluginFolders); var sources = new DataBackupSources(dataDirectory, configDirectory, settingsJson, indexCopiesDirectory, organizationPluginFolders);
var manifest = new DataBackupManifest( var manifest = new DataBackupManifest(
@@ -207,7 +207,7 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
} }
/// <summary> /// <summary>
/// Copies both indexes of the data sources, while the indexing pauses. /// Copies both indexes of the data sources and the transcripts of their media files, while the indexing pauses.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Both copies are made during one pause: the vector stores have to fit to the index, or a /// Both copies are made during one pause: the vector stores have to fit to the index, or a
@@ -217,8 +217,10 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
/// ///
/// Without either database, the backup leaves both indexes out, and its manifest says so. /// Without either database, the backup leaves both indexes out, and its manifest says so.
/// </remarks> /// </remarks>
/// <param name="dataDirectory">The data folder, which holds the transcripts.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The directory of the copies, in the layout of the data folder, or an empty string when the backup leaves the indexes out.</returns> /// <returns>The directory of the copies, in the layout of the data folder, or an empty string when the backup leaves the indexes out.</returns>
private async Task<string> CopyIndexesAsync(CancellationToken token) private async Task<string> CopyIndexesAsync(string dataDirectory, CancellationToken token)
{ {
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token); var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
var vectorStore = await databaseClientProvider.GetVectorStoreAsync(token); var vectorStore = await databaseClientProvider.GetVectorStoreAsync(token);
@@ -241,6 +243,12 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
await vectorStore.CopyStoreForBackupAsync(preparation.BackupId, preparation.Stores[index].StoreName, token); await vectorStore.CopyStoreForBackupAsync(preparation.BackupId, preparation.Stores[index].StoreName, token);
} }
//
// The index knows which transcript belongs to which media file, so the transcripts are
// copied while the indexing pauses as well, and the backup holds both as they fit together:
//
await DataSourceTranscriptStore.CopyAllAsync(dataDirectory, preparation.StagingPath, token);
return preparation.StagingPath; return preparation.StagingPath;
} }
catch catch
@@ -5,6 +5,24 @@ namespace AIStudio.Tools.Services;
/// </summary> /// </summary>
public enum DataSourceEmbeddingIgnoreReason public enum DataSourceEmbeddingIgnoreReason
{ {
MEDIA_NOT_SUPPORTED, /// <summary>
/// An image, which cannot be added to the index yet.
/// </summary>
IMAGE_NOT_SUPPORTED,
/// <summary>
/// An audio or video file without a transcript, while no usable transcription provider is configured.
/// </summary>
TRANSCRIPTION_PROVIDER_MISSING,
/// <summary>
/// An audio or video file without a transcript, while the transcription provider falls short of
/// the confidence level the data source asks for.
/// </summary>
TRANSCRIPTION_PROVIDER_NOT_ALLOWED,
/// <summary>
/// A file larger than the limit of the data source.
/// </summary>
TOO_LARGE, TOO_LARGE,
} }
@@ -6,7 +6,8 @@ namespace AIStudio.Tools.Services;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Files below folders the user chose not to scan never reach enumeration and therefore do not /// Files below folders the user chose not to scan never reach enumeration and therefore do not
/// appear here. Temporary and system files are implementation details and stay hidden as well. /// appear here. Generated transcript artifacts and temporary/system files are implementation
/// details and stay hidden as well.
/// </remarks> /// </remarks>
/// <param name="FilePath">The absolute path of the ignored file.</param> /// <param name="FilePath">The absolute path of the ignored file.</param>
/// <param name="Reason">Why the file was ignored.</param> /// <param name="Reason">Why the file was ignored.</param>
@@ -7,6 +7,7 @@ using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases; using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.IndexStore; using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Databases.VectorStore; using AIStudio.Tools.Databases.VectorStore;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Security; using AIStudio.Tools.Security;
using AIStudio.Tools.Services.Indexing; using AIStudio.Tools.Services.Indexing;
@@ -16,7 +17,7 @@ using static AIStudio.Tools.Services.Indexing.IndexingLogFormat;
namespace AIStudio.Tools.Services; namespace AIStudio.Tools.Services;
public sealed partial class DataSourceEmbeddingService(SettingsManager settingsManager, RustService rustService, DatabaseClientProvider databaseClientProvider, public sealed partial class DataSourceEmbeddingService(SettingsManager settingsManager, RustService rustService, DatabaseClientProvider databaseClientProvider,
PromptInjectionGuardService guardService, ILogger<DataSourceEmbeddingService> logger) : BackgroundService, IMessageBusReceiver PromptInjectionGuardService guardService, MediaTranscriptionService mediaTranscriptionService, ILogger<DataSourceEmbeddingService> logger) : BackgroundService, IMessageBusReceiver
{ {
/// <summary> /// <summary>
/// How long the re-index check waits for the index database before it gives up. /// How long the re-index check waits for the index database before it gives up.
@@ -40,7 +41,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
/// <summary> /// <summary>
/// One indexer per kind of data source this service indexes. /// One indexer per kind of data source this service indexes.
/// </summary> /// </summary>
private readonly IReadOnlyList<IIndexedSourceIndexer> indexers = CreateIndexers(settingsManager, rustService, guardService, logger); private readonly IReadOnlyList<IIndexedSourceIndexer> indexers = CreateIndexers(settingsManager, rustService, guardService, mediaTranscriptionService, logger);
private readonly DataSourceRunQueue runQueue = new(); private readonly DataSourceRunQueue runQueue = new();
@@ -93,12 +94,12 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
/// <summary> /// <summary>
/// Creates one indexer per kind of data source, all of them cutting their text with the same chunker. /// Creates one indexer per kind of data source, all of them cutting their text with the same chunker.
/// </summary> /// </summary>
private static IReadOnlyList<IIndexedSourceIndexer> CreateIndexers(SettingsManager settingsManager, RustService rustService, PromptInjectionGuardService guardService, ILogger logger) private static IReadOnlyList<IIndexedSourceIndexer> CreateIndexers(SettingsManager settingsManager, RustService rustService, PromptInjectionGuardService guardService, MediaTranscriptionService mediaTranscriptionService, ILogger logger)
{ {
var textChunker = new TextChunker(rustService, logger); var textChunker = new TextChunker(rustService, logger);
return return
[ [
new FileSourceIndexer(settingsManager, rustService, textChunker, logger), new FileSourceIndexer(settingsManager, rustService, mediaTranscriptionService, textChunker, logger),
new MailboxIndexer(settingsManager, rustService, guardService, textChunker, logger), new MailboxIndexer(settingsManager, rustService, guardService, textChunker, logger),
]; ];
} }
@@ -518,10 +519,35 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
this.statuses.TryRemove(dataSource.Id, out _); this.statuses.TryRemove(dataSource.Id, out _);
await this.ResetPersistedStateAsync(dataSource.Id, null, null, CancellationToken.None); await this.ResetPersistedStateAsync(dataSource.Id, null, null, CancellationToken.None);
this.DeleteMediaTranscripts(dataSource);
this.statuses.TryRemove(dataSource.Id, out _); this.statuses.TryRemove(dataSource.Id, out _);
PublishStatusChanged(); PublishStatusChanged();
} }
/// <summary>
/// Deletes the transcripts of the audio and video files of a data source which the user removed.
/// </summary>
/// <remarks>
/// Only the removal of a data source does this. A reset for new embedding settings keeps them,
/// cf. ResetPersistedStateAsync: they are still valid, and the new index would otherwise pay
/// for every recording once more.
/// </remarks>
/// <param name="dataSource">The removed data source.</param>
private void DeleteMediaTranscripts(IDataSourceBase dataSource)
{
if (dataSource is not (DataSourceLocalDirectory or DataSourceLocalFile))
return;
try
{
DataSourceTranscriptStore.TryForDataSource(dataSource.Id)?.DeleteAll();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not remove the transcripts of the deleted data source '{DataSourceName}' ({DataSourceId}).", dataSource.Name, dataSource.Id);
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
// Changes to the embedding settings which do not pass through the queue, cf. ProcessMessage: // Changes to the embedding settings which do not pass through the queue, cf. ProcessMessage:
@@ -1086,6 +1112,18 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
? embeddingProviderConfidence.AllowsMailboxConfidenceLevel(dataSource.ConfidenceLevel) ? embeddingProviderConfidence.AllowsMailboxConfidenceLevel(dataSource.ConfidenceLevel)
: embeddingProviderConfidence.AllowsDataSourceConfidenceLevel(dataSource.ConfidenceLevel); : embeddingProviderConfidence.AllowsDataSourceConfidenceLevel(dataSource.ConfidenceLevel);
/// <summary>
/// Whether a transcription provider of the given confidence level may hear the audio and video files of a data source.
/// </summary>
/// <remarks>
/// The transcription provider receives the content of the data source just like the embedding
/// provider does, so the same rule applies to both, cf. AllowsEmbedding.
/// </remarks>
/// <param name="dataSource">The data source to index.</param>
/// <param name="transcriptionProviderConfidence">The confidence level of the transcription provider.</param>
/// <returns>True when the provider may transcribe the media files of the data source.</returns>
internal static bool AllowsTranscription(IIndexedDataSource dataSource, ConfidenceLevel transcriptionProviderConfidence) => AllowsEmbedding(dataSource, transcriptionProviderConfidence);
/// <summary> /// <summary>
/// The configured data sources this service indexes, from every list which holds some. /// The configured data sources this service indexes, from every list which holds some.
/// </summary> /// </summary>
@@ -68,6 +68,13 @@ public sealed record DataSourceEmbeddingStatus(
/// </summary> /// </summary>
public int SkippedDocuments => this.PermanentlySkippedDocuments + this.IgnoredFiles.Count; public int SkippedDocuments => this.PermanentlySkippedDocuments + this.IgnoredFiles.Count;
/// <summary>
/// Whether selected audio or video files wait for a transcription provider which this data
/// source allows, so they cannot be indexed until the settings change.
/// </summary>
public bool NeedsTranscriptionProvider => this.IgnoredFiles.Any(file =>
file.Reason is DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING or DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED);
/// <summary> /// <summary>
/// The settled health of this data source. Activity is represented by State /// The settled health of this data source. Activity is represented by State
/// instead, so queued and running sources can keep their activity indicator. /// instead, so queued and running sources can keep their activity indicator.
@@ -84,7 +91,8 @@ public sealed record DataSourceEmbeddingStatus(
if (this.State is DataSourceEmbeddingState.FAILED || this.FailedDocuments > 0) if (this.State is DataSourceEmbeddingState.FAILED || this.FailedDocuments > 0)
return DataSourceEmbeddingHealth.ERROR; return DataSourceEmbeddingHealth.ERROR;
if (this.State is DataSourceEmbeddingState.COMPLETED && this.IndexedDocuments == 0 && this.SkippedDocuments > 0) if (this.NeedsTranscriptionProvider ||
this.State is DataSourceEmbeddingState.COMPLETED && this.IndexedDocuments == 0 && this.SkippedDocuments > 0)
return DataSourceEmbeddingHealth.WARNING; return DataSourceEmbeddingHealth.WARNING;
return DataSourceEmbeddingHealth.HEALTHY; return DataSourceEmbeddingHealth.HEALTHY;
@@ -1,3 +1,3 @@
namespace AIStudio.Tools.Services; namespace AIStudio.Tools.Services;
public sealed record EmbeddedFileRecord(string Fingerprint, long FileSize, DateTimeOffset LastWriteUtc, DateTimeOffset EmbeddedAtUtc, int ChunkCount); public sealed record EmbeddedFileRecord(string Fingerprint, long FileSize, DateTimeOffset LastWriteUtc, DateTimeOffset EmbeddedAtUtc, int ChunkCount, string? TranscriptKey = null);
@@ -226,7 +226,7 @@ internal sealed class DocumentRunProgress(IndexedRunContext context, int totalDo
var embeddedAtUtc = DateTimeOffset.UtcNow; var embeddedAtUtc = DateTimeOffset.UtcNow;
var state = document.State with { ChunkCount = chunkCount, EmbeddedAtUtc = embeddedAtUtc }; var state = document.State with { ChunkCount = chunkCount, EmbeddedAtUtc = embeddedAtUtc };
await context.IndexStore.UpsertFileAsync(context.DataSource.Id, state, token); await context.IndexStore.UpsertFileAsync(context.DataSource.Id, state, token);
context.Manifest.Files[document.Key] = new EmbeddedFileRecord(state.Fingerprint, state.FileSize, state.LastWriteUtc, embeddedAtUtc, chunkCount); context.Manifest.Files[document.Key] = new EmbeddedFileRecord(state.Fingerprint, state.FileSize, state.LastWriteUtc, embeddedAtUtc, chunkCount, state.TranscriptKey);
await context.ForgetPermanentFailureAsync(document.Key, token); await context.ForgetPermanentFailureAsync(document.Key, token);
context.Pass.Forget(document.Key); context.Pass.Forget(document.Key);
@@ -300,7 +300,7 @@ internal sealed class DocumentRunProgress(IndexedRunContext context, int totalDo
// //
this.PermanentlySkippedDocuments++; this.PermanentlySkippedDocuments++;
var occurredAtUtc = DateTimeOffset.UtcNow; var occurredAtUtc = DateTimeOffset.UtcNow;
var indexingMessage = this.GetFailureMessage(extractionFailure.Code, document); var indexingMessage = this.GetFailureMessage(extractionFailure.Code, document, extractionFailure.Message);
this.failures.Add(new DataSourceEmbeddingFailure(document.Key, indexingMessage, occurredAtUtc, ExtractionCode: extractionFailure.Code, IsPermanent: true, DisplayName: document.DisplayName)); this.failures.Add(new DataSourceEmbeddingFailure(document.Key, indexingMessage, occurredAtUtc, ExtractionCode: extractionFailure.Code, IsPermanent: true, DisplayName: document.DisplayName));
context.Manifest.Files.Remove(document.Key); context.Manifest.Files.Remove(document.Key);
context.Pass.Forget(document.Key); context.Pass.Forget(document.Key);
@@ -533,10 +533,37 @@ internal sealed class DocumentRunProgress(IndexedRunContext context, int totalDo
/// </remarks> /// </remarks>
/// <param name="code">Why reading the document failed, NONE when it was not about reading it.</param> /// <param name="code">Why reading the document failed, NONE when it was not about reading it.</param>
/// <param name="document">The document.</param> /// <param name="document">The document.</param>
/// <param name="details">A localized explanation supplied by a specialized reader, when available.</param>
/// <returns>The message, ready to show.</returns> /// <returns>The message, ready to show.</returns>
private string GetFailureMessage(FileExtractionErrorCode code, EmbeddingDocument document) => context.DataSource is DataSourceMailbox private string GetFailureMessage(FileExtractionErrorCode code, EmbeddingDocument document, string? details = null)
? string.Format(TB("The mail '{0}' could not be indexed. AI Studio tries again during the next sync."), document.DisplayName) {
: code.ToIndexingUserMessage(document.DisplayName); if (context.DataSource is DataSourceMailbox)
return string.Format(TB("The mail '{0}' could not be indexed. AI Studio tries again during the next sync."), document.DisplayName);
if (code is FileExtractionErrorCode.MEDIA_PROCESSING_FAILED && !string.IsNullOrWhiteSpace(details))
{
return string.Format(
TB("The media file '{0}' could not be processed, so it was not indexed. Reason: {1} AI Studio reads it again as soon as the file changes."),
document.DisplayName,
AsSentence(details));
}
return code.ToIndexingUserMessage(document.DisplayName);
}
/// <summary>
/// Ends a reason with a full stop, so the sentence after it does not run into it.
/// </summary>
/// <remarks>
/// Providers write their error messages as they like, often without a full stop at the end.
/// </remarks>
/// <param name="reason">The reason.</param>
/// <returns>The reason, ending with a punctuation mark.</returns>
internal static string AsSentence(string reason)
{
var sentence = reason.TrimEnd();
return sentence.Length is 0 || sentence[^1] is '.' or '!' or '?' or '…' ? sentence : $"{sentence}.";
}
private DataSourceEmbeddingStatus CreateStatus(DataSourceEmbeddingState state, string currentDocument, string lastError, int? currentBlock, int? currentPage, DataSourceAttention attention = DataSourceAttention.NONE, int? pendingRemovalCount = null) => new( private DataSourceEmbeddingStatus CreateStatus(DataSourceEmbeddingState state, string currentDocument, string lastError, int? currentBlock, int? currentPage, DataSourceAttention attention = DataSourceAttention.NONE, int? pendingRemovalCount = null) => new(
context.DataSource.Id, context.DataSource.Id,
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.Media;
namespace AIStudio.Tools.Services.Indexing; namespace AIStudio.Tools.Services.Indexing;
@@ -268,6 +269,14 @@ internal sealed partial class FileSourceIndexer
if (Directory.Exists(path)) if (Directory.Exists(path))
return true; return true;
//
// The Batch Processing assistant writes the transcript of a media file next to it. When
// the data source selects the media file itself, the transcript is no document of its
// own, so writing it must not start a run either:
//
if (MediaSidecarTranscripts.IsCoveredArtifact(path, selection))
return false;
if (selection.Selects(path)) if (selection.Selects(path))
return true; return true;
@@ -5,6 +5,7 @@ using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases.IndexStore; using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Databases.VectorStore; using AIStudio.Tools.Databases.VectorStore;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Security; using AIStudio.Tools.Security;
@@ -32,9 +33,10 @@ namespace AIStudio.Tools.Services.Indexing;
/// </remarks> /// </remarks>
/// <param name="settingsManager">The settings, read for whether local data sources refresh on their own.</param> /// <param name="settingsManager">The settings, read for whether local data sources refresh on their own.</param>
/// <param name="rustService">The runtime, which extracts the text of the files.</param> /// <param name="rustService">The runtime, which extracts the text of the files.</param>
/// <param name="mediaTranscriptionService">Transcribes selected audio and video files.</param>
/// <param name="textChunker">Cuts the text of a file into chunks.</param> /// <param name="textChunker">Cuts the text of a file into chunks.</param>
/// <param name="logger">The logger of the embedding service, so the log reads the same whoever writes it.</param> /// <param name="logger">The logger of the embedding service, so the log reads the same whoever writes it.</param>
internal sealed partial class FileSourceIndexer(SettingsManager settingsManager, RustService rustService, TextChunker textChunker, ILogger logger) : IIndexedSourceIndexer internal sealed partial class FileSourceIndexer(SettingsManager settingsManager, RustService rustService, MediaTranscriptionService mediaTranscriptionService, TextChunker textChunker, ILogger logger) : IIndexedSourceIndexer
{ {
private const bool IMAGE_EMBEDDING_ENABLED = false; private const bool IMAGE_EMBEDDING_ENABLED = false;
private const int MAX_LOGGED_EXCLUDED_FILE_EXAMPLES = 3; private const int MAX_LOGGED_EXCLUDED_FILE_EXAMPLES = 3;
@@ -45,12 +47,22 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
{ {
INDEXABLE, INDEXABLE,
EXCLUDED, EXCLUDED,
MEDIA_NOT_SUPPORTED, IMAGE_NOT_SUPPORTED,
TRANSCRIPTION_PROVIDER_MISSING,
TRANSCRIPTION_PROVIDER_NOT_ALLOWED,
TOO_LARGE, TOO_LARGE,
} }
private sealed record DataSourceMetadataSnapshot(string SourceHash, IReadOnlyDictionary<string, string> FileHashes); private sealed record DataSourceMetadataSnapshot(string SourceHash, IReadOnlyDictionary<string, string> FileHashes);
/// <summary>
/// What decides about the files of a data source while they are listed.
/// </summary>
/// <param name="Selection">The files of a folder data source, or null for a single file, which has no size limit.</param>
/// <param name="Manifest">What the index stores about the data source, or null when it does not matter.</param>
/// <param name="MediaTranscription">Which transcription provider may hear audio and video files, asked for only once such a file turns up.</param>
private sealed record InputFileRules(FolderFileSelection? Selection, DataSourceEmbeddingManifest? Manifest, Lazy<MediaTranscriptionAvailability> MediaTranscription);
/// <inheritdoc /> /// <inheritdoc />
public bool Supports(IDataSourceBase dataSource) => dataSource is DataSourceLocalDirectory or DataSourceLocalFile; public bool Supports(IDataSourceBase dataSource) => dataSource is DataSourceLocalDirectory or DataSourceLocalFile;
@@ -66,7 +78,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
var manifest = context.Manifest; var manifest = context.Manifest;
var enumerationStartedUtc = DateTimeOffset.UtcNow; var enumerationStartedUtc = DateTimeOffset.UtcNow;
var inputFiles = this.GetInputFiles(dataSource); var inputFiles = this.GetInputFiles(dataSource, manifest);
var enumerationDuration = DateTimeOffset.UtcNow - enumerationStartedUtc; var enumerationDuration = DateTimeOffset.UtcNow - enumerationStartedUtc;
if (inputFiles.UnreachableRootReason is { } unreachableRootReason) if (inputFiles.UnreachableRootReason is { } unreachableRootReason)
return this.StopWithoutRoot(context, dataSource, unreachableRootReason); return this.StopWithoutRoot(context, dataSource, unreachableRootReason);
@@ -133,6 +145,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
progress.RecordStillUnreadable(filePath, permanentFailure); progress.RecordStillUnreadable(filePath, permanentFailure);
await progress.CompleteRunAsync(metadataSnapshot.SourceHash, "data source finished after removing missing files", token); await progress.CompleteRunAsync(metadataSnapshot.SourceHash, "data source finished after removing missing files", token);
this.CleanUpTranscripts(context, progress);
return IndexedRunOutcome.DONE; return IndexedRunOutcome.DONE;
} }
@@ -181,7 +194,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
var fingerprint = metadataSnapshot.FileHashes[file.FullName]; var fingerprint = metadataSnapshot.FileHashes[file.FullName];
var existingRecord = manifest.Files.GetValueOrDefault(file.FullName); var existingRecord = manifest.Files.GetValueOrDefault(file.FullName);
var document = this.CreateFileDocument(context, dataSource, file, fingerprint); var document = this.CreateFileDocument(context, dataSource, file, fingerprint, null);
var reportBlockProgress = progress.BeginDocument(document); var reportBlockProgress = progress.BeginDocument(document);
var startedAtUtc = DateTimeOffset.UtcNow; var startedAtUtc = DateTimeOffset.UtcNow;
@@ -196,6 +209,14 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
ShortHash(fingerprint), ShortHash(fingerprint),
progress.DoneDocuments + 1, progress.DoneDocuments + 1,
totalFiles); totalFiles);
//
// Audio and video hold no text until they are transcribed. The document then reads
// its transcript instead, and its row knows which transcript that is:
//
if (PersistentMediaTranscript.IsMediaPath(file.FullName))
document = this.CreateFileDocument(context, dataSource, file, fingerprint, await this.ReadOrCreateTranscriptAsync(dataSource, file.FullName, token));
var chunkCount = await context.IndexDocumentAsync(document, reportBlockProgress, token); var chunkCount = await context.IndexDocumentAsync(document, reportBlockProgress, token);
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var fingerprintAfterEmbedding = BuildFileMetadataHash(file); var fingerprintAfterEmbedding = BuildFileMetadataHash(file);
@@ -203,6 +224,11 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
throw new IOException(string.Format(TB("The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run."), file.FullName)); throw new IOException(string.Format(TB("The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run."), file.FullName));
await progress.RecordDocumentIndexedAsync(document, chunkCount, existingRecord is null, token); await progress.RecordDocumentIndexedAsync(document, chunkCount, existingRecord is null, token);
// The recording changed, so its old transcript is no longer what it says:
if (existingRecord?.TranscriptKey is { } previousTranscriptKey && !string.Equals(previousTranscriptKey, document.State.TranscriptKey, StringComparison.Ordinal))
ForgetTranscript(DataSourceTranscriptStore.TryForDataSource(dataSource.Id), manifest, previousTranscriptKey, logger);
logger.LogInformation( logger.LogInformation(
"Embedded file '{FilePath}' for data source '{DataSourceName}' ({DataSourceId}) successfully. Chunks={ChunkCount}, DurationMs={DurationMs}.", "Embedded file '{FilePath}' for data source '{DataSourceName}' ({DataSourceId}) successfully. Chunks={ChunkCount}, DurationMs={DurationMs}.",
file.FullName, file.FullName,
@@ -246,6 +272,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
} }
await progress.CompleteRunAsync(metadataSnapshot.SourceHash, "data source embedding run finished", token); await progress.CompleteRunAsync(metadataSnapshot.SourceHash, "data source embedding run finished", token);
this.CleanUpTranscripts(context, progress);
logger.LogInformation( logger.LogInformation(
"Finished background embeddings for data source '{DataSourceName}' ({DataSourceId}). RefreshMode={RefreshMode}, Embedded={EmbeddedFiles}, New={NewFiles}, Changed={ChangedFiles}, Skipped={SkippedFiles}, PermanentlySkipped={PermanentlySkippedFiles}, RemovedMissing={RemovedMissingFiles}, Failed={FailedFiles}, Total={TotalFiles}, SourceHashPrefix={SourceHashPrefix}.", "Finished background embeddings for data source '{DataSourceName}' ({DataSourceId}). RefreshMode={RefreshMode}, Embedded={EmbeddedFiles}, New={NewFiles}, Changed={ChangedFiles}, Skipped={SkippedFiles}, PermanentlySkipped={PermanentlySkippedFiles}, RemovedMissing={RemovedMissingFiles}, Failed={FailedFiles}, Total={TotalFiles}, SourceHashPrefix={SourceHashPrefix}.",
dataSource.Name, dataSource.Name,
@@ -355,6 +382,79 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return IndexedRunOutcome.DONE; return IndexedRunOutcome.DONE;
} }
/// <summary>
/// Deletes the transcripts which no file of the data source uses anymore, once a run got through all of it.
/// </summary>
/// <remarks>
/// Only a run without a failure knows every transcript which is still needed: a file which
/// failed may have its transcript already, and its next attempt must not pay for it again.
/// The transcript of a recording which is gone has to go as well, since it holds what was said.
/// </remarks>
/// <param name="context">The run.</param>
/// <param name="progress">How the run went.</param>
private void CleanUpTranscripts(IndexedRunContext context, DocumentRunProgress progress)
{
if (progress.FailedDocuments > 0)
return;
DeleteUnusedTranscripts(DataSourceTranscriptStore.TryForDataSource(context.DataSource.Id), context.Manifest, logger);
}
/// <summary>
/// Deletes every transcript of a data source which no indexed file uses, and the temporary files of interrupted writes.
/// </summary>
/// <param name="store">The transcripts of the data source, or null when there are none to clean up.</param>
/// <param name="manifest">What the index stores about the data source.</param>
/// <param name="logger">The logger.</param>
internal static void DeleteUnusedTranscripts(DataSourceTranscriptStore? store, DataSourceEmbeddingManifest manifest, ILogger logger)
{
if (store is null)
return;
try
{
var usedKeys = manifest.Files.Values
.Select(record => record.TranscriptKey)
.OfType<string>()
.ToHashSet(StringComparer.Ordinal);
var deleted = store.ListKeys().Count(key => !usedKeys.Contains(key) && store.Delete(key));
deleted += store.DeleteTemporaryFiles();
if (deleted > 0)
logger.LogInformation("Removed {DeletedTranscripts} transcript file(s) which no file of the data source uses anymore.", deleted);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not remove the transcripts which no file of the data source uses anymore. The next complete run tries again.");
}
}
/// <summary>
/// Deletes the transcript with the given key, unless another file of the data source still uses it.
/// </summary>
/// <remarks>
/// Two recordings with the same content share one transcript, cf. DataSourceTranscriptStore.
/// </remarks>
/// <param name="store">The transcripts of the data source, or null when there are none to clean up.</param>
/// <param name="manifest">What the index stores about the data source, without the file the transcript belonged to.</param>
/// <param name="key">The key of the transcript, or null when the file had none.</param>
/// <param name="logger">The logger.</param>
internal static void ForgetTranscript(DataSourceTranscriptStore? store, DataSourceEmbeddingManifest manifest, string? key, ILogger logger)
{
if (store is null || string.IsNullOrWhiteSpace(key) || manifest.Files.Values.Any(record => string.Equals(record.TranscriptKey, key, StringComparison.Ordinal)))
return;
try
{
if (store.Delete(key))
logger.LogInformation("Removed the transcript '{TranscriptKey}', since no file of the data source uses it anymore.", key);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not remove the transcript '{TranscriptKey}'. The next complete run tries again.", key);
}
}
private async Task<int> RemoveMissingFileEmbeddingsAsync(IndexedRunContext context, FileEnumerationResult inputFiles, CancellationToken token) private async Task<int> RemoveMissingFileEmbeddingsAsync(IndexedRunContext context, FileEnumerationResult inputFiles, CancellationToken token)
{ {
var manifest = context.Manifest; var manifest = context.Manifest;
@@ -363,7 +463,9 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
{ {
await context.DeleteDocumentPointsAsync(removedFilePath, token); await context.DeleteDocumentPointsAsync(removedFilePath, token);
await context.IndexStore.DeleteFileAsync(context.DataSource.Id, removedFilePath, token); await context.IndexStore.DeleteFileAsync(context.DataSource.Id, removedFilePath, token);
var removedTranscriptKey = manifest.Files.GetValueOrDefault(removedFilePath)?.TranscriptKey;
manifest.Files.Remove(removedFilePath); manifest.Files.Remove(removedFilePath);
ForgetTranscript(DataSourceTranscriptStore.TryForDataSource(context.DataSource.Id), manifest, removedTranscriptKey, logger);
removedFiles++; removedFiles++;
logger.LogInformation( logger.LogInformation(
"Removed stale embeddings for deleted file '{FilePath}' from data source '{DataSourceName}' ({DataSourceId}).", "Removed stale embeddings for deleted file '{FilePath}' from data source '{DataSourceName}' ({DataSourceId}).",
@@ -487,19 +589,123 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
: string.Join("; ", reasons); : string.Join("; ", reasons);
} }
private async IAsyncEnumerable<EmbeddingChunk> StreamEmbeddingChunksAsync(string filePath, IDataSource dataSource, EmbeddingProvider embeddingProvider, PromptInjectionReportingScope reportingScope, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token) private async IAsyncEnumerable<EmbeddingChunk> StreamEmbeddingChunksAsync(string filePath, MediaTranscriptSlot? transcript, IDataSource dataSource, EmbeddingProvider embeddingProvider, PromptInjectionReportingScope reportingScope, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
{ {
var options = DataSourceEmbeddingService.GetChunkingOptions(dataSource, embeddingProvider); var options = DataSourceEmbeddingService.GetChunkingOptions(dataSource, embeddingProvider);
var strategy = TextChunker.GetStrategyForFile(filePath);
var content = await ExtractedFileText.ReadAsync(rustService, filePath, embeddingProvider, null, reportingScope, token); //
// An audio or video file is read through its transcript. The runtime reads the transcript
// like every other document, so it passes the same prompt injection filter, and a notice
// names the media file rather than the transcript below the data directory:
//
var contentPath = transcript?.Path ?? filePath;
PromptInjectionSource? reportAs = transcript is null ? null : PromptInjectionSource.FileContent(filePath);
var strategy = TextChunker.GetStrategyForFile(contentPath);
var content = await ExtractedFileText.ReadAsync(rustService, contentPath, embeddingProvider, reportAs, reportingScope, token);
await foreach (var chunk in textChunker.SplitAsync(content, strategy, options, embeddingProvider, token)) await foreach (var chunk in textChunker.SplitAsync(content, strategy, options, embeddingProvider, token))
yield return chunk; yield return chunk;
} }
internal FileEnumerationResult GetInputFiles(IDataSource dataSource) /// <summary>
/// Returns the transcript of an audio or video file, transcribing the file only when the data source holds no transcript of its content yet.
/// </summary>
/// <remarks>
/// The provider is checked against the data source once more, right before the upload: the
/// settings may have changed since the files were listed. A transcript which exists is used
/// without any provider, since nothing leaves the computer for it.<br/><br/>
///
/// A transcription which cannot succeed for this file, such as one without text, is recorded
/// for good, so the file is not paid for again until it changes.
/// </remarks>
/// <param name="dataSource">The data source.</param>
/// <param name="mediaPath">The audio or video file.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>Where the transcript is.</returns>
private async Task<MediaTranscriptSlot> ReadOrCreateTranscriptAsync(IDataSource dataSource, string mediaPath, CancellationToken token)
{
var provider = this.GetMediaTranscriptionAvailability(dataSource).Provider;
var transcript = await PersistentMediaTranscript.ReadOrCreateAsync(DataSourceTranscriptStore.ForDataSource(dataSource.Id), mediaPath, provider, mediaTranscriptionService.TranscribeAsync, logger, token);
return GetTranscriptOrThrow(transcript, token);
}
/// <summary>
/// Returns the transcript, or throws what tells the run how to treat the media file without one.
/// </summary>
/// <remarks>
/// A FileExtractionException records the file for good, until it changes; any other exception
/// lets the next pass try again. A transcription without text, a file which the media pipeline
/// cannot read, and a file which the transcription provider refused would fail the same way
/// every time, and every attempt would cost money or another upload.
/// </remarks>
/// <param name="transcript">The outcome of the request for the transcript.</param>
/// <param name="token">The cancellation token of the run.</param>
/// <returns>Where the transcript is.</returns>
internal static MediaTranscriptSlot GetTranscriptOrThrow(PersistentMediaTranscriptResult transcript, CancellationToken token) => transcript.Status switch
{
PersistentMediaTranscriptStatus.READY => new(transcript.TranscriptPath, transcript.Key),
PersistentMediaTranscriptStatus.CANCELLED => throw new OperationCanceledException(token),
PersistentMediaTranscriptStatus.EMPTY or PersistentMediaTranscriptStatus.NO_AUDIBLE_SIGNAL => throw new FileExtractionException(FileExtractionErrorCode.NO_CONTENT, transcript.UserMessage),
PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED when IsPermanentMediaFailure(transcript.ErrorCode) => throw new FileExtractionException(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED, transcript.UserMessage),
PersistentMediaTranscriptStatus.REJECTED => throw new FileExtractionException(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED, transcript.UserMessage),
_ => throw new IOException(transcript.UserMessage),
};
/// <summary>
/// Which transcription provider may hear the audio and video files of a data source right now.
/// </summary>
/// <remarks>
/// The transcription provider receives the content of the data source just like the embedding
/// provider, so it has to meet the confidence level of the data source as well, cf.
/// DataSourceEmbeddingService.AllowsTranscription. A data source without a confidence level
/// of its own is never sent anywhere.
/// </remarks>
/// <param name="dataSource">The data source.</param>
/// <returns>The provider, or why there is none.</returns>
private MediaTranscriptionAvailability GetMediaTranscriptionAvailability(IDataSource dataSource)
{
var provider = mediaTranscriptionService.GetUsableProvider();
if (provider is null)
return MediaTranscriptionAvailability.MISSING;
var providerConfidence = provider.GetConfidenceLevel(settingsManager);
if (dataSource is IIndexedDataSource indexedDataSource && DataSourceEmbeddingService.AllowsTranscription(indexedDataSource, providerConfidence))
return MediaTranscriptionAvailability.Allowed(provider);
logger.LogInformation(
"The transcription provider '{ProviderName}' may not hear the audio and video files of data source '{DataSourceName}' ({DataSourceId}): the data source asks for the confidence level {RequiredConfidence}, while the provider has {ProviderConfidence}.",
provider.Name,
dataSource.Name,
dataSource.Id,
(dataSource as IIndexedDataSource)?.ConfidenceLevel,
providerConfidence);
return MediaTranscriptionAvailability.NOT_ALLOWED;
}
private static bool IsPermanentMediaFailure(MediaJobErrorCode? code) => code is
MediaJobErrorCode.UNKNOWN_FORMAT
or MediaJobErrorCode.UNSAFE_FILE
or MediaJobErrorCode.NOT_MEDIA
or MediaJobErrorCode.UNSUPPORTED_CONTAINER
or MediaJobErrorCode.NO_AUDIO_TRACK
or MediaJobErrorCode.UNSUPPORTED_CODEC
or MediaJobErrorCode.INVALID_AUDIO_PARAMETERS
or MediaJobErrorCode.INVALID_OPUS_HEADER
or MediaJobErrorCode.UNSUPPORTED_OPUS_MAPPING
or MediaJobErrorCode.DAMAGED_CONTAINER;
/// <summary>
/// Lists the files of a data source and decides what a run does with each of them.
/// </summary>
/// <param name="dataSource">The data source.</param>
/// <param name="manifest">What the index stores about the data source, or null when it does not matter.</param>
/// <param name="mediaTranscription">Which transcription provider may hear its audio and video files, or null to ask the settings once a media file turns up.</param>
/// <returns>The files, and those which were left out.</returns>
internal FileEnumerationResult GetInputFiles(IDataSource dataSource, DataSourceEmbeddingManifest? manifest = null, MediaTranscriptionAvailability? mediaTranscription = null)
{ {
var result = new FileEnumerationResult(); var result = new FileEnumerationResult();
var mediaTranscriptionAvailability = new Lazy<MediaTranscriptionAvailability>(() => mediaTranscription ?? this.GetMediaTranscriptionAvailability(dataSource));
switch (dataSource) switch (dataSource)
{ {
@@ -507,13 +713,13 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
var file = new FileInfo(localFile.FilePath); var file = new FileInfo(localFile.FilePath);
// No size limit: the user chose this very file, cf. FolderFileSelection.IsTooLarge. // No size limit: the user chose this very file, cf. FolderFileSelection.IsTooLarge.
if (this.AddInputFile(file, null, result) is RagFileIndexingDecision.EXCLUDED) if (this.AddInputFile(file, new InputFileRules(null, manifest, mediaTranscriptionAvailability), result) is RagFileIndexingDecision.EXCLUDED)
logger.LogDebug("Skipping excluded file '{FilePath}' while indexing.", file.FullName); logger.LogDebug("Skipping excluded file '{FilePath}' while indexing.", file.FullName);
return result; return result;
case DataSourceLocalDirectory localDirectory when Directory.Exists(localDirectory.Path): case DataSourceLocalDirectory localDirectory when Directory.Exists(localDirectory.Path):
this.EnumerateAccessibleFiles(localDirectory, result); this.EnumerateAccessibleFiles(localDirectory, manifest, mediaTranscriptionAvailability, result);
if (result.UnreadableDirectories.Contains(localDirectory.Path)) if (result.UnreadableDirectories.Contains(localDirectory.Path))
result.UnreachableRootReason = string.Format(TB("The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index."), localDirectory.Path); result.UnreachableRootReason = string.Format(TB("The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index."), localDirectory.Path);
@@ -537,9 +743,10 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return result; return result;
} }
private void EnumerateAccessibleFiles(DataSourceLocalDirectory dataSource, FileEnumerationResult result) private void EnumerateAccessibleFiles(DataSourceLocalDirectory dataSource, DataSourceEmbeddingManifest? manifest, Lazy<MediaTranscriptionAvailability> mediaTranscription, FileEnumerationResult result)
{ {
var selection = FolderFileSelection.Of(dataSource); var selection = FolderFileSelection.Of(dataSource);
var rules = new InputFileRules(selection, manifest, mediaTranscription);
// //
// Every log line travels to the runtime as a request of its own. A folder holding many // Every log line travels to the runtime as a request of its own. A folder holding many
@@ -580,7 +787,17 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
continue; continue;
} }
if (this.AddInputFile(fileInfo, selection, result) is not RagFileIndexingDecision.EXCLUDED) //
// The transcript which the Batch Processing assistant wrote next to a selected media
// file holds the text the data source gets from the media file itself:
//
if (MediaSidecarTranscripts.IsCoveredArtifact(fileInfo.FullName, selection))
{
logger.LogDebug("Skipping the transcript '{FilePath}', because its media file is indexed as the document.", fileInfo.FullName);
continue;
}
if (this.AddInputFile(fileInfo, rules, result) is not RagFileIndexingDecision.EXCLUDED)
continue; continue;
excludedFiles++; excludedFiles++;
@@ -593,11 +810,11 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
} }
/// <param name="file">The file.</param> /// <param name="file">The file.</param>
/// <param name="selection">The files of a folder data source, or null for a single file, which has no size limit.</param> /// <param name="rules">What decides about the files of the data source.</param>
/// <param name="result">Where the file goes, if anywhere.</param> /// <param name="result">Where the file goes, if anywhere.</param>
private RagFileIndexingDecision AddInputFile(FileInfo file, FolderFileSelection? selection, FileEnumerationResult result) private RagFileIndexingDecision AddInputFile(FileInfo file, InputFileRules rules, FileEnumerationResult result)
{ {
var decision = this.GetRagFileIndexingDecision(file, selection); var decision = this.GetRagFileIndexingDecision(file, rules);
switch (decision) switch (decision)
{ {
case RagFileIndexingDecision.INDEXABLE: case RagFileIndexingDecision.INDEXABLE:
@@ -607,8 +824,16 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
case RagFileIndexingDecision.EXCLUDED: case RagFileIndexingDecision.EXCLUDED:
break; break;
case RagFileIndexingDecision.MEDIA_NOT_SUPPORTED: case RagFileIndexingDecision.IMAGE_NOT_SUPPORTED:
result.AddIgnoredFile(file.FullName, DataSourceEmbeddingIgnoreReason.MEDIA_NOT_SUPPORTED); result.AddIgnoredFile(file.FullName, DataSourceEmbeddingIgnoreReason.IMAGE_NOT_SUPPORTED);
break;
case RagFileIndexingDecision.TRANSCRIPTION_PROVIDER_MISSING:
result.AddIgnoredFile(file.FullName, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING);
break;
case RagFileIndexingDecision.TRANSCRIPTION_PROVIDER_NOT_ALLOWED:
result.AddIgnoredFile(file.FullName, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED);
break; break;
case RagFileIndexingDecision.TOO_LARGE: case RagFileIndexingDecision.TOO_LARGE:
@@ -630,24 +855,24 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE); return FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE);
} }
private RagFileIndexingDecision GetRagFileIndexingDecision(FileInfo file, FolderFileSelection? selection) private RagFileIndexingDecision GetRagFileIndexingDecision(FileInfo file, InputFileRules rules)
{ {
if (this.IsSkippedRagFile(file)) if (this.IsSkippedRagFile(file))
return RagFileIndexingDecision.EXCLUDED; return RagFileIndexingDecision.EXCLUDED;
if (!IMAGE_EMBEDDING_ENABLED && this.IsImageFilePath(file.FullName)) if (!IMAGE_EMBEDDING_ENABLED && this.IsImageFilePath(file.FullName))
return RagFileIndexingDecision.MEDIA_NOT_SUPPORTED; return RagFileIndexingDecision.IMAGE_NOT_SUPPORTED;
// //
// Audio and video hold no text until they are transcribed, and data sources do not // Before any transcription: a video of several gigabytes is left out like every other
// transcribe them yet: // file above the limit, rather than sent to the transcription provider.
// //
if (FileTypes.IsAllowedPath(file.FullName, FileTypes.AUDIO, FileTypes.VIDEO)) if (rules.Selection?.IsTooLarge(file) is true)
return RagFileIndexingDecision.MEDIA_NOT_SUPPORTED;
if (selection?.IsTooLarge(file) is true)
return RagFileIndexingDecision.TOO_LARGE; return RagFileIndexingDecision.TOO_LARGE;
if (PersistentMediaTranscript.IsMediaPath(file.FullName))
return GetMediaIndexingDecision(file, rules);
// A directory source reaches this point only after its user-configured pattern selected // A directory source reaches this point only after its user-configured pattern selected
// the file. A single-file source was validated before it was stored. Extraction is the // the file. A single-file source was validated before it was stored. Extraction is the
// authoritative capability check and records an unreadable selected file through the // authoritative capability check and records an unreadable selected file through the
@@ -655,6 +880,39 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return RagFileIndexingDecision.INDEXABLE; return RagFileIndexingDecision.INDEXABLE;
} }
/// <summary>
/// Decides about an audio or video file, which holds no text until it is transcribed.
/// </summary>
/// <remarks>
/// A media file which the index holds unchanged needs no transcript in this run, nor does one
/// which failed for good before: neither is read. A missing transcription provider, or one which
/// the data source no longer allows, therefore takes nothing out of the index. Every other media
/// file needs a provider which may hear it, unless the data source has its transcript already;
/// that is only known once the file is read, so such a file waits for the provider as well.
/// </remarks>
/// <param name="file">The audio or video file.</param>
/// <param name="rules">What decides about the files of the data source.</param>
/// <returns>INDEXABLE, or why the file is left out.</returns>
private static RagFileIndexingDecision GetMediaIndexingDecision(FileInfo file, InputFileRules rules)
{
if (rules.Manifest is { } manifest)
{
var fingerprint = BuildFileMetadataHash(file);
if (manifest.Files.TryGetValue(file.FullName, out var existingRecord) && string.Equals(existingRecord.Fingerprint, fingerprint, StringComparison.Ordinal))
return RagFileIndexingDecision.INDEXABLE;
if (manifest.PermanentFailures.TryGetValue(file.FullName, out var permanentFailure) && string.Equals(permanentFailure.Fingerprint, fingerprint, StringComparison.Ordinal))
return RagFileIndexingDecision.INDEXABLE;
}
return rules.MediaTranscription.Value.IgnoreReason switch
{
null => RagFileIndexingDecision.INDEXABLE,
DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED => RagFileIndexingDecision.TRANSCRIPTION_PROVIDER_NOT_ALLOWED,
_ => RagFileIndexingDecision.TRANSCRIPTION_PROVIDER_MISSING,
};
}
private bool IsSkippedRagFile(FileInfo file) private bool IsSkippedRagFile(FileInfo file)
{ {
if (FileExclusions.IsAlwaysExcludedFile(file.Name)) if (FileExclusions.IsAlwaysExcludedFile(file.Name))
@@ -731,7 +989,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return BuildMetadataHash(parts); return BuildMetadataHash(parts);
} }
private static string BuildFileMetadataHash(FileInfo file) internal static string BuildFileMetadataHash(FileInfo file)
{ {
file.Refresh(); file.Refresh();
if (!file.Exists) if (!file.Exists)
@@ -774,12 +1032,13 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
/// <param name="dataSource">The data source the file belongs to.</param> /// <param name="dataSource">The data source the file belongs to.</param>
/// <param name="file">The file.</param> /// <param name="file">The file.</param>
/// <param name="fingerprint">The fingerprint of the file as it is about to be read.</param> /// <param name="fingerprint">The fingerprint of the file as it is about to be read.</param>
/// <param name="transcript">The transcript an audio or video file is read through, or null for every other file.</param>
/// <returns>The document.</returns> /// <returns>The document.</returns>
private EmbeddingDocument CreateFileDocument(IndexedRunContext context, IDataSource dataSource, FileInfo file, string fingerprint) => new( private EmbeddingDocument CreateFileDocument(IndexedRunContext context, IDataSource dataSource, FileInfo file, string fingerprint, MediaTranscriptSlot? transcript) => new(
file.FullName, file.FullName,
this.CreateEmbeddingStateFile(dataSource, file, fingerprint, 0, DateTimeOffset.UtcNow), this.CreateEmbeddingStateFile(dataSource, file, fingerprint, 0, DateTimeOffset.UtcNow) with { TranscriptKey = transcript?.Key },
file.Name, file.Name,
token => this.StreamEmbeddingChunksAsync(file.FullName, dataSource, context.EmbeddingProvider, context.PromptInjectionScope, token)); token => this.StreamEmbeddingChunksAsync(file.FullName, transcript, dataSource, context.EmbeddingProvider, context.PromptInjectionScope, token));
private EmbeddingStateFile CreateEmbeddingStateFile(IDataSource dataSource, FileInfo file, string fingerprint, int chunkCount, DateTimeOffset embeddedAtUtc) private EmbeddingStateFile CreateEmbeddingStateFile(IDataSource dataSource, FileInfo file, string fingerprint, int chunkCount, DateTimeOffset embeddedAtUtc)
{ {
@@ -0,0 +1,28 @@
using AIStudio.Settings;
namespace AIStudio.Tools.Services.Indexing;
/// <summary>
/// Which transcription provider may hear the audio and video files of a data source.
/// </summary>
/// <param name="Provider">The provider, or null when none may.</param>
/// <param name="IgnoreReason">Why audio and video files without a transcript are left out when no provider may hear them; null when there is a provider.</param>
internal sealed record MediaTranscriptionAvailability(TranscriptionProvider? Provider, DataSourceEmbeddingIgnoreReason? IgnoreReason)
{
/// <summary>
/// No usable transcription provider is configured.
/// </summary>
public static readonly MediaTranscriptionAvailability MISSING = new(null, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING);
/// <summary>
/// The configured transcription provider falls short of the confidence level of the data source.
/// </summary>
public static readonly MediaTranscriptionAvailability NOT_ALLOWED = new(null, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED);
/// <summary>
/// The given provider may hear the media files.
/// </summary>
/// <param name="provider">The provider.</param>
/// <returns>The availability.</returns>
public static MediaTranscriptionAvailability Allowed(TranscriptionProvider provider) => new(provider, null);
}
@@ -63,7 +63,7 @@ public sealed class MediaTranscriptionService(
} }
/// <summary>Gets whether the currently configured transcription provider can be used.</summary> /// <summary>Gets whether the currently configured transcription provider can be used.</summary>
public bool HasUsableTranscriptionProvider => this.ResolveProvider() is not null; public bool HasUsableTranscriptionProvider => this.GetUsableProvider() is not null;
/// <summary>Gets the last retained state for one owner.</summary> /// <summary>Gets the last retained state for one owner.</summary>
public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner) public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner)
@@ -411,14 +411,31 @@ public sealed class MediaTranscriptionService(
/// <param name="mediaPath">Audio or video file path.</param> /// <param name="mediaPath">Audio or video file path.</param>
/// <param name="token">Caller cancellation token.</param> /// <param name="token">Caller cancellation token.</param>
/// <returns>A typed terminal result.</returns> /// <returns>A typed terminal result.</returns>
public async Task<MediaTranscriptionResult> TranscribeAsync(string mediaPath, CancellationToken token = default) public Task<MediaTranscriptionResult> TranscribeAsync(string mediaPath, CancellationToken token = default) => this.TranscribeWithoutImportAsync(mediaPath, null, token);
/// <summary>
/// Transcribes an audio or video file with exactly the given provider, without starting a visible import operation.
/// </summary>
/// <remarks>
/// For callers which checked the provider against rules of their own, such as the confidence
/// level of a data source. The provider is used as given and not resolved from the settings
/// again, so a change of the settings between that check and the upload cannot send the media
/// to another provider.
/// </remarks>
/// <param name="mediaPath">Audio or video file path.</param>
/// <param name="provider">The transcription provider the caller checked.</param>
/// <param name="token">Caller cancellation token.</param>
/// <returns>A typed terminal result.</returns>
public Task<MediaTranscriptionResult> TranscribeAsync(string mediaPath, TranscriptionProvider provider, CancellationToken token = default) => this.TranscribeWithoutImportAsync(mediaPath, provider, token);
private async Task<MediaTranscriptionResult> TranscribeWithoutImportAsync(string mediaPath, TranscriptionProvider? provider, CancellationToken token)
{ {
this.ThrowIfDisposed(); this.ThrowIfDisposed();
var operation = this.CreateOperation(null, token); var operation = this.CreateOperation(null, token);
try try
{ {
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: false); return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: false, provider);
} }
finally finally
{ {
@@ -458,8 +475,9 @@ public sealed class MediaTranscriptionService(
/// <param name="mediaPath">Source media path.</param> /// <param name="mediaPath">Source media path.</param>
/// <param name="operation">Operation-specific cancellation and Rust-job state.</param> /// <param name="operation">Operation-specific cancellation and Rust-job state.</param>
/// <param name="updateImportState">Whether progress belongs to the visible import lane.</param> /// <param name="updateImportState">Whether progress belongs to the visible import lane.</param>
/// <param name="checkedProvider">The provider a caller checked itself, or null for the configured one.</param>
/// <returns>A typed terminal result.</returns> /// <returns>A typed terminal result.</returns>
private async Task<MediaTranscriptionResult> TranscribeCoreAsync(string mediaPath, MediaOperation operation, bool updateImportState) private async Task<MediaTranscriptionResult> TranscribeCoreAsync(string mediaPath, MediaOperation operation, bool updateImportState, TranscriptionProvider? checkedProvider = null)
{ {
var normalizedPath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", $"{operation.Id:N}.webm"); var normalizedPath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", $"{operation.Id:N}.webm");
Directory.CreateDirectory(Path.GetDirectoryName(normalizedPath)!); Directory.CreateDirectory(Path.GetDirectoryName(normalizedPath)!);
@@ -497,7 +515,7 @@ public sealed class MediaTranscriptionService(
return MediaTranscriptionResult.NoAudibleSignal(TB("The audio track contains no audible signal, so there is nothing to transcribe.")); return MediaTranscriptionResult.NoAudibleSignal(TB("The audio track contains no audible signal, so there is nothing to transcribe."));
} }
var providerSettings = this.ResolveProvider(); var providerSettings = checkedProvider ?? this.GetUsableProvider();
if (providerSettings is null) if (providerSettings is null)
return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured.")); return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured."));
@@ -530,16 +548,20 @@ public sealed class MediaTranscriptionService(
"The transcription provider failed for '{FileName}' (operation {OperationId}): {Diagnostic}", "The transcription provider failed for '{FileName}' (operation {OperationId}): {Diagnostic}",
fileName, fileName,
operation.Id, operation.Id,
providerResult.ErrorMessage); string.IsNullOrWhiteSpace(providerResult.ErrorMessage) ? "<empty>" : providerResult.ErrorMessage);
// //
// When the provider told us why it failed, the user gets to read it. Only that // When the provider told us why it failed, the user gets to read it. Only that
// message says whether to wait, to check the API key, or to ask the provider for // message says whether to wait, to check the API key, or to ask the provider for
// a format it can read: // a format it can read:
// //
return MediaTranscriptionResult.Failed(string.IsNullOrWhiteSpace(providerResult.ErrorMessage) var failureMessage = string.IsNullOrWhiteSpace(providerResult.ErrorMessage)
? TB("The transcription provider could not transcribe the media file.") ? TB("The transcription provider could not transcribe the media file.")
: providerResult.ErrorMessage); : providerResult.ErrorMessage;
return providerResult.FileRejected
? MediaTranscriptionResult.Rejected(failureMessage)
: MediaTranscriptionResult.Failed(failureMessage);
} }
return MediaTranscriptionResult.Succeeded(providerResult.Text.Trim()); return MediaTranscriptionResult.Succeeded(providerResult.Text.Trim());
@@ -719,7 +741,12 @@ public sealed class MediaTranscriptionService(
} }
/// <summary>Resolves the configured provider after confidence validation.</summary> /// <summary>Resolves the configured provider after confidence validation.</summary>
private TranscriptionProvider? ResolveProvider() /// <remarks>
/// Checks the global minimum confidence only. A caller with rules of its own, such as the
/// confidence level of a data source, checks the provider against them as well.
/// </remarks>
/// <returns>The configured provider, or null when none is configured or it falls short of the global minimum.</returns>
public TranscriptionProvider? GetUsableProvider()
{ {
var providerId = settingsManager.ConfigurationData.App.UseTranscriptionProvider; var providerId = settingsManager.ConfigurationData.App.UseTranscriptionProvider;
if (string.IsNullOrWhiteSpace(providerId)) if (string.IsNullOrWhiteSpace(providerId))
@@ -735,6 +762,19 @@ public sealed class MediaTranscriptionService(
: null; : null;
} }
/// <summary>
/// The configured provider, when it may also hear the audio and video files of the given data source.
/// </summary>
/// <param name="dataSource">The data source, with the confidence level it asks for.</param>
/// <returns>The provider, or null when none is configured or it falls short of the global minimum or of the data source.</returns>
public TranscriptionProvider? GetUsableProvider(IIndexedDataSource dataSource)
{
var provider = this.GetUsableProvider();
return provider is not null && DataSourceEmbeddingService.AllowsTranscription(dataSource, provider.GetConfidenceLevel(settingsManager))
? provider
: null;
}
/// <summary>Creates and registers operation-owned cancellation state.</summary> /// <summary>Creates and registers operation-owned cancellation state.</summary>
/// <param name="target">Optional visible media import target.</param> /// <param name="target">Optional visible media import target.</param>
/// <param name="token">Caller token linked to the operation.</param> /// <param name="token">Caller token linked to the operation.</param>
+29 -1
View File
@@ -65,7 +65,7 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME)
var transportTimestamp = FormatTransportTimestamp(now); var transportTimestamp = FormatTransportTimestamp(now);
var logLevel = logEntry.LogLevel.ToString(); var logLevel = logEntry.LogLevel.ToString();
var category = logEntry.Category; var category = logEntry.Category;
var exceptionMessage = logEntry.Exception?.Message; var exceptionMessage = DescribeException(logEntry.Exception);
var stackTrace = logEntry.Exception?.StackTrace; var stackTrace = logEntry.Exception?.StackTrace;
var colorCode = GetColorForLogLevel(logEntry.LogLevel); var colorCode = GetColorForLogLevel(logEntry.LogLevel);
@@ -108,6 +108,34 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME)
/// <returns>The time in the round-trip format.</returns> /// <returns>The time in the round-trip format.</returns>
public static string FormatTransportTimestamp(DateTimeOffset timestamp) => timestamp.ToString("O", CultureInfo.InvariantCulture); public static string FormatTransportTimestamp(DateTimeOffset timestamp) => timestamp.ToString("O", CultureInfo.InvariantCulture);
/// <summary>
/// Describes an exception by its message and the messages of its inner exceptions.
/// </summary>
/// <remarks>
/// The outer message alone often hides the cause. HttpClient, for example, reports a broken
/// upload as "Error while copying content to a stream.", and only an inner exception says
/// whether the other side closed the connection.
/// </remarks>
/// <param name="exception">The exception, or null when the log event has none.</param>
/// <returns>The messages, from the outer exception inwards, or null without an exception.</returns>
public static string? DescribeException(Exception? exception)
{
if (exception is null)
return null;
var description = exception.Message;
for (var innerException = exception.InnerException; innerException is not null; innerException = innerException.InnerException)
{
// An AggregateException names the messages of its inner exceptions already:
if (description.Contains(innerException.Message, StringComparison.Ordinal))
continue;
description = $"{description} ---> {innerException.Message}";
}
return description;
}
private static string GetColorForLogLevel(LogLevel logLevel) => logLevel switch private static string GetColorForLogLevel(LogLevel logLevel) => logLevel switch
{ {
LogLevel.Trace => ANSI_GRAY, LogLevel.Trace => ANSI_GRAY,
@@ -0,0 +1,23 @@
namespace AIStudio.Tools.Validation;
/// <summary>
/// What a data source sends to a provider which is not self-hosted.
/// </summary>
[Flags]
public enum CloudProcessing
{
/// <summary>
/// Nothing leaves the computer or the organization.
/// </summary>
NONE = 0,
/// <summary>
/// The content goes to an embedding provider in the cloud.
/// </summary>
EMBEDDING = 1,
/// <summary>
/// Audio and video files go to a transcription provider in the cloud.
/// </summary>
TRANSCRIPTION = 2,
}
@@ -0,0 +1,17 @@
namespace AIStudio.Tools.Validation;
public static class CloudProcessingExtensions
{
/// <summary>
/// Whether what the user acknowledged covers what the data source sends to the cloud now.
/// </summary>
/// <remarks>
/// An acknowledgment holds only for the providers it was given for. When the data source starts
/// to send its recordings to a transcription provider in the cloud, say since a new pattern
/// selects them, the user has to acknowledge that once more.
/// </remarks>
/// <param name="acknowledged">What the user acknowledged.</param>
/// <param name="current">What the data source sends to the cloud with its current settings.</param>
/// <returns>True when nothing is sent that the user did not acknowledge.</returns>
public static bool Covers(this CloudProcessing acknowledged, CloudProcessing current) => (current & ~acknowledged) is CloudProcessing.NONE;
}
@@ -31,6 +31,8 @@ public sealed class DataSourceValidation
public Func<bool> GetSelectedCloudEmbedding { get; init; } = () => false; public Func<bool> GetSelectedCloudEmbedding { get; init; } = () => false;
public Func<bool> GetSelectedCloudTranscription { get; init; } = () => false;
public Func<EmbeddingProvider?> GetSelectedEmbeddingProvider { get; init; } = () => null; public Func<EmbeddingProvider?> GetSelectedEmbeddingProvider { get; init; } = () => null;
public Func<ConfidenceLevel> GetConfidenceLevel { get; init; } = () => ConfidenceLevel.NONE; public Func<ConfidenceLevel> GetConfidenceLevel { get; init; } = () => ConfidenceLevel.NONE;
@@ -158,8 +160,8 @@ public sealed class DataSourceValidation
if(!File.Exists(filePath)) if(!File.Exists(filePath))
return TB("The file does not exist. Please select a valid file."); return TB("The file does not exist. Please select a valid file.");
if(!FileTypes.IsAllowedPath(filePath, FileTypes.DOCUMENT)) if(!FileTypes.IsAllowedPath(filePath, FileTypes.DOCUMENT, FileTypes.AUDIO, FileTypes.VIDEO))
return TB("The selected file is not a supported document or readable text file."); return TB("The selected file is not a supported document, readable text file, audio file, or video file.");
return null; return null;
} }
@@ -250,6 +252,9 @@ public sealed class DataSourceValidation
public string? ValidateUserAcknowledgedCloudEmbedding(bool value) public string? ValidateUserAcknowledgedCloudEmbedding(bool value)
{ {
if(this.GetSelectedCloudTranscription() && !value)
return TB("Please acknowledge that you are aware of the cloud transcription implications.");
if(this.GetSelectedCloudEmbedding() && !value) if(this.GetSelectedCloudEmbedding() && !value)
return TB("Please acknowledge that you are aware of the cloud embedding implications."); return TB("Please acknowledge that you are aware of the cloud embedding implications.");
@@ -10,6 +10,7 @@
- Added Requesty as a new LLM provider for chats, embeddings, and transcriptions. Requesty is an AI gateway from the United Kingdom that gives you models from many providers with a single API key. Because where your data is processed depends on the model you choose, you set its trust level yourself. Thanks, Thibault Jaigu (`Thibaultjaigu`), for this first contribution. - Added Requesty as a new LLM provider for chats, embeddings, and transcriptions. Requesty is an AI gateway from the United Kingdom that gives you models from many providers with a single API key. Because where your data is processed depends on the model you choose, you set its trust level yourself. Thanks, Thibault Jaigu (`Thibaultjaigu`), for this first contribution.
- Added [llmman](https://github.com/llmmanorg/llmman) as a new option for the models you host yourself. llmman runs open models on your own computer or server, and AI Studio can use them for chats, embeddings, and transcriptions. Thanks, Eric Curtin (`ericcurtin`), for this first contribution. - Added [llmman](https://github.com/llmmanorg/llmman) as a new option for the models you host yourself. llmman runs open models on your own computer or server, and AI Studio can use them for chats, embeddings, and transcriptions. Thanks, Eric Curtin (`ericcurtin`), for this first contribution.
- Added support for Mistral Large 4. - Added support for Mistral Large 4.
- Added transcription of audio and video files in local data sources. When you select such files, for example with the pattern `*.mp3` or `*`, AI Studio sends them to your transcription provider and adds the transcripts to the index. It keeps each transcript, so an unchanged recording is transcribed only once. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Added backups of all your data. Under Data Backup & Restore, on the information page and in the settings, you save your chats, settings, plugins, and the index of your data sources into a single file, for example, to take everything to a new computer. Restoring such a backup replaces your data when AI Studio restarts, or you add only its chats to the ones you have. API keys and passwords never go into a backup, and after a restore, AI Studio tells you where to enter them again. Thanks, Jens Erler (`j-erler`), for this contribution. - Added backups of all your data. Under Data Backup & Restore, on the information page and in the settings, you save your chats, settings, plugins, and the index of your data sources into a single file, for example, to take everything to a new computer. Restoring such a backup replaces your data when AI Studio restarts, or you add only its chats to the ones you have. API keys and passwords never go into a backup, and after a restore, AI Studio tells you where to enter them again. Thanks, Jens Erler (`j-erler`), for this contribution.
- Added organization-wide management for backups. IT departments can prevent users from backing up their data or from restoring a backup. A backup leaves out what the configurations of your organization bring along, such as their providers and plugins, so nothing appears twice after a restore. - Added organization-wide management for backups. IT departments can prevent users from backing up their data or from restoring a backup. A backup leaves out what the configurations of your organization bring along, such as their providers and plugins, so nothing appears twice after a restore.
- Added an overview of the data folders of AI Studio to the information page, for when you back up your computer with a tool of your own. It shows where the folders are and which of their contents belong into a backup. AI Studio now stores the locations of its own files, such as transcripts, relative to its data folder, so such a copy also works on a computer with another user name. - Added an overview of the data folders of AI Studio to the information page, for when you back up your computer with a tool of your own. It shows where the folders are and which of their contents belong into a backup. AI Studio now stores the locations of its own files, such as transcripts, relative to its data folder, so such a copy also works on a computer with another user name.
@@ -0,0 +1,78 @@
using System.Net;
using AIStudio.Provider;
namespace AIStudio.Tests.Provider;
/// <summary>
/// Checks which answers of a transcription provider refuse the media file itself.
/// </summary>
/// <remarks>
/// A data source records such a file for good, until it changes. An answer counted here by
/// mistake keeps a file out of the index; one missed here uploads the file again with every run.
/// </remarks>
[TestFixture]
public sealed class MediaFileRejectionTests
{
private const string OPENAI_DAMAGED_FILE = """
{
"error": {
"message": "Audio file might be corrupted or unsupported",
"type": "invalid_request_error",
"param": "file",
"code": "invalid_value"
}
}
""";
private const string OPENAI_UNKNOWN_MODEL = """
{
"error": {
"message": "Invalid model.",
"type": "invalid_request_error",
"param": "model",
"code": "invalid_value"
}
}
""";
private const string UNDECODABLE_FORMAT = """
{
"error": {
"message": "Could not decode the audio file.",
"param": "file"
}
}
""";
[Test]
public void AFileTheProviderCannotReadIsRefused()
{
Assert.That(BaseProvider.IsMediaFileRejection(HttpStatusCode.BadRequest, OPENAI_DAMAGED_FILE), Is.True);
}
[TestCase(HttpStatusCode.RequestEntityTooLarge)]
[TestCase(HttpStatusCode.UnsupportedMediaType)]
public void AFileTooLargeOrOfAnUnacceptedTypeIsRefused(HttpStatusCode statusCode)
{
Assert.That(BaseProvider.IsMediaFileRejection(statusCode, string.Empty), Is.True);
}
[Test]
public void AProviderWhichDecodesNoWebMRefusesNoSingleFile()
{
Assert.That(BaseProvider.IsMediaFileRejection(HttpStatusCode.BadRequest, UNDECODABLE_FORMAT), Is.False, "Every file fails the same way, and only another provider helps.");
}
[TestCase(HttpStatusCode.BadRequest, OPENAI_UNKNOWN_MODEL)]
[TestCase(HttpStatusCode.BadRequest, "Bad request")]
[TestCase(HttpStatusCode.BadRequest, "")]
[TestCase(HttpStatusCode.TooManyRequests, OPENAI_DAMAGED_FILE)]
[TestCase(HttpStatusCode.Unauthorized, "")]
[TestCase(HttpStatusCode.InternalServerError, "")]
[TestCase(HttpStatusCode.ServiceUnavailable, "")]
public void AFailureWhichMayPassRefusesNoFile(HttpStatusCode statusCode, string responseBody)
{
Assert.That(BaseProvider.IsMediaFileRejection(statusCode, responseBody), Is.False);
}
}
@@ -0,0 +1,74 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks which transcription providers may hear the audio and video files of a data source.
/// </summary>
/// <remarks>
/// The transcription provider receives the content of a data source just like the embedding
/// provider. A data source which asks for a high confidence level must not send its recordings to a
/// provider it would never let embed its documents.
/// </remarks>
[TestFixture]
public sealed class TranscriptionConfidenceTests
{
private static IEnumerable<IIndexedDataSource> DataSources()
{
foreach (var level in Enum.GetValues<ConfidenceLevel>())
{
yield return new DataSourceLocalDirectory { ConfidenceLevel = level };
yield return new DataSourceLocalFile { ConfidenceLevel = level };
yield return new DataSourceMailbox { ConfidenceLevel = level };
}
}
[TestCaseSource(nameof(DataSources))]
public void TranscriptionFollowsTheRuleOfTheEmbedding(IIndexedDataSource dataSource)
{
foreach (var providerLevel in Enum.GetValues<ConfidenceLevel>())
Assert.That(DataSourceEmbeddingService.AllowsTranscription(dataSource, providerLevel), Is.EqualTo(DataSourceEmbeddingService.AllowsEmbedding(dataSource, providerLevel)), $"A provider with the level {providerLevel} is treated differently for {dataSource.GetType().Name} with the level {dataSource.ConfidenceLevel}.");
}
[Test]
public void AFolderWhichAsksForAHighLevelKeepsItsRecordingsFromAMediumProvider()
{
var directory = new DataSourceLocalDirectory { ConfidenceLevel = ConfidenceLevel.HIGH };
Assert.Multiple(() =>
{
Assert.That(DataSourceEmbeddingService.AllowsTranscription(directory, ConfidenceLevel.MEDIUM), Is.False);
Assert.That(DataSourceEmbeddingService.AllowsTranscription(directory, ConfidenceLevel.HIGH), Is.True);
});
}
[Test]
public void AFolderWithoutALevelStaysOpenToEveryTranscriptionProvider()
{
var directory = new DataSourceLocalDirectory { ConfidenceLevel = ConfidenceLevel.NONE };
Assert.That(DataSourceEmbeddingService.AllowsTranscription(directory, ConfidenceLevel.UNTRUSTED), Is.True);
}
[Test]
public void ATranscriptionProviderHasTheLevelOfItsCompany()
{
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
settingsManager.ConfigurationData.Confidence.ConfidenceScheme = ConfidenceSchemes.CUSTOM;
settingsManager.ConfigurationData.Confidence.CustomConfidenceScheme[LLMProviders.OPEN_AI] = ConfidenceLevel.LOW;
var provider = new TranscriptionProvider { Id = Guid.NewGuid().ToString(), UsedLLMProvider = LLMProviders.OPEN_AI };
Assert.That(provider.GetConfidenceLevel(settingsManager), Is.EqualTo(ConfidenceLevel.LOW));
}
[Test]
public void NoTranscriptionProviderHasNoConfidence()
{
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
Assert.That(TranscriptionProvider.NONE.GetConfidenceLevel(settingsManager), Is.EqualTo(ConfidenceLevel.NONE));
}
}
@@ -25,6 +25,8 @@ public sealed class DataBackupWriterTests
private static readonly Guid CHAT_ID = Guid.Parse("8a1c4e7f-2b5d-4f9a-b3c6-0e2d4f6a8b1c"); private static readonly Guid CHAT_ID = Guid.Parse("8a1c4e7f-2b5d-4f9a-b3c6-0e2d4f6a8b1c");
private const string MEDIA_TRANSCRIPT = "mediaTranscripts/5c9e2a7b-1d3f-4b6e-8a0c-2e4f6b8d0a1c/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.md";
private string testDirectory = string.Empty; private string testDirectory = string.Empty;
private string DataDirectory => Path.Combine(this.testDirectory, "data"); private string DataDirectory => Path.Combine(this.testDirectory, "data");
@@ -53,10 +55,12 @@ public sealed class DataBackupWriterTests
this.WriteDataFile("media-staging/operation/transcript.md", "temporary"); this.WriteDataFile("media-staging/operation/transcript.md", "temporary");
this.WriteDataFile("databases/sqlite/rag-index.sqlite3", "the index while AI Studio writes to it"); this.WriteDataFile("databases/sqlite/rag-index.sqlite3", "the index while AI Studio writes to it");
this.WriteDataFile("databases/vector_database/stores/store_1/segments/segment", "the store while AI Studio writes to it"); this.WriteDataFile("databases/vector_database/stores/store_1/segments/segment", "the store while AI Studio writes to it");
this.WriteDataFile(MEDIA_TRANSCRIPT, "the transcript while AI Studio writes it");
WriteFile(Path.Combine(this.ConfigDirectory, "settings.json"), "the settings on the disk, with the objects of the organization"); WriteFile(Path.Combine(this.ConfigDirectory, "settings.json"), "the settings on the disk, with the objects of the organization");
WriteFile(Path.Combine(this.IndexCopiesDirectory, "databases", "sqlite", "rag-index.sqlite3"), "the copy of the index"); WriteFile(Path.Combine(this.IndexCopiesDirectory, "databases", "sqlite", "rag-index.sqlite3"), "the copy of the index");
WriteFile(Path.Combine(this.IndexCopiesDirectory, "databases", "vector_database", "stores", "store_1", "segments", "segment"), "the copy of the store"); WriteFile(Path.Combine(this.IndexCopiesDirectory, "databases", "vector_database", "stores", "store_1", "segments", "segment"), "the copy of the store");
WriteFile(Path.Combine(this.IndexCopiesDirectory, MEDIA_TRANSCRIPT), "the copy of the transcript");
} }
[TearDown] [TearDown]
@@ -84,6 +88,7 @@ public sealed class DataBackupWriterTests
"data/visualBriefings/briefing/manifest.json", "data/visualBriefings/briefing/manifest.json",
"data/databases/sqlite/rag-index.sqlite3", "data/databases/sqlite/rag-index.sqlite3",
"data/databases/vector_database/stores/store_1/segments/segment", "data/databases/vector_database/stores/store_1/segments/segment",
$"data/{MEDIA_TRANSCRIPT}",
DataBackupFormat.MANIFEST_ENTRY_NAME, DataBackupFormat.MANIFEST_ENTRY_NAME,
})); }));
@@ -92,6 +97,7 @@ public sealed class DataBackupWriterTests
Assert.That(archive["config/settings.json"], Is.EqualTo(SETTINGS_JSON), "The settings come from memory, without the objects of configuration plugins."); Assert.That(archive["config/settings.json"], Is.EqualTo(SETTINGS_JSON), "The settings come from memory, without the objects of configuration plugins.");
Assert.That(archive["data/databases/sqlite/rag-index.sqlite3"], Is.EqualTo("the copy of the index")); Assert.That(archive["data/databases/sqlite/rag-index.sqlite3"], Is.EqualTo("the copy of the index"));
Assert.That(archive["data/databases/vector_database/stores/store_1/segments/segment"], Is.EqualTo("the copy of the store")); Assert.That(archive["data/databases/vector_database/stores/store_1/segments/segment"], Is.EqualTo("the copy of the store"));
Assert.That(archive[$"data/{MEDIA_TRANSCRIPT}"], Is.EqualTo("the copy of the transcript"), "The transcripts are copied together with the index they belong to.");
}); });
} }
@@ -101,6 +107,7 @@ public sealed class DataBackupWriterTests
var archive = await this.WriteBackupAsync(string.Empty); var archive = await this.WriteBackupAsync(string.Empty);
Assert.That(archive.Keys.Where(name => name.StartsWith("data/databases/", StringComparison.Ordinal)), Is.Empty, "The indexes in the data folder change while AI Studio runs."); Assert.That(archive.Keys.Where(name => name.StartsWith("data/databases/", StringComparison.Ordinal)), Is.Empty, "The indexes in the data folder change while AI Studio runs.");
Assert.That(archive.Keys.Where(name => name.StartsWith("data/mediaTranscripts/", StringComparison.Ordinal)), Is.Empty, "The transcripts belong to the indexes and come only together with them.");
} }
[Test] [Test]
@@ -25,14 +25,33 @@ public sealed class DataSourceEmbeddingHealthTests
Assert.That(status.Health, Is.EqualTo(DataSourceEmbeddingHealth.WARNING)); Assert.That(status.Health, Is.EqualTo(DataSourceEmbeddingHealth.WARNING));
} }
[Test] [TestCase(DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING)]
public void AnIgnoredMediaFileKeepsAUsableDataSourceHealthy() [TestCase(DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED)]
public void ATranscriptionProviderWarningRemainsActionableWhenOtherFilesWereIndexed(DataSourceEmbeddingIgnoreReason reason)
{ {
var status = Status(indexedDocuments: 1, totalDocuments: 2) with var status = Status(indexedDocuments: 1, totalDocuments: 2) with
{ {
IgnoredFiles = IgnoredFiles =
[ [
new DataSourceEmbeddingIgnoredFile("/data/interview.mp3", DataSourceEmbeddingIgnoreReason.MEDIA_NOT_SUPPORTED), new DataSourceEmbeddingIgnoredFile("/data/interview.mp3", reason),
],
};
Assert.Multiple(() =>
{
Assert.That(status.Health, Is.EqualTo(DataSourceEmbeddingHealth.WARNING));
Assert.That(status.NeedsTranscriptionProvider, Is.True, "The embedding page offers the way to the settings only then.");
});
}
[Test]
public void AnIgnoredImageKeepsAUsableDataSourceHealthy()
{
var status = Status(indexedDocuments: 1, totalDocuments: 2) with
{
IgnoredFiles =
[
new DataSourceEmbeddingIgnoredFile("/data/photo.png", DataSourceEmbeddingIgnoreReason.IMAGE_NOT_SUPPORTED),
], ],
}; };
@@ -40,6 +40,27 @@ public sealed class IndexStoreManifestTests
}); });
} }
[Test]
public async Task TheManifestKnowsWhichTranscriptBelongsToAMediaFile()
{
await using var store = await TemporaryIndexStore.CreateAsync();
await store.Client.UpsertDataSourceAsync(DATA_SOURCE_ID, "LOCAL_DIRECTORY", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
var recording = Path.Combine(Path.GetTempPath(), "test-data", "meeting.mp3");
var document = Path.Combine(Path.GetTempPath(), "test-data", "report.pdf");
var transcriptKey = new string('f', 64);
await store.Client.UpsertFileAsync(DATA_SOURCE_ID, Row(recording, 2) with { TranscriptKey = transcriptKey }, TOKEN);
await store.Client.UpsertFileAsync(DATA_SOURCE_ID, Row(document, 4), TOKEN);
var manifest = await store.Client.GetManifestAsync(DATA_SOURCE_ID, TOKEN);
Assert.Multiple(() =>
{
Assert.That(manifest.Files[recording].TranscriptKey, Is.EqualTo(transcriptKey), "Without the key, AI Studio cannot remove the transcript once the recording is gone.");
Assert.That(manifest.Files[document].TranscriptKey, Is.Null);
});
}
private static EmbeddingStateFile Row(string filePath, int chunkCount) private static EmbeddingStateFile Row(string filePath, int chunkCount)
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
+32 -2
View File
@@ -64,6 +64,27 @@ public sealed class DocumentRunProgressTests
}); });
} }
[Test]
public async Task APermanentMediaFailureKeepsItsExplanation()
{
var (progress, statuses, manifest) = CreateProgress(totalDocuments: 1);
var document = Document(Path.Combine(Path.GetTempPath(), "test-data", "recording.webm"));
await progress.RecordDocumentFailureAsync(
document,
new FileExtractionException(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED, "The media file is damaged or its format could not be identified."),
CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(statuses[^1].Failures, Has.Count.EqualTo(1));
Assert.That(statuses[^1].Failures[0].ExtractionCode, Is.EqualTo(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED));
Assert.That(statuses[^1].Failures[0].Reason, Does.Contain("The media file is damaged or its format could not be identified."));
Assert.That(manifest.PermanentFailures[document.State.AbsolutePath].Code, Is.EqualTo(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED));
Assert.That(manifest.PermanentFailures[document.State.AbsolutePath].Message, Is.EqualTo(statuses[^1].Failures[0].Reason));
});
}
[Test] [Test]
public async Task ARunWhichOnlySkippedDocumentsForGoodIsCompleted() public async Task ARunWhichOnlySkippedDocumentsForGoodIsCompleted()
{ {
@@ -379,8 +400,8 @@ public sealed class DocumentRunProgressTests
var testDataPath = Path.Combine(Path.GetTempPath(), "test-data"); var testDataPath = Path.Combine(Path.GetTempPath(), "test-data");
var ignoredFiles = new[] var ignoredFiles = new[]
{ {
new DataSourceEmbeddingIgnoredFile(Path.Combine(testDataPath, "recording.mp3"), DataSourceEmbeddingIgnoreReason.MEDIA_NOT_SUPPORTED), new DataSourceEmbeddingIgnoredFile(Path.Combine(testDataPath, "recording.mp3"), DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING),
new DataSourceEmbeddingIgnoredFile(Path.Combine(testDataPath, "diagram.png"), DataSourceEmbeddingIgnoreReason.MEDIA_NOT_SUPPORTED), new DataSourceEmbeddingIgnoredFile(Path.Combine(testDataPath, "diagram.png"), DataSourceEmbeddingIgnoreReason.IMAGE_NOT_SUPPORTED),
}; };
var filePatternExclusions = new[] { new DataSourceEmbeddingFilePatternGroup(".pdf", 42) }; var filePatternExclusions = new[] { new DataSourceEmbeddingFilePatternGroup(".pdf", 42) };
var excludedFolders = new[] { new DataSourceEmbeddingExcludedFolderGroup("node_modules", 3) }; var excludedFolders = new[] { new DataSourceEmbeddingExcludedFolderGroup("node_modules", 3) };
@@ -432,6 +453,15 @@ public sealed class DocumentRunProgressTests
Assert.That(statuses[^1].Failures[0].DisplayName, Is.EqualTo("report.pdf")); Assert.That(statuses[^1].Failures[0].DisplayName, Is.EqualTo("report.pdf"));
} }
[TestCase("Audio file might be corrupted or unsupported", "Audio file might be corrupted or unsupported.")]
[TestCase("The container is damaged. ", "The container is damaged.")]
[TestCase("Is it damaged?", "Is it damaged?")]
[TestCase("", "")]
public void AReasonEndsAsASentence(string reason, string sentence)
{
Assert.That(DocumentRunProgress.AsSentence(reason), Is.EqualTo(sentence));
}
private static IndexingPass NewPass() => new(new DataSourceRunSignature("TEST-EMBEDDING-SIGNATURE", string.Empty)); private static IndexingPass NewPass() => new(new DataSourceRunSignature("TEST-EMBEDDING-SIGNATURE", string.Empty));
/// <summary> /// <summary>
+1 -1
View File
@@ -140,5 +140,5 @@ public sealed class FileSizeLimitTests
} }
// Finding the files reads none of them, so it needs neither the runtime nor the chunker: // Finding the files reads none of them, so it needs neither the runtime nor the chunker:
private static FileSourceIndexer CreateIndexer() => new(null!, null!, null!, NullLogger.Instance); private static FileSourceIndexer CreateIndexer() => new(null!, null!, null!, null!, NullLogger.Instance);
} }
@@ -1,4 +1,5 @@
using AIStudio.Tools; using AIStudio.Tools;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services.Indexing; using AIStudio.Tools.Services.Indexing;
namespace AIStudio.Tests.Tools; namespace AIStudio.Tests.Tools;
@@ -261,6 +262,78 @@ public sealed class FileSourceIndexerWatcherTests
Assert.That(FileSourceIndexer.IsSameWatchConfiguration(before, after), Is.False); Assert.That(FileSourceIndexer.IsSameWatchConfiguration(before, after), Is.False);
} }
[Test]
public async Task DirectoryWatcherIgnoresTheBatchTranscriptWhenTheMediaIsSelected()
{
var directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-transcript-watcher-{Guid.NewGuid():N}");
var mediaPath = Path.Combine(directoryPath, "recording.mp3");
Directory.CreateDirectory(directoryPath);
try
{
await File.WriteAllBytesAsync(mediaPath, [1, 2, 3]);
var transcriptPath = MediaSidecarTranscripts.GetFilePath(mediaPath);
var configuration = new FileSourceIndexer.DataSourceWatcherConfiguration(
directoryPath,
true,
FilePatterns: FileNamePatterns.Parse("*"));
var unrelatedPatterns = configuration with { FilePatterns = FileNamePatterns.Parse("*.tf") };
Assert.Multiple(() =>
{
Assert.That(FileSourceIndexer.IsRelevantWatcherPath(configuration, mediaPath, WatcherChangeTypes.Changed), Is.True);
Assert.That(FileSourceIndexer.IsRelevantWatcherPath(configuration, transcriptPath, WatcherChangeTypes.Created), Is.False);
Assert.That(FileSourceIndexer.IsRelevantWatcherPath(unrelatedPatterns, transcriptPath, WatcherChangeTypes.Created), Is.False);
});
}
finally
{
Directory.Delete(directoryPath, true);
}
}
[Test]
public async Task DirectoryWatcherKeepsTranscriptAsADocumentWhenTheMediaIsNotSelected()
{
var directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-transcript-document-{Guid.NewGuid():N}");
var mediaPath = Path.Combine(directoryPath, "recording.mp3");
Directory.CreateDirectory(directoryPath);
try
{
await File.WriteAllBytesAsync(mediaPath, [1, 2, 3]);
var configuration = new FileSourceIndexer.DataSourceWatcherConfiguration(
directoryPath,
true,
FilePatterns: FileNamePatterns.Parse("*.md"));
Assert.That(
FileSourceIndexer.IsRelevantWatcherPath(configuration, MediaSidecarTranscripts.GetFilePath(mediaPath), WatcherChangeTypes.Changed),
Is.True);
}
finally
{
Directory.Delete(directoryPath, true);
}
}
[TestCase("recording.mp3")]
[TestCase("meeting.mp4")]
[TestCase("voice.webm")]
public void MediaStartsARunOnlyWhenAPatternSelectsIt(string fileName)
{
var rootPath = Path.Combine(Path.GetTempPath(), $"ai-studio-media-watcher-{Guid.NewGuid():N}");
var path = Path.Combine(rootPath, fileName);
var documents = DefaultConfiguration(rootPath);
var everything = documents with { FilePatterns = FileNamePatterns.Parse("*") };
Assert.Multiple(() =>
{
Assert.That(FileSourceIndexer.IsRelevantWatcherPath(documents, path, WatcherChangeTypes.Created), Is.False, "The documents selector never selects audio or video.");
Assert.That(FileSourceIndexer.IsRelevantWatcherPath(everything, path, WatcherChangeTypes.Created), Is.True);
});
}
private static FileSourceIndexer.DataSourceWatcherConfiguration DefaultConfiguration(string rootPath) => new( private static FileSourceIndexer.DataSourceWatcherConfiguration DefaultConfiguration(string rootPath) => new(
rootPath, rootPath,
true, true,
+2 -2
View File
@@ -190,10 +190,10 @@ public class FileTypesTests
[TestCase("recording.mp3")] [TestCase("recording.mp3")]
[TestCase("meeting.mp4")] [TestCase("meeting.mp4")]
[TestCase("voice-recording.webm")] [TestCase("voice-recording.webm")]
public void LocalFileDataSourcesRejectMedia(string fileName) public void LocalFileDataSourcesAcceptTranscribableMedia(string fileName)
{ {
var validation = new DataSourceValidation(); var validation = new DataSourceValidation();
WithFile([1, 2, 3], fileName, path => Assert.That(validation.ValidateFilePath(path), Is.Not.Null)); WithFile([1, 2, 3], fileName, path => Assert.That(validation.ValidateFilePath(path), Is.Null));
} }
private static void AssertPath(string content, string fileName, Encoding encoding, bool expected, FileTypeFilter? type = null) private static void AssertPath(string content, string fileName, Encoding encoding, bool expected, FileTypeFilter? type = null)
@@ -0,0 +1,233 @@
using AIStudio.Settings;
using AIStudio.Tools.Media;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.Media;
/// <summary>
/// Checks where data sources keep the transcripts of their audio and video files.
/// </summary>
[TestFixture]
public sealed class DataSourceTranscriptStoreTests
{
private static readonly string DATA_SOURCE_ID = Guid.NewGuid().ToString();
private string rootPath = string.Empty;
private string dataDirectory = string.Empty;
private string userFolder = string.Empty;
[SetUp]
public void CreateFolders()
{
this.rootPath = Path.Combine(Path.GetTempPath(), $"ai-studio-transcript-store-{Guid.NewGuid():N}");
this.dataDirectory = Path.Combine(this.rootPath, "data");
this.userFolder = Path.Combine(this.rootPath, "user");
Directory.CreateDirectory(this.dataDirectory);
Directory.CreateDirectory(this.userFolder);
}
[TearDown]
public void DeleteFolders()
{
if (Directory.Exists(this.rootPath))
Directory.Delete(this.rootPath, true);
}
[Test]
public async Task TheTranscriptLiesBelowTheDataDirectoryOfItsDataSource()
{
var mediaPath = await this.CreateMediaAsync("recording.mp3", [1, 2, 3]);
var slot = await this.Store().ResolveAsync(mediaPath, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(DataSourceTranscriptStore.IsKey(slot.Key), Is.True);
Assert.That(slot.Path, Is.EqualTo(Path.Join(this.dataDirectory, DataSourceTranscriptStore.FOLDER_NAME, DATA_SOURCE_ID, slot.Key + ".md")));
});
}
[Test]
public async Task TheSameContentFindsTheSameTranscriptWhereverTheFileLies()
{
var original = await this.CreateMediaAsync("recording.mp3", [1, 2, 3]);
var copy = await this.CreateMediaAsync(Path.Combine("moved", "renamed.mp3"), [1, 2, 3]);
File.SetLastWriteTimeUtc(copy, DateTime.UtcNow.AddDays(-3));
var other = await this.CreateMediaAsync("other.mp3", [1, 2, 3, 4]);
var store = this.Store();
var originalSlot = await store.ResolveAsync(original, CancellationToken.None);
var copySlot = await store.ResolveAsync(copy, CancellationToken.None);
var otherSlot = await store.ResolveAsync(other, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(copySlot, Is.EqualTo(originalSlot));
Assert.That(otherSlot.Key, Is.Not.EqualTo(originalSlot.Key));
});
}
[Test]
public async Task ARenamedRecordingIsPaidForOnlyOnce()
{
var mediaPath = await this.CreateMediaAsync("recording.mp3", [1, 2, 3]);
var provider = new TranscriptionProvider { Id = "test-transcription-provider" };
var calls = 0;
Task<MediaTranscriptionResult> Transcribe(string path, TranscriptionProvider _, CancellationToken __)
{
calls++;
return Task.FromResult(MediaTranscriptionResult.Succeeded("Transcript"));
}
var first = await PersistentMediaTranscript.ReadOrCreateAsync(this.Store(), mediaPath, provider, Transcribe, NullLogger.Instance, CancellationToken.None);
var renamedPath = Path.Combine(this.userFolder, "renamed.mp3");
File.Move(mediaPath, renamedPath);
File.SetLastWriteTimeUtc(renamedPath, DateTime.UtcNow.AddMinutes(5));
var second = await PersistentMediaTranscript.ReadOrCreateAsync(this.Store(), renamedPath, provider, Transcribe, NullLogger.Instance, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(first.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(second.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(second.Reused, Is.True);
Assert.That(calls, Is.EqualTo(1));
Assert.That(Directory.GetFiles(this.userFolder), Has.Length.EqualTo(1), "The data source wrote into the folder of the user.");
});
}
[Test]
public async Task OnlyTranscriptsAreListedAndDeleted()
{
var store = this.Store();
var key = new string('a', 64);
var transcriptPath = store.GetPath(key);
var folder = Path.GetDirectoryName(transcriptPath)!;
Directory.CreateDirectory(folder);
await File.WriteAllTextAsync(transcriptPath, "Transcript");
await File.WriteAllTextAsync($"{transcriptPath}.{Guid.NewGuid():N}.tmp", "Interrupted");
await File.WriteAllTextAsync(Path.Combine(folder, "notes.md"), "Not ours");
Assert.That(store.ListKeys(), Is.EqualTo(new[] { key }));
store.DeleteAll();
Assert.Multiple(() =>
{
Assert.That(File.Exists(transcriptPath), Is.False);
Assert.That(Directory.GetFiles(folder, "*.tmp"), Is.Empty);
Assert.That(File.Exists(Path.Combine(folder, "notes.md")), Is.True, "A file which the store did not write was deleted.");
});
}
[Test]
public async Task DeletingAllTranscriptsRemovesTheFolderOfTheDataSource()
{
var store = this.Store();
var transcriptPath = store.GetPath(new string('b', 64));
Directory.CreateDirectory(Path.GetDirectoryName(transcriptPath)!);
await File.WriteAllTextAsync(transcriptPath, "Transcript");
store.DeleteAll();
Assert.That(Directory.Exists(Path.GetDirectoryName(transcriptPath)), Is.False);
}
[Test]
public void DeletingADataSourceWithoutTranscriptsWorks()
{
Assert.DoesNotThrow(() => this.Store().DeleteAll());
}
[Test]
public async Task ALinkInTheFolderIsNeitherFollowedNorDeleted()
{
var store = this.Store();
var key = new string('c', 64);
var linkPath = store.GetPath(key);
Directory.CreateDirectory(Path.GetDirectoryName(linkPath)!);
var targetPath = Path.Combine(this.userFolder, "precious.md");
await File.WriteAllTextAsync(targetPath, "Precious");
try
{
File.CreateSymbolicLink(linkPath, targetPath);
}
catch (Exception exception) when (exception is UnauthorizedAccessException or IOException)
{
Assert.Ignore("Creating symbolic links needs a privilege this system does not grant.");
}
var deleted = store.Delete(key);
store.DeleteAll();
Assert.Multiple(() =>
{
Assert.That(deleted, Is.False);
Assert.That(File.ReadAllText(targetPath), Is.EqualTo("Precious"));
});
}
[Test]
public async Task ABackupCopiesOnlyTheTranscriptsOfAllDataSources()
{
var secondId = Guid.NewGuid().ToString();
var firstPath = this.Store().GetPath(new string('d', 64));
var secondPath = new DataSourceTranscriptStore(this.dataDirectory, secondId).GetPath(new string('e', 64));
foreach (var path in new[] { firstPath, secondPath })
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, "Transcript");
}
await File.WriteAllTextAsync($"{firstPath}.{Guid.NewGuid():N}.tmp", "Interrupted");
var targetDirectory = Path.Combine(this.rootPath, "copies");
var copied = await DataSourceTranscriptStore.CopyAllAsync(this.dataDirectory, targetDirectory, CancellationToken.None);
var copiedFiles = Directory.GetFiles(targetDirectory, "*", SearchOption.AllDirectories).Select(path => Path.GetRelativePath(targetDirectory, path).Replace('\\', '/'));
Assert.Multiple(() =>
{
Assert.That(copied, Is.EqualTo(2));
Assert.That(copiedFiles, Is.EquivalentTo(new[]
{
$"{DataSourceTranscriptStore.FOLDER_NAME}/{DATA_SOURCE_ID}/{new string('d', 64)}.md",
$"{DataSourceTranscriptStore.FOLDER_NAME}/{secondId}/{new string('e', 64)}.md",
}));
});
}
[Test]
public async Task ABackupWithoutTranscriptsCopiesNothing()
{
Assert.That(await DataSourceTranscriptStore.CopyAllAsync(this.dataDirectory, Path.Combine(this.rootPath, "copies"), CancellationToken.None), Is.Zero);
}
[TestCase("")]
[TestCase("ABCDEF")]
[TestCase("../outside")]
public void ADataSourceIdWhichIsNoGuidIsRejected(string dataSourceId)
{
Assert.Throws<ArgumentException>(() => _ = new DataSourceTranscriptStore(this.dataDirectory, dataSourceId));
}
[TestCase("")]
[TestCase("../../settings")]
[TestCase("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")]
public void AKeyWhichIsNoHashIsRejected(string key)
{
Assert.Throws<ArgumentException>(() => this.Store().GetPath(key));
}
private DataSourceTranscriptStore Store() => new(this.dataDirectory, DATA_SOURCE_ID);
private async Task<string> CreateMediaAsync(string relativePath, byte[] content)
{
var path = Path.Combine(this.userFolder, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, content);
return path;
}
}
@@ -0,0 +1,57 @@
using AIStudio.Tools.Media;
namespace AIStudio.Tests.Tools.Media;
/// <summary>
/// Checks the transcripts which the Batch Processing assistant keeps next to its media files.
/// </summary>
[TestFixture]
public sealed class MediaSidecarTranscriptsTests
{
[TestCase("recording.mp3.transcript.md")]
[TestCase("recording.mp3.transcript.md.0123456789abcdef.tmp")]
[TestCase("RECORDING.MP3.TRANSCRIPT.MD")]
public void GeneratedTranscriptFilesAreRecognized(string fileName)
{
Assert.That(MediaSidecarTranscripts.IsArtifactPath(fileName), Is.True);
}
[TestCase("notes.md")]
[TestCase("recording.mp3")]
[TestCase("recording.mp3.transcript.md.backup")]
public void OtherFilesAreNoTranscripts(string fileName)
{
Assert.That(MediaSidecarTranscripts.IsArtifactPath(fileName), Is.False);
}
[Test]
public async Task TheTranscriptLiesNextToItsMediaFile()
{
var mediaPath = Path.Combine(Path.GetTempPath(), "recording.mp3");
var slot = await MediaSidecarTranscripts.INSTANCE.ResolveAsync(mediaPath, CancellationToken.None);
Assert.That(slot.Path, Is.EqualTo(mediaPath + MediaSidecarTranscripts.FILE_SUFFIX));
}
[Test]
public async Task OnlyATranscriptWithContentCanBeReused()
{
var directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-sidecar-transcript-{Guid.NewGuid():N}");
var mediaPath = Path.Combine(directoryPath, "recording.mp3");
Directory.CreateDirectory(directoryPath);
try
{
Assert.That(MediaSidecarTranscripts.HasReusableTranscript(mediaPath), Is.False, "A missing transcript counted as reusable.");
await File.WriteAllTextAsync(MediaSidecarTranscripts.GetFilePath(mediaPath), string.Empty);
Assert.That(MediaSidecarTranscripts.HasReusableTranscript(mediaPath), Is.False, "An empty transcript counted as reusable.");
await File.WriteAllTextAsync(MediaSidecarTranscripts.GetFilePath(mediaPath), "Transcript");
Assert.That(MediaSidecarTranscripts.HasReusableTranscript(mediaPath), Is.True);
}
finally
{
Directory.Delete(directoryPath, true);
}
}
}
@@ -0,0 +1,252 @@
using AIStudio.Settings;
using AIStudio.Tools.Media;
using AIStudio.Tools.Rust;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.Media;
/// <summary>
/// Checks the rules which keep a paid transcription from being paid twice.
/// </summary>
[TestFixture]
public sealed class PersistentMediaTranscriptTests
{
private TranscriptionProvider? provider;
private string directoryPath = string.Empty;
private string mediaPath = string.Empty;
[SetUp]
public async Task CreateMediaFile()
{
this.directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-persistent-transcript-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.directoryPath);
this.mediaPath = Path.Combine(this.directoryPath, "recording.mp3");
this.provider = new TranscriptionProvider { Id = "test-transcription-provider" };
await File.WriteAllBytesAsync(this.mediaPath, [1, 2, 3]);
}
[TearDown]
public void DeleteMediaFile()
{
if (Directory.Exists(this.directoryPath))
Directory.Delete(this.directoryPath, true);
}
[Test]
public async Task AnExistingTranscriptIsUsedWithoutAskingTheProvider()
{
await File.WriteAllTextAsync(MediaSidecarTranscripts.GetFilePath(this.mediaPath), "Transcript");
var transcriber = new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("New transcript"));
var result = await this.ReadOrCreateAsync(transcriber, this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(result.Reused, Is.True);
Assert.That(transcriber.Calls, Is.Zero);
});
}
[Test]
public async Task AnExistingTranscriptIsUsedEvenWithoutAProvider()
{
await File.WriteAllTextAsync(MediaSidecarTranscripts.GetFilePath(this.mediaPath), "Transcript");
var result = await this.ReadOrCreateAsync(new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("New transcript")), null);
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
}
[Test]
public async Task WithoutATranscriptAndAProviderNothingIsTranscribed()
{
var transcriber = new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("Transcript"));
var result = await this.ReadOrCreateAsync(transcriber, null);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.NO_PROVIDER));
Assert.That(transcriber.Calls, Is.Zero);
});
}
[Test]
public async Task ANewTranscriptIsStoredAndPaidForOnlyOnce()
{
var transcriber = new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("Transcript"));
var first = await this.ReadOrCreateAsync(transcriber, this.provider);
var second = await this.ReadOrCreateAsync(transcriber, this.provider);
Assert.Multiple(() =>
{
Assert.That(first.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(first.Reused, Is.False);
Assert.That(File.ReadAllText(first.TranscriptPath), Is.EqualTo("Transcript"));
Assert.That(second.Reused, Is.True);
Assert.That(transcriber.Calls, Is.EqualTo(1));
Assert.That(Directory.GetFiles(this.directoryPath, "*.tmp"), Is.Empty);
});
}
[Test]
public async Task AnEmptyTranscriptIsNeverStored()
{
var result = await this.ReadOrCreateAsync(new CountingTranscriber(() => MediaTranscriptionResult.Succeeded(" \n ")), this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.EMPTY));
Assert.That(File.Exists(MediaSidecarTranscripts.GetFilePath(this.mediaPath)), Is.False);
});
}
[Test]
public async Task ATranscriptHoldingOnlyWhiteSpaceIsReplaced()
{
await File.WriteAllTextAsync(MediaSidecarTranscripts.GetFilePath(this.mediaPath), " \n ");
var transcriber = new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("Transcript"));
var result = await this.ReadOrCreateAsync(transcriber, this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(transcriber.Calls, Is.EqualTo(1));
Assert.That(File.ReadAllText(result.TranscriptPath), Is.EqualTo("Transcript"));
});
}
[Test]
public async Task AFileWhichChangesDuringItsTranscriptionKeepsNoTranscript()
{
var transcriber = new CountingTranscriber(() =>
{
File.WriteAllBytes(this.mediaPath, [1, 2, 3, 4]);
return MediaTranscriptionResult.Succeeded("Transcript of the old file");
});
var result = await this.ReadOrCreateAsync(transcriber, this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.SOURCE_CHANGED));
Assert.That(File.Exists(MediaSidecarTranscripts.GetFilePath(this.mediaPath)), Is.False);
});
}
[Test]
public async Task ATranscriptWhichArrivesAfterACancellationIsStillStored()
{
using var cancellation = new CancellationTokenSource();
var transcriber = new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("Paid transcript"), cancellation.Cancel);
var result = await PersistentMediaTranscript.ReadOrCreateAsync(MediaSidecarTranscripts.INSTANCE, this.mediaPath, this.provider, transcriber.TranscribeAsync, NullLogger.Instance, cancellation.Token);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(File.ReadAllText(MediaSidecarTranscripts.GetFilePath(this.mediaPath)), Is.EqualTo("Paid transcript"));
});
}
[Test]
public async Task ACancelledTranscriptionStoresNothing()
{
var result = await this.ReadOrCreateAsync(new CountingTranscriber(MediaTranscriptionResult.Cancelled), this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.CANCELLED));
Assert.That(File.Exists(MediaSidecarTranscripts.GetFilePath(this.mediaPath)), Is.False);
});
}
[Test]
public async Task AFailedTranscriptionKeepsTheReasonOfTheMediaPipeline()
{
var result = await this.ReadOrCreateAsync(new CountingTranscriber(() => MediaTranscriptionResult.Failed("The container is damaged.", MediaJobErrorCode.DAMAGED_CONTAINER)), this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED));
Assert.That(result.ErrorCode, Is.EqualTo(MediaJobErrorCode.DAMAGED_CONTAINER));
Assert.That(result.UserMessage, Is.EqualTo("The container is damaged."));
});
}
[Test]
public async Task AFileTheProviderRefusedIsReportedAsSuch()
{
var result = await this.ReadOrCreateAsync(new CountingTranscriber(() => MediaTranscriptionResult.Rejected("The file is too large.")), this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.REJECTED));
Assert.That(result.UserMessage, Is.EqualTo("The file is too large."));
Assert.That(File.Exists(MediaSidecarTranscripts.GetFilePath(this.mediaPath)), Is.False);
});
}
[Test]
public async Task ATranscriptWhichCannotBeStoredIsReportedAndLeavesNoTemporaryFile()
{
//
// A file where the folder of the transcript should be: writing fails for every user, root
// included, unlike a folder without write permission.
//
var blockingFilePath = Path.Combine(this.directoryPath, "not-a-folder");
await File.WriteAllTextAsync(blockingFilePath, "occupied");
var location = new FixedLocation(Path.Combine(blockingFilePath, "transcript.md"));
var result = await PersistentMediaTranscript.ReadOrCreateAsync(location, this.mediaPath, this.provider, new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("Transcript")).TranscribeAsync, NullLogger.Instance, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.STORE_FAILED));
Assert.That(Directory.GetFiles(this.directoryPath, "*.tmp", SearchOption.AllDirectories), Is.Empty);
});
}
[Test]
public async Task AMissingMediaFileIsReportedWithoutAskingTheProvider()
{
File.Delete(this.mediaPath);
var transcriber = new CountingTranscriber(() => MediaTranscriptionResult.Succeeded("Transcript"));
var result = await this.ReadOrCreateAsync(transcriber, this.provider);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.SOURCE_UNREADABLE));
Assert.That(transcriber.Calls, Is.Zero);
});
}
private Task<PersistentMediaTranscriptResult> ReadOrCreateAsync(CountingTranscriber transcriber, TranscriptionProvider? transcriptionProvider) => PersistentMediaTranscript.ReadOrCreateAsync(MediaSidecarTranscripts.INSTANCE, this.mediaPath, transcriptionProvider, transcriber.TranscribeAsync, NullLogger.Instance, CancellationToken.None);
/// <summary>
/// Stands in for the provider and counts how often it was asked.
/// </summary>
/// <param name="answer">Creates the answer of the provider.</param>
/// <param name="whileAnswering">Runs while the provider is answering, e.g., to cancel the run in the middle of an upload.</param>
private sealed class CountingTranscriber(Func<MediaTranscriptionResult> answer, Action? whileAnswering = null)
{
public int Calls { get; private set; }
public Task<MediaTranscriptionResult> TranscribeAsync(string mediaPath, TranscriptionProvider provider, CancellationToken token)
{
this.Calls++;
whileAnswering?.Invoke();
return Task.FromResult(answer());
}
}
private sealed class FixedLocation(string transcriptPath) : IMediaTranscriptLocation
{
public Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token) => Task.FromResult(new MediaTranscriptSlot(transcriptPath, "fixed"));
}
}
@@ -0,0 +1,110 @@
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
using AIStudio.Tools.Services.Indexing;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.Media;
/// <summary>
/// Checks that the transcript of a recording leaves the data source together with the recording.
/// </summary>
/// <remarks>
/// A transcript holds what was said. Once its recording is gone from the data source, keeping it
/// below the data directory would keep that text, unseen by the user.
/// </remarks>
[TestFixture]
public sealed class TranscriptCleanupTests
{
private static readonly string KEY_A = new('a', 64);
private static readonly string KEY_B = new('b', 64);
private static readonly string KEY_C = new('c', 64);
private string dataDirectory = string.Empty;
private DataSourceTranscriptStore store = null!;
[SetUp]
public async Task CreateTranscripts()
{
this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-transcript-cleanup-{Guid.NewGuid():N}");
this.store = new DataSourceTranscriptStore(this.dataDirectory, Guid.NewGuid().ToString());
foreach (var key in new[] { KEY_A, KEY_B, KEY_C })
{
var path = this.store.GetPath(key);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, $"Transcript {key[0]}");
}
}
[TearDown]
public void DeleteTranscripts()
{
if (Directory.Exists(this.dataDirectory))
Directory.Delete(this.dataDirectory, true);
}
[Test]
public void TheTranscriptOfARemovedRecordingIsDeleted()
{
var manifest = Manifest(("/data/kept.mp3", KEY_A));
FileSourceIndexer.ForgetTranscript(this.store, manifest, KEY_B, NullLogger.Instance);
Assert.That(this.store.ListKeys(), Is.EquivalentTo(new[] { KEY_A, KEY_C }));
}
[Test]
public void ATranscriptWhichAnotherRecordingSharesIsKept()
{
var manifest = Manifest(("/data/copy-of-the-removed-recording.mp3", KEY_B));
FileSourceIndexer.ForgetTranscript(this.store, manifest, KEY_B, NullLogger.Instance);
Assert.That(this.store.ListKeys(), Does.Contain(KEY_B), "Two recordings with the same content share one transcript.");
}
[Test]
public void AFileWithoutTranscriptDeletesNothing()
{
FileSourceIndexer.ForgetTranscript(this.store, new DataSourceEmbeddingManifest(), null, NullLogger.Instance);
Assert.That(this.store.ListKeys(), Has.Count.EqualTo(3));
}
[Test]
public async Task ACompleteRunDeletesWhatNoFileUses()
{
var temporaryPath = $"{this.store.GetPath(KEY_A)}.{Guid.NewGuid():N}.tmp";
await File.WriteAllTextAsync(temporaryPath, "Interrupted");
var manifest = Manifest(("/data/meeting.mp3", KEY_A), ("/data/report.pdf", null));
FileSourceIndexer.DeleteUnusedTranscripts(this.store, manifest, NullLogger.Instance);
Assert.Multiple(() =>
{
Assert.That(this.store.ListKeys(), Is.EqualTo(new[] { KEY_A }));
Assert.That(File.Exists(temporaryPath), Is.False, "A temporary file of an interrupted write is no transcript of anything.");
});
}
[Test]
public void WithoutAStoreNothingHappens()
{
Assert.DoesNotThrow(() =>
{
FileSourceIndexer.DeleteUnusedTranscripts(null, new DataSourceEmbeddingManifest(), NullLogger.Instance);
FileSourceIndexer.ForgetTranscript(null, new DataSourceEmbeddingManifest(), KEY_A, NullLogger.Instance);
});
}
private static DataSourceEmbeddingManifest Manifest(params (string Path, string? TranscriptKey)[] files)
{
var manifest = new DataSourceEmbeddingManifest();
foreach (var (path, transcriptKey) in files)
manifest.Files[path] = new EmbeddedFileRecord("fingerprint", 16, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 1, transcriptKey);
return manifest;
}
}
+258
View File
@@ -0,0 +1,258 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.Media;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using AIStudio.Tools.Services.Indexing;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks which audio and video files a local data source transcribes, and how it treats those it cannot.
/// </summary>
/// <remarks>
/// Transcribing costs money with every cloud provider. A media file is therefore only sent when a
/// provider exists which the data source allows, and a file which cannot be transcribed for a
/// reason of its own is not sent again until it changes.
/// </remarks>
public sealed class MediaIndexingTests
{
private string testRoot = string.Empty;
[SetUp]
public void CreateTestRoot()
{
this.testRoot = Path.Combine(Path.GetTempPath(), $"ai-studio-media-indexing-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testRoot);
}
[TearDown]
public void DeleteTestRoot()
{
if (Directory.Exists(this.testRoot))
Directory.Delete(this.testRoot, true);
}
[Test]
public void AnAllowedProviderTranscribesASelectedRecording()
{
var recording = this.CreateFile("meeting.mp3", 16);
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), Allowed());
Assert.Multiple(() =>
{
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }));
Assert.That(result.IgnoredFiles, Is.Empty);
});
}
[Test]
public void WithoutAProviderANewRecordingWaitsForOne()
{
var recording = this.CreateFile("meeting.mp3", 16);
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), MediaTranscriptionAvailability.MISSING);
Assert.Multiple(() =>
{
Assert.That(result.Files, Is.Empty);
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING) }));
});
}
[Test]
public void AProviderBelowTheConfidenceLevelHearsNothing()
{
var recording = this.CreateFile("meeting.mp4", 16);
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), MediaTranscriptionAvailability.NOT_ALLOWED);
Assert.Multiple(() =>
{
Assert.That(result.Files, Is.Empty, "The recording would have gone to a provider the data source does not allow.");
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED) }));
});
}
[Test]
public void AnIndexedRecordingStaysWhenTheProviderIsGone()
{
var recording = this.CreateFile("meeting.mp3", 16);
var manifest = new DataSourceEmbeddingManifest();
manifest.Files[recording] = new EmbeddedFileRecord(FileSourceIndexer.BuildFileMetadataHash(new FileInfo(recording)), 16, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, new string('a', 64));
var result = CreateIndexer().GetInputFiles(this.Folder("*"), manifest, MediaTranscriptionAvailability.MISSING);
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }), "An unchanged recording is not read, so it needs no provider, and leaving it out would remove it from the index.");
}
[Test]
public void ARecordingWhichFailedForGoodStaysWithItsReasonWhenTheProviderIsGone()
{
var recording = this.CreateFile("silence.wav", 16);
var manifest = new DataSourceEmbeddingManifest();
manifest.PermanentFailures[recording] = new PermanentIndexingFailureRecord(FileSourceIndexer.BuildFileMetadataHash(new FileInfo(recording)), FileExtractionErrorCode.NO_CONTENT, "No content", DateTimeOffset.UtcNow);
var result = CreateIndexer().GetInputFiles(this.Folder("*"), manifest, MediaTranscriptionAvailability.NOT_ALLOWED);
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }), "The run lists it with its stored reason instead of reading it.");
}
[Test]
public void AChangedRecordingNeedsAProviderAgain()
{
var recording = this.CreateFile("meeting.mp3", 16);
var manifest = new DataSourceEmbeddingManifest();
manifest.Files[recording] = new EmbeddedFileRecord("THE-FINGERPRINT-BEFORE-THE-CHANGE", 8, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, new string('a', 64));
var result = CreateIndexer().GetInputFiles(this.Folder("*"), manifest, MediaTranscriptionAvailability.MISSING);
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING) }));
}
[Test]
public void ARecordingAboveTheLimitIsNeverTranscribed()
{
var recording = this.CreateFile("conference.mkv", FolderFileSelection.MegabytesToBytes(1) + 1);
var dataSource = this.Folder("*") with { MaxFileSizeMegabytes = 1 };
var result = CreateIndexer().GetInputFiles(dataSource, new DataSourceEmbeddingManifest(), Allowed());
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TOO_LARGE) }));
}
[Test]
public void ImagesAreStillLeftOut()
{
var image = this.CreateFile("photo.png", 16);
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), Allowed());
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(image, DataSourceEmbeddingIgnoreReason.IMAGE_NOT_SUPPORTED) }));
}
[TestCase("meeting.mp3")]
[TestCase("meeting.mp4")]
[TestCase("meeting.wav")]
[TestCase("meeting.webm")]
public void TheDefaultPatternsSelectNoRecording(string fileName)
{
var recording = this.CreateFile(fileName, 16);
var result = CreateIndexer().GetInputFiles(this.Folder(DataSourceLocalDirectory.DEFAULT_FILE_PATTERNS), new DataSourceEmbeddingManifest(), Allowed());
Assert.Multiple(() =>
{
Assert.That(result.Files, Is.Empty, "Existing data sources must not start transcribing, and paying for it, after an update.");
Assert.That(FileNamePatterns.Matches(recording, FileNamePatterns.Parse(":text:")), Is.False);
});
}
[TestCase("*")]
[TestCase("*.mp3")]
[TestCase("*.pdf;*.mp3")]
public void APatternWhichNamesRecordingsSelectsThem(string patterns)
{
var recording = this.CreateFile("meeting.mp3", 16);
var result = CreateIndexer().GetInputFiles(this.Folder(patterns), new DataSourceEmbeddingManifest(), Allowed());
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }));
}
[Test]
public void TheBatchTranscriptOfASelectedRecordingIsNoDocumentOfItsOwn()
{
var recording = this.CreateFile("meeting.mp3", 16);
var transcript = this.CreateFile(Path.GetFileName(MediaSidecarTranscripts.GetFilePath(recording)), 16);
var everything = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), Allowed());
var markdownOnly = CreateIndexer().GetInputFiles(this.Folder("*.md"), new DataSourceEmbeddingManifest(), Allowed());
Assert.Multiple(() =>
{
Assert.That(everything.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }), "The transcript would duplicate the text of the recording.");
Assert.That(markdownOnly.Files.Select(file => file.FullName), Is.EqualTo(new[] { transcript }), "Without the recording, the transcript is a document like every other.");
});
}
[Test]
public void ASingleRecordingCanBeADataSource()
{
var recording = this.CreateFile("interview.m4a", 16);
var dataSource = new DataSourceLocalFile { Type = DataSourceType.LOCAL_FILE, FilePath = recording };
var result = CreateIndexer().GetInputFiles(dataSource, new DataSourceEmbeddingManifest(), Allowed());
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }));
}
[Test]
public void AReadyTranscriptIsRead()
{
var transcript = PersistentMediaTranscriptResult.Ready(new MediaTranscriptSlot("/data/mediaTranscripts/source/key.md", "key"), reused: true);
Assert.That(FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None), Is.EqualTo(new MediaTranscriptSlot("/data/mediaTranscripts/source/key.md", "key")));
}
[TestCase(PersistentMediaTranscriptStatus.EMPTY)]
[TestCase(PersistentMediaTranscriptStatus.NO_AUDIBLE_SIGNAL)]
public void ARecordingWithoutTextIsRecordedForGood(PersistentMediaTranscriptStatus status)
{
var transcript = PersistentMediaTranscriptResult.Unavailable(status, null, "Nothing to read.");
var exception = Assert.Throws<FileExtractionException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
Assert.That(exception!.Code, Is.EqualTo(FileExtractionErrorCode.NO_CONTENT), "Asked again with every run, the provider would be paid again with every run.");
}
[Test]
public void ARecordingTheMediaPipelineCannotReadIsRecordedForGood()
{
var transcript = PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED, null, "The container is damaged.", MediaJobErrorCode.DAMAGED_CONTAINER);
var exception = Assert.Throws<FileExtractionException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
Assert.That(exception!.Code, Is.EqualTo(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED));
}
[Test]
public void ARecordingTheProviderRefusedIsRecordedForGood()
{
var transcript = PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.REJECTED, null, "The provider 'Cloud' reported an error: Audio file might be corrupted or unsupported");
var exception = Assert.Throws<FileExtractionException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
Assert.That(exception!.Code, Is.EqualTo(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED), "Tried again with every run, the same file would be uploaded again with every run.");
}
[TestCase(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED)]
[TestCase(PersistentMediaTranscriptStatus.STORE_FAILED)]
[TestCase(PersistentMediaTranscriptStatus.SOURCE_CHANGED)]
[TestCase(PersistentMediaTranscriptStatus.NO_PROVIDER)]
public void AFailureWhichMayPassIsTriedAgain(PersistentMediaTranscriptStatus status)
{
var transcript = PersistentMediaTranscriptResult.Unavailable(status, null, "Try again later.");
Assert.Throws<IOException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
}
private DataSourceLocalDirectory Folder(string patterns) => new()
{
Type = DataSourceType.LOCAL_DIRECTORY,
Path = this.testRoot,
FilePatterns = patterns,
};
private string CreateFile(string fileName, long length)
{
var filePath = Path.Combine(this.testRoot, fileName);
using var stream = File.Create(filePath);
stream.SetLength(length);
return filePath;
}
private static MediaTranscriptionAvailability Allowed() => MediaTranscriptionAvailability.Allowed(new TranscriptionProvider { Id = "test-transcription-provider" });
// Finding the files reads none of them, so it needs neither the runtime nor a transcription provider:
private static FileSourceIndexer CreateIndexer() => new(null!, null!, null!, null!, NullLogger.Instance);
}
+1 -1
View File
@@ -82,7 +82,7 @@ public sealed class QueuedStatusTests
PendingRemovalCount: 42, PendingRemovalCount: 42,
LastSyncUtc: NOW) LastSyncUtc: NOW)
{ {
IgnoredFiles = [new("/tmp/test-data/interview.mp3", DataSourceEmbeddingIgnoreReason.MEDIA_NOT_SUPPORTED)], IgnoredFiles = [new("/tmp/test-data/interview.mp3", DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING)],
FilePatternExclusions = [new(".png", 5)], FilePatternExclusions = [new(".png", 5)],
}; };
+26
View File
@@ -45,4 +45,30 @@ public sealed class TerminalLoggerTests
CultureInfo.CurrentCulture = previousCulture; CultureInfo.CurrentCulture = previousCulture;
} }
} }
[Test]
public void AnExceptionIsDescribedTogetherWithItsCause()
{
var exception = new HttpRequestException("Error while copying content to a stream.", new IOException("Unable to write data to the transport connection.", new InvalidOperationException("Connection reset by peer.")));
Assert.That(TerminalLogger.DescribeException(exception), Is.EqualTo("Error while copying content to a stream. ---> Unable to write data to the transport connection. ---> Connection reset by peer."));
}
[Test]
public void AnExceptionWithoutCauseKeepsItsMessage()
{
Assert.Multiple(() =>
{
Assert.That(TerminalLogger.DescribeException(new InvalidOperationException("Something failed.")), Is.EqualTo("Something failed."));
Assert.That(TerminalLogger.DescribeException(null), Is.Null);
});
}
[Test]
public void TheCausesOfAnAggregateExceptionAreNotRepeated()
{
var exception = new AggregateException(new InvalidOperationException("First failure."), new InvalidOperationException("Second failure."));
Assert.That(TerminalLogger.DescribeException(exception), Is.EqualTo(exception.Message));
}
} }
@@ -117,5 +117,5 @@ public sealed class MailDraftSyncTests : ToolRegistryTestBase
} }
// Queueing a run needs none of the services which carry it out: // Queueing a run needs none of the services which carry it out:
private DataSourceEmbeddingService CreateService() => new(this.SettingsManager, null!, null!, null!, NullLogger<DataSourceEmbeddingService>.Instance); private DataSourceEmbeddingService CreateService() => new(this.SettingsManager, null!, null!, null!, null!, NullLogger<DataSourceEmbeddingService>.Instance);
} }
@@ -205,7 +205,7 @@ public sealed class UnreachableDataSourceTests
NullLogger.Instance); NullLogger.Instance);
// A run which reaches no file reads none, so it needs neither the runtime nor the chunker: // A run which reaches no file reads none, so it needs neither the runtime nor the chunker:
var indexer = new FileSourceIndexer(settingsManager, null!, null!, NullLogger.Instance); var indexer = new FileSourceIndexer(settingsManager, null!, null!, null!, NullLogger.Instance);
var outcome = await indexer.ProcessAsync(context, DataSourceEmbeddingRefreshMode.HASH_CHECK, CancellationToken.None); var outcome = await indexer.ProcessAsync(context, DataSourceEmbeddingRefreshMode.HASH_CHECK, CancellationToken.None);
return new(outcome, statuses, manifest); return new(outcome, statuses, manifest);
@@ -0,0 +1,68 @@
using AIStudio.Tools;
using AIStudio.Tools.Validation;
namespace AIStudio.Tests.Tools.Validation;
/// <summary>
/// Checks what the dialogs of local data sources tell about the cloud, and when they ask again.
/// </summary>
/// <remarks>
/// A data source can send its text to an embedding provider and its recordings to a transcription
/// provider. The user acknowledges what leaves the computer, and an acknowledgment for the one must
/// not count for the other.
/// </remarks>
[TestFixture]
public sealed class CloudProcessingTests
{
[TestCase(CloudProcessing.EMBEDDING, CloudProcessing.EMBEDDING, true)]
[TestCase(CloudProcessing.EMBEDDING, CloudProcessing.EMBEDDING | CloudProcessing.TRANSCRIPTION, false)]
[TestCase(CloudProcessing.EMBEDDING, CloudProcessing.TRANSCRIPTION, false)]
[TestCase(CloudProcessing.EMBEDDING | CloudProcessing.TRANSCRIPTION, CloudProcessing.TRANSCRIPTION, true)]
[TestCase(CloudProcessing.NONE, CloudProcessing.EMBEDDING, false)]
[TestCase(CloudProcessing.NONE, CloudProcessing.NONE, true)]
public void AnAcknowledgmentCoversOnlyWhatItWasGivenFor(CloudProcessing acknowledged, CloudProcessing current, bool expected)
{
Assert.That(acknowledged.Covers(current), Is.EqualTo(expected));
}
[Test]
public void ARecordingSentToTheCloudNeedsAnAcknowledgment()
{
var validation = new DataSourceValidation { GetSelectedCloudTranscription = () => true };
Assert.Multiple(() =>
{
Assert.That(validation.ValidateUserAcknowledgedCloudEmbedding(false), Is.Not.Null);
Assert.That(validation.ValidateUserAcknowledgedCloudEmbedding(true), Is.Null);
});
}
[Test]
public void NothingSentToTheCloudNeedsNoAcknowledgment()
{
Assert.That(new DataSourceValidation().ValidateUserAcknowledgedCloudEmbedding(false), Is.Null);
}
[TestCase("*")]
[TestCase("*.mp3")]
[TestCase("*.MP4")]
[TestCase("meeting*")]
[TestCase("*.*")]
[TestCase("*.m?a")]
[TestCase(":documents:;*.webm")]
public void APatternWhichMaySelectRecordingsCounts(string patterns)
{
Assert.That(FileNamePatterns.MaySelectMedia(FileNamePatterns.Parse(patterns)), Is.True);
}
[TestCase(":documents:")]
[TestCase(":text:")]
[TestCase("*.pdf;*.docx")]
[TestCase(".gitignore")]
[TestCase("Makefile")]
[TestCase("report_*.txt")]
public void APatternWhichSelectsNoRecordingDoesNotCount(string patterns)
{
Assert.That(FileNamePatterns.MaySelectMedia(FileNamePatterns.Parse(patterns)), Is.False, "The dialog would warn about a transcription which never happens.");
}
}
+17 -1
View File
@@ -52,7 +52,7 @@ const SETTINGS_FILE_NAME: &str = "settings.json";
/// (`DataBackupContents`) includes. The runtime refuses a plan which names anything else, so that /// (`DataBackupContents`) includes. The runtime refuses a plan which names anything else, so that
/// whatever goes wrong in the app, a restore never replaces the logs, the downloaded tools, or the /// whatever goes wrong in the app, a restore never replaces the logs, the downloaded tools, or the
/// data folder as a whole. /// data folder as a whole.
const REPLACEABLE_ELEMENTS: [(Location, &str); 8] = [ const REPLACEABLE_ELEMENTS: [(Location, &str); 9] = [
(Location::ConfigDirectory, SETTINGS_FILE_NAME), (Location::ConfigDirectory, SETTINGS_FILE_NAME),
(Location::DataDirectory, "workspaces/"), (Location::DataDirectory, "workspaces/"),
(Location::DataDirectory, "tempChats/"), (Location::DataDirectory, "tempChats/"),
@@ -61,6 +61,7 @@ const REPLACEABLE_ELEMENTS: [(Location, &str); 8] = [
(Location::DataDirectory, "visualBriefings/"), (Location::DataDirectory, "visualBriefings/"),
(Location::DataDirectory, "databases/sqlite/"), (Location::DataDirectory, "databases/sqlite/"),
(Location::DataDirectory, "databases/vector_database/stores/"), (Location::DataDirectory, "databases/vector_database/stores/"),
(Location::DataDirectory, "mediaTranscripts/"),
]; ];
/// Paths which a restore always keeps, whatever the plan says: the plugins which come with AI Studio /// Paths which a restore always keeps, whatever the plan says: the plugins which come with AI Studio
@@ -794,6 +795,21 @@ mod tests {
assert_current_data_untouched(&test); assert_current_data_untouched(&test);
} }
#[test]
fn a_restore_replaces_the_media_transcripts_of_the_data_sources() {
let test = TestFolders::new();
test.write_current_data();
test.write(Location::DataDirectory, "mediaTranscripts/current/transcript.md", "current transcript");
test.prepare_restore(r#"{ "location": "DATA_DIRECTORY", "relative_path": "mediaTranscripts/", "keep": [] }"#);
test.write(Location::DataDirectory, ".restore-pending/mediaTranscripts/restored/transcript.md", "restored transcript");
let outcome = apply_pending_restore_in(&test.folders(), &[]);
assert_eq!(outcome, Some(RestoreOutcome::Applied));
assert_eq!(test.read(Location::DataDirectory, "mediaTranscripts/restored/transcript.md").as_deref(), Some("restored transcript"));
assert!(!test.exists(Location::DataDirectory, "mediaTranscripts/current"));
}
#[test] #[test]
fn a_plan_which_names_another_element_is_refused() { fn a_plan_which_names_another_element_is_refused() {
for relative_path in ["", "events.log", "databases/", "../data/", "pandoc/"] { for relative_path in ["", "events.log", "databases/", "../data/", "pandoc/"] {