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

@@ -170,7 +170,7 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
var settingsJson = JsonSerializer.Serialize(settings, SettingsManager.JSON_OPTIONS);
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 manifest = new DataBackupManifest(
@@ -207,7 +207,7 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
}
/// <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>
/// <remarks>
/// 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.
/// </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>
private async Task<string> CopyIndexesAsync(CancellationToken token)
private async Task<string> CopyIndexesAsync(string dataDirectory, CancellationToken token)
{
var indexStore = await databaseClientProvider.GetIndexStoreAsync(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);
}
//
// 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;
}
catch
@@ -5,6 +5,24 @@ namespace AIStudio.Tools.Services;
/// </summary>
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,
}
@@ -6,7 +6,8 @@ namespace AIStudio.Tools.Services;
/// </summary>
/// <remarks>
/// 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>
/// <param name="FilePath">The absolute path of the ignored file.</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.IndexStore;
using AIStudio.Tools.Databases.VectorStore;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Security;
using AIStudio.Tools.Services.Indexing;
@@ -16,7 +17,7 @@ using static AIStudio.Tools.Services.Indexing.IndexingLogFormat;
namespace AIStudio.Tools.Services;
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>
/// 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>
/// One indexer per kind of data source this service indexes.
/// </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();
@@ -93,12 +94,12 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
/// <summary>
/// Creates one indexer per kind of data source, all of them cutting their text with the same chunker.
/// </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);
return
[
new FileSourceIndexer(settingsManager, rustService, textChunker, logger),
new FileSourceIndexer(settingsManager, rustService, mediaTranscriptionService, 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 _);
await this.ResetPersistedStateAsync(dataSource.Id, null, null, CancellationToken.None);
this.DeleteMediaTranscripts(dataSource);
this.statuses.TryRemove(dataSource.Id, out _);
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)
{
// 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.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>
/// The configured data sources this service indexes, from every list which holds some.
/// </summary>
@@ -68,6 +68,13 @@ public sealed record DataSourceEmbeddingStatus(
/// </summary>
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>
/// The settled health of this data source. Activity is represented by State
/// 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)
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.HEALTHY;
@@ -1,3 +1,3 @@
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 state = document.State with { ChunkCount = chunkCount, EmbeddedAtUtc = embeddedAtUtc };
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);
context.Pass.Forget(document.Key);
@@ -300,7 +300,7 @@ internal sealed class DocumentRunProgress(IndexedRunContext context, int totalDo
//
this.PermanentlySkippedDocuments++;
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));
context.Manifest.Files.Remove(document.Key);
context.Pass.Forget(document.Key);
@@ -533,10 +533,37 @@ internal sealed class DocumentRunProgress(IndexedRunContext context, int totalDo
/// </remarks>
/// <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="details">A localized explanation supplied by a specialized reader, when available.</param>
/// <returns>The message, ready to show.</returns>
private string GetFailureMessage(FileExtractionErrorCode code, EmbeddingDocument document) => context.DataSource is DataSourceMailbox
? string.Format(TB("The mail '{0}' could not be indexed. AI Studio tries again during the next sync."), document.DisplayName)
: code.ToIndexingUserMessage(document.DisplayName);
private string GetFailureMessage(FileExtractionErrorCode code, EmbeddingDocument document, string? details = null)
{
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(
context.DataSource.Id,
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Media;
namespace AIStudio.Tools.Services.Indexing;
@@ -268,6 +269,14 @@ internal sealed partial class FileSourceIndexer
if (Directory.Exists(path))
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))
return true;
@@ -5,6 +5,7 @@ using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Databases.VectorStore;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Security;
@@ -32,9 +33,10 @@ namespace AIStudio.Tools.Services.Indexing;
/// </remarks>
/// <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="mediaTranscriptionService">Transcribes selected audio and video files.</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>
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 int MAX_LOGGED_EXCLUDED_FILE_EXAMPLES = 3;
@@ -45,12 +47,22 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
{
INDEXABLE,
EXCLUDED,
MEDIA_NOT_SUPPORTED,
IMAGE_NOT_SUPPORTED,
TRANSCRIPTION_PROVIDER_MISSING,
TRANSCRIPTION_PROVIDER_NOT_ALLOWED,
TOO_LARGE,
}
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 />
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 enumerationStartedUtc = DateTimeOffset.UtcNow;
var inputFiles = this.GetInputFiles(dataSource);
var inputFiles = this.GetInputFiles(dataSource, manifest);
var enumerationDuration = DateTimeOffset.UtcNow - enumerationStartedUtc;
if (inputFiles.UnreachableRootReason is { } unreachableRootReason)
return this.StopWithoutRoot(context, dataSource, unreachableRootReason);
@@ -133,6 +145,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
progress.RecordStillUnreadable(filePath, permanentFailure);
await progress.CompleteRunAsync(metadataSnapshot.SourceHash, "data source finished after removing missing files", token);
this.CleanUpTranscripts(context, progress);
return IndexedRunOutcome.DONE;
}
@@ -181,7 +194,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
var fingerprint = metadataSnapshot.FileHashes[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 startedAtUtc = DateTimeOffset.UtcNow;
@@ -196,6 +209,14 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
ShortHash(fingerprint),
progress.DoneDocuments + 1,
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);
token.ThrowIfCancellationRequested();
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));
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(
"Embedded file '{FilePath}' for data source '{DataSourceName}' ({DataSourceId}) successfully. Chunks={ChunkCount}, DurationMs={DurationMs}.",
file.FullName,
@@ -246,6 +272,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
}
await progress.CompleteRunAsync(metadataSnapshot.SourceHash, "data source embedding run finished", token);
this.CleanUpTranscripts(context, progress);
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}.",
dataSource.Name,
@@ -355,6 +382,79 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
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)
{
var manifest = context.Manifest;
@@ -363,7 +463,9 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
{
await context.DeleteDocumentPointsAsync(removedFilePath, token);
await context.IndexStore.DeleteFileAsync(context.DataSource.Id, removedFilePath, token);
var removedTranscriptKey = manifest.Files.GetValueOrDefault(removedFilePath)?.TranscriptKey;
manifest.Files.Remove(removedFilePath);
ForgetTranscript(DataSourceTranscriptStore.TryForDataSource(context.DataSource.Id), manifest, removedTranscriptKey, logger);
removedFiles++;
logger.LogInformation(
"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);
}
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 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))
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 mediaTranscriptionAvailability = new Lazy<MediaTranscriptionAvailability>(() => mediaTranscription ?? this.GetMediaTranscriptionAvailability(dataSource));
switch (dataSource)
{
@@ -507,13 +713,13 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
var file = new FileInfo(localFile.FilePath);
// 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);
return result;
case DataSourceLocalDirectory localDirectory when Directory.Exists(localDirectory.Path):
this.EnumerateAccessibleFiles(localDirectory, result);
this.EnumerateAccessibleFiles(localDirectory, manifest, mediaTranscriptionAvailability, result);
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);
@@ -537,9 +743,10 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
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 rules = new InputFileRules(selection, manifest, mediaTranscription);
//
// 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;
}
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;
excludedFiles++;
@@ -593,11 +810,11 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
}
/// <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>
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)
{
case RagFileIndexingDecision.INDEXABLE:
@@ -607,8 +824,16 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
case RagFileIndexingDecision.EXCLUDED:
break;
case RagFileIndexingDecision.MEDIA_NOT_SUPPORTED:
result.AddIgnoredFile(file.FullName, DataSourceEmbeddingIgnoreReason.MEDIA_NOT_SUPPORTED);
case RagFileIndexingDecision.IMAGE_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;
case RagFileIndexingDecision.TOO_LARGE:
@@ -630,24 +855,24 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE);
}
private RagFileIndexingDecision GetRagFileIndexingDecision(FileInfo file, FolderFileSelection? selection)
private RagFileIndexingDecision GetRagFileIndexingDecision(FileInfo file, InputFileRules rules)
{
if (this.IsSkippedRagFile(file))
return RagFileIndexingDecision.EXCLUDED;
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
// transcribe them yet:
// Before any transcription: a video of several gigabytes is left out like every other
// file above the limit, rather than sent to the transcription provider.
//
if (FileTypes.IsAllowedPath(file.FullName, FileTypes.AUDIO, FileTypes.VIDEO))
return RagFileIndexingDecision.MEDIA_NOT_SUPPORTED;
if (selection?.IsTooLarge(file) is true)
if (rules.Selection?.IsTooLarge(file) is true)
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
// 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
@@ -655,6 +880,39 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
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)
{
if (FileExclusions.IsAlwaysExcludedFile(file.Name))
@@ -731,7 +989,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return BuildMetadataHash(parts);
}
private static string BuildFileMetadataHash(FileInfo file)
internal static string BuildFileMetadataHash(FileInfo file)
{
file.Refresh();
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="file">The file.</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>
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,
this.CreateEmbeddingStateFile(dataSource, file, fingerprint, 0, DateTimeOffset.UtcNow),
this.CreateEmbeddingStateFile(dataSource, file, fingerprint, 0, DateTimeOffset.UtcNow) with { TranscriptKey = transcript?.Key },
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)
{
@@ -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>
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>
public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner)
@@ -411,14 +411,31 @@ public sealed class MediaTranscriptionService(
/// <param name="mediaPath">Audio or video file path.</param>
/// <param name="token">Caller cancellation token.</param>
/// <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();
var operation = this.CreateOperation(null, token);
try
{
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: false);
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: false, provider);
}
finally
{
@@ -458,8 +475,9 @@ public sealed class MediaTranscriptionService(
/// <param name="mediaPath">Source media path.</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="checkedProvider">The provider a caller checked itself, or null for the configured one.</param>
/// <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");
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."));
}
var providerSettings = this.ResolveProvider();
var providerSettings = checkedProvider ?? this.GetUsableProvider();
if (providerSettings is null)
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}",
fileName,
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
// message says whether to wait, to check the API key, or to ask the provider for
// 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.")
: providerResult.ErrorMessage);
: providerResult.ErrorMessage;
return providerResult.FileRejected
? MediaTranscriptionResult.Rejected(failureMessage)
: MediaTranscriptionResult.Failed(failureMessage);
}
return MediaTranscriptionResult.Succeeded(providerResult.Text.Trim());
@@ -719,7 +741,12 @@ public sealed class MediaTranscriptionService(
}
/// <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;
if (string.IsNullOrWhiteSpace(providerId))
@@ -735,6 +762,19 @@ public sealed class MediaTranscriptionService(
: 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>
/// <param name="target">Optional visible media import target.</param>
/// <param name="token">Caller token linked to the operation.</param>