mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 19:08:45 +00:00
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:
1 parent
8642ce2d81
commit
752f7d828a
82 files changed
+3231
-302
No files matched your search
@@ -46,6 +46,7 @@ public static class DataBackupContents
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "visualBriefings/", DataBackupEntryKind.VISUAL_BRIEFINGS),
|
||||
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, "mediaTranscripts/", DataBackupEntryKind.MEDIA_TRANSCRIPTS),
|
||||
|
||||
new(DataBackupLocation.CONFIG_DIRECTORY, "settings.v*.json", DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS),
|
||||
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.VISUAL_BRIEFINGS
|
||||
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>
|
||||
/// Whether the entry is a folder rather than a single file.
|
||||
|
||||
@@ -45,6 +45,11 @@ public enum DataBackupEntryKind
|
||||
/// </summary>
|
||||
VECTOR_STORES,
|
||||
|
||||
/// <summary>
|
||||
/// The transcripts of the audio and video files of the data sources. Making them again costs money.
|
||||
/// </summary>
|
||||
MEDIA_TRANSCRIPTS,
|
||||
|
||||
/// <summary>
|
||||
/// The copies of the settings which every version of AI Studio keeps for itself.
|
||||
/// </summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace AIStudio.Tools.DataBackup;
|
||||
/// <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="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>
|
||||
public sealed record DataBackupManifest(
|
||||
int FormatVersion,
|
||||
|
||||
@@ -101,7 +101,7 @@ public static class DataBackupWriter
|
||||
var files = new List<BackupFile>();
|
||||
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.DataDirectory;
|
||||
|
||||
|
||||
@@ -147,15 +147,16 @@ public static class DataRestorePreparer
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// replaced only when the backup holds both of them, cf. DataBackupManifest.IncludesDataSourceIndexes.
|
||||
/// or whether the version of AI Studio which made the backup knew it at all. The indexes and the
|
||||
/// media transcripts are replaced only when the backup holds the indexes, cf.
|
||||
/// DataBackupManifest.IncludesDataSourceIndexes and DataBackupEntry.ComesFromIndexCopies.
|
||||
/// </remarks>
|
||||
private static List<DataRestoreElement> DetermineElements(DataBackupManifest manifest, DataRestoreTarget target)
|
||||
{
|
||||
var elements = new List<DataRestoreElement>();
|
||||
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;
|
||||
|
||||
var entryName = DataBackupFormat.EntryName(entry.Location, entry.RelativePath);
|
||||
|
||||
@@ -11,4 +11,5 @@ public sealed record EmbeddingStateFile(
|
||||
DateTimeOffset CreationUtc,
|
||||
DateTimeOffset LastWriteUtc,
|
||||
DateTimeOffset EmbeddedAtUtc,
|
||||
int ChunkCount);
|
||||
int ChunkCount,
|
||||
string? TranscriptKey = null);
|
||||
@@ -26,6 +26,8 @@ internal sealed class EmbeddingStateFileEntity
|
||||
|
||||
public int ChunkCount { get; set; }
|
||||
|
||||
public string? TranscriptKey { get; set; }
|
||||
|
||||
public EmbeddingStateDataSourceEntity? DataSource { 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.EmbeddedAtUtc).HasColumnName("embedded_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
|
||||
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.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.DropDataSourceName))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.MailboxIndex))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.MediaTranscriptKey))]
|
||||
public static async Task MigrateAsync(IndexStoreDbContext context, CancellationToken token)
|
||||
{
|
||||
await context.Database.MigrateAsync(token);
|
||||
|
||||
+35
@@ -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;");
|
||||
}
|
||||
}
|
||||
+4
@@ -119,6 +119,10 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("relative_path");
|
||||
|
||||
entity.Property<string>("TranscriptKey")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("transcript_key");
|
||||
|
||||
entity.HasKey("ParentFileId");
|
||||
|
||||
entity.HasIndex("AbsolutePath")
|
||||
|
||||
+3
-1
@@ -148,7 +148,8 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
|
||||
file.FileSize,
|
||||
file.LastWriteUtc,
|
||||
file.EmbeddedAtUtc,
|
||||
file.ChunkCount);
|
||||
file.ChunkCount,
|
||||
file.TranscriptKey);
|
||||
}
|
||||
|
||||
var permanentFailures = await context.PermanentIndexingFailures
|
||||
@@ -690,6 +691,7 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
|
||||
fileEntity.LastWriteUtc = file.LastWriteUtc;
|
||||
fileEntity.EmbeddedAtUtc = file.EmbeddedAtUtc;
|
||||
fileEntity.ChunkCount = file.ChunkCount;
|
||||
fileEntity.TranscriptKey = file.TranscriptKey;
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
/// 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
|
||||
|
||||
@@ -37,6 +37,7 @@ internal static class FileExtractionErrorCodeExtensions
|
||||
FileExtractionErrorCode.FORMAT_DETECTION_FAILED => true,
|
||||
FileExtractionErrorCode.EXECUTABLE_REJECTED => 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
|
||||
// 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.EXECUTABLE_REJECTED => TB("Executable program"),
|
||||
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.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.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.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."),
|
||||
|
||||
//
|
||||
|
||||
@@ -80,6 +80,33 @@ internal static class FileNamePatterns
|
||||
&& 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)
|
||||
{
|
||||
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/<data source id>/<key>.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 '<media file>.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="UserMessage">Localized message suitable for display after a warning or failure.</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>
|
||||
/// <param name="text">Provider transcript.</param>
|
||||
@@ -20,6 +21,10 @@ public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Sta
|
||||
/// <param name="errorCode">Optional runtime error category.</param>
|
||||
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>
|
||||
/// <param name="userMessage">Localized visible warning.</param>
|
||||
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 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>
|
||||
|
||||
@@ -65,7 +65,7 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME)
|
||||
var transportTimestamp = FormatTransportTimestamp(now);
|
||||
var logLevel = logEntry.LogLevel.ToString();
|
||||
var category = logEntry.Category;
|
||||
var exceptionMessage = logEntry.Exception?.Message;
|
||||
var exceptionMessage = DescribeException(logEntry.Exception);
|
||||
var stackTrace = logEntry.Exception?.StackTrace;
|
||||
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>
|
||||
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
|
||||
{
|
||||
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> GetSelectedCloudTranscription { get; init; } = () => false;
|
||||
|
||||
public Func<EmbeddingProvider?> GetSelectedEmbeddingProvider { get; init; } = () => null;
|
||||
|
||||
public Func<ConfidenceLevel> GetConfidenceLevel { get; init; } = () => ConfidenceLevel.NONE;
|
||||
@@ -158,8 +160,8 @@ public sealed class DataSourceValidation
|
||||
if(!File.Exists(filePath))
|
||||
return TB("The file does not exist. Please select a valid file.");
|
||||
|
||||
if(!FileTypes.IsAllowedPath(filePath, FileTypes.DOCUMENT))
|
||||
return TB("The selected file is not a supported document or readable text file.");
|
||||
if(!FileTypes.IsAllowedPath(filePath, FileTypes.DOCUMENT, FileTypes.AUDIO, FileTypes.VIDEO))
|
||||
return TB("The selected file is not a supported document, readable text file, audio file, or video file.");
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -250,6 +252,9 @@ public sealed class DataSourceValidation
|
||||
|
||||
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)
|
||||
return TB("Please acknowledge that you are aware of the cloud embedding implications.");
|
||||
|
||||
|
||||
Reference in new issue
Block a user