mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 13:27:21 +00:00
Co-authored-by: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com>
258 lines
12 KiB
C#
258 lines
12 KiB
C#
using AIStudio.Settings;
|
|
using AIStudio.Settings.DataModel;
|
|
using AIStudio.Tools;
|
|
using AIStudio.Tools.Media;
|
|
using AIStudio.Tools.Rust;
|
|
using AIStudio.Tools.Services;
|
|
using AIStudio.Tools.Services.Indexing;
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace AIStudio.Tests.Tools;
|
|
|
|
/// <summary>
|
|
/// Checks which audio and video files a local data source transcribes, and how it treats those it cannot.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Transcribing costs money with every cloud provider. A media file is therefore only sent when a
|
|
/// provider exists which the data source allows, and a file which cannot be transcribed for a
|
|
/// reason of its own is not sent again until it changes.
|
|
/// </remarks>
|
|
public sealed class MediaIndexingTests
|
|
{
|
|
private string testRoot = string.Empty;
|
|
|
|
[SetUp]
|
|
public void CreateTestRoot()
|
|
{
|
|
this.testRoot = Path.Combine(Path.GetTempPath(), $"ai-studio-media-indexing-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(this.testRoot);
|
|
}
|
|
|
|
[TearDown]
|
|
public void DeleteTestRoot()
|
|
{
|
|
if (Directory.Exists(this.testRoot))
|
|
Directory.Delete(this.testRoot, true);
|
|
}
|
|
|
|
[Test]
|
|
public void AnAllowedProviderTranscribesASelectedRecording()
|
|
{
|
|
var recording = this.CreateFile("meeting.mp3", 16);
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), Allowed());
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }));
|
|
Assert.That(result.IgnoredFiles, Is.Empty);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void WithoutAProviderANewRecordingWaitsForOne()
|
|
{
|
|
var recording = this.CreateFile("meeting.mp3", 16);
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), MediaTranscriptionAvailability.MISSING);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(result.Files, Is.Empty);
|
|
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING) }));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void AProviderBelowTheConfidenceLevelHearsNothing()
|
|
{
|
|
var recording = this.CreateFile("meeting.mp4", 16);
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), MediaTranscriptionAvailability.NOT_ALLOWED);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(result.Files, Is.Empty, "The recording would have gone to a provider the data source does not allow.");
|
|
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_NOT_ALLOWED) }));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void AnIndexedRecordingStaysWhenTheProviderIsGone()
|
|
{
|
|
var recording = this.CreateFile("meeting.mp3", 16);
|
|
var manifest = new DataSourceEmbeddingManifest();
|
|
manifest.Files[recording] = new EmbeddedFileRecord(FileSourceIndexer.BuildFileMetadataHash(new FileInfo(recording)), 16, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, new string('a', 64));
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder("*"), manifest, MediaTranscriptionAvailability.MISSING);
|
|
|
|
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }), "An unchanged recording is not read, so it needs no provider, and leaving it out would remove it from the index.");
|
|
}
|
|
|
|
[Test]
|
|
public void ARecordingWhichFailedForGoodStaysWithItsReasonWhenTheProviderIsGone()
|
|
{
|
|
var recording = this.CreateFile("silence.wav", 16);
|
|
var manifest = new DataSourceEmbeddingManifest();
|
|
manifest.PermanentFailures[recording] = new PermanentIndexingFailureRecord(FileSourceIndexer.BuildFileMetadataHash(new FileInfo(recording)), FileExtractionErrorCode.NO_CONTENT, "No content", DateTimeOffset.UtcNow);
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder("*"), manifest, MediaTranscriptionAvailability.NOT_ALLOWED);
|
|
|
|
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }), "The run lists it with its stored reason instead of reading it.");
|
|
}
|
|
|
|
[Test]
|
|
public void AChangedRecordingNeedsAProviderAgain()
|
|
{
|
|
var recording = this.CreateFile("meeting.mp3", 16);
|
|
var manifest = new DataSourceEmbeddingManifest();
|
|
manifest.Files[recording] = new EmbeddedFileRecord("THE-FINGERPRINT-BEFORE-THE-CHANGE", 8, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, new string('a', 64));
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder("*"), manifest, MediaTranscriptionAvailability.MISSING);
|
|
|
|
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TRANSCRIPTION_PROVIDER_MISSING) }));
|
|
}
|
|
|
|
[Test]
|
|
public void ARecordingAboveTheLimitIsNeverTranscribed()
|
|
{
|
|
var recording = this.CreateFile("conference.mkv", FolderFileSelection.MegabytesToBytes(1) + 1);
|
|
var dataSource = this.Folder("*") with { MaxFileSizeMegabytes = 1 };
|
|
|
|
var result = CreateIndexer().GetInputFiles(dataSource, new DataSourceEmbeddingManifest(), Allowed());
|
|
|
|
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(recording, DataSourceEmbeddingIgnoreReason.TOO_LARGE) }));
|
|
}
|
|
|
|
[Test]
|
|
public void ImagesAreStillLeftOut()
|
|
{
|
|
var image = this.CreateFile("photo.png", 16);
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), Allowed());
|
|
|
|
Assert.That(result.IgnoredFiles, Is.EqualTo(new[] { new DataSourceEmbeddingIgnoredFile(image, DataSourceEmbeddingIgnoreReason.IMAGE_NOT_SUPPORTED) }));
|
|
}
|
|
|
|
[TestCase("meeting.mp3")]
|
|
[TestCase("meeting.mp4")]
|
|
[TestCase("meeting.wav")]
|
|
[TestCase("meeting.webm")]
|
|
public void TheDefaultPatternsSelectNoRecording(string fileName)
|
|
{
|
|
var recording = this.CreateFile(fileName, 16);
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder(DataSourceLocalDirectory.DEFAULT_FILE_PATTERNS), new DataSourceEmbeddingManifest(), Allowed());
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(result.Files, Is.Empty, "Existing data sources must not start transcribing, and paying for it, after an update.");
|
|
Assert.That(FileNamePatterns.Matches(recording, FileNamePatterns.Parse(":text:")), Is.False);
|
|
});
|
|
}
|
|
|
|
[TestCase("*")]
|
|
[TestCase("*.mp3")]
|
|
[TestCase("*.pdf;*.mp3")]
|
|
public void APatternWhichNamesRecordingsSelectsThem(string patterns)
|
|
{
|
|
var recording = this.CreateFile("meeting.mp3", 16);
|
|
|
|
var result = CreateIndexer().GetInputFiles(this.Folder(patterns), new DataSourceEmbeddingManifest(), Allowed());
|
|
|
|
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }));
|
|
}
|
|
|
|
[Test]
|
|
public void TheBatchTranscriptOfASelectedRecordingIsNoDocumentOfItsOwn()
|
|
{
|
|
var recording = this.CreateFile("meeting.mp3", 16);
|
|
var transcript = this.CreateFile(Path.GetFileName(MediaSidecarTranscripts.GetFilePath(recording)), 16);
|
|
|
|
var everything = CreateIndexer().GetInputFiles(this.Folder("*"), new DataSourceEmbeddingManifest(), Allowed());
|
|
var markdownOnly = CreateIndexer().GetInputFiles(this.Folder("*.md"), new DataSourceEmbeddingManifest(), Allowed());
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(everything.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }), "The transcript would duplicate the text of the recording.");
|
|
Assert.That(markdownOnly.Files.Select(file => file.FullName), Is.EqualTo(new[] { transcript }), "Without the recording, the transcript is a document like every other.");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ASingleRecordingCanBeADataSource()
|
|
{
|
|
var recording = this.CreateFile("interview.m4a", 16);
|
|
var dataSource = new DataSourceLocalFile { Type = DataSourceType.LOCAL_FILE, FilePath = recording };
|
|
|
|
var result = CreateIndexer().GetInputFiles(dataSource, new DataSourceEmbeddingManifest(), Allowed());
|
|
|
|
Assert.That(result.Files.Select(file => file.FullName), Is.EqualTo(new[] { recording }));
|
|
}
|
|
|
|
[Test]
|
|
public void AReadyTranscriptIsRead()
|
|
{
|
|
var transcript = PersistentMediaTranscriptResult.Ready(new MediaTranscriptSlot("/data/mediaTranscripts/source/key.md", "key"), reused: true);
|
|
Assert.That(FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None), Is.EqualTo(new MediaTranscriptSlot("/data/mediaTranscripts/source/key.md", "key")));
|
|
}
|
|
|
|
[TestCase(PersistentMediaTranscriptStatus.EMPTY)]
|
|
[TestCase(PersistentMediaTranscriptStatus.NO_AUDIBLE_SIGNAL)]
|
|
public void ARecordingWithoutTextIsRecordedForGood(PersistentMediaTranscriptStatus status)
|
|
{
|
|
var transcript = PersistentMediaTranscriptResult.Unavailable(status, null, "Nothing to read.");
|
|
|
|
var exception = Assert.Throws<FileExtractionException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
|
|
Assert.That(exception!.Code, Is.EqualTo(FileExtractionErrorCode.NO_CONTENT), "Asked again with every run, the provider would be paid again with every run.");
|
|
}
|
|
|
|
[Test]
|
|
public void ARecordingTheMediaPipelineCannotReadIsRecordedForGood()
|
|
{
|
|
var transcript = PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED, null, "The container is damaged.", MediaJobErrorCode.DAMAGED_CONTAINER);
|
|
|
|
var exception = Assert.Throws<FileExtractionException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
|
|
Assert.That(exception!.Code, Is.EqualTo(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED));
|
|
}
|
|
|
|
[Test]
|
|
public void ARecordingTheProviderRefusedIsRecordedForGood()
|
|
{
|
|
var transcript = PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.REJECTED, null, "The provider 'Cloud' reported an error: Audio file might be corrupted or unsupported");
|
|
|
|
var exception = Assert.Throws<FileExtractionException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
|
|
Assert.That(exception!.Code, Is.EqualTo(FileExtractionErrorCode.MEDIA_PROCESSING_FAILED), "Tried again with every run, the same file would be uploaded again with every run.");
|
|
}
|
|
|
|
[TestCase(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED)]
|
|
[TestCase(PersistentMediaTranscriptStatus.STORE_FAILED)]
|
|
[TestCase(PersistentMediaTranscriptStatus.SOURCE_CHANGED)]
|
|
[TestCase(PersistentMediaTranscriptStatus.NO_PROVIDER)]
|
|
public void AFailureWhichMayPassIsTriedAgain(PersistentMediaTranscriptStatus status)
|
|
{
|
|
var transcript = PersistentMediaTranscriptResult.Unavailable(status, null, "Try again later.");
|
|
Assert.Throws<IOException>(() => FileSourceIndexer.GetTranscriptOrThrow(transcript, CancellationToken.None));
|
|
}
|
|
|
|
private DataSourceLocalDirectory Folder(string patterns) => new()
|
|
{
|
|
Type = DataSourceType.LOCAL_DIRECTORY,
|
|
Path = this.testRoot,
|
|
FilePatterns = patterns,
|
|
};
|
|
|
|
private string CreateFile(string fileName, long length)
|
|
{
|
|
var filePath = Path.Combine(this.testRoot, fileName);
|
|
using var stream = File.Create(filePath);
|
|
stream.SetLength(length);
|
|
return filePath;
|
|
}
|
|
|
|
private static MediaTranscriptionAvailability Allowed() => MediaTranscriptionAvailability.Allowed(new TranscriptionProvider { Id = "test-transcription-provider" });
|
|
|
|
// Finding the files reads none of them, so it needs neither the runtime nor a transcription provider:
|
|
private static FileSourceIndexer CreateIndexer() => new(null!, null!, null!, null!, NullLogger.Instance);
|
|
} |