using System.Collections.Concurrent; using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.Media; using AIStudio.Tools.Rust; using AIStudio.Tools.Security; using AIStudio.Tools.Services; namespace AIStudio.Assistants.BeyondFactsLab; /// /// Adds documents, recordings, and images to the material of a preparation of the BeyondFacts Lab, and removes them. /// /// /// A singleton, so that reading a long document or transcribing a recording goes on while the person looks at another /// page: the lab shows the material again when it comes back and listens to MaterialsChanged. The lab reads each file /// once. Of a document or a recording, it keeps only the text which passed the prompt injection filter of the runtime; /// of an image, a copy prepared for models; see BflMaterial. /// /// The store of the lab. /// The runtime, which reads the documents and transcripts and prepares the images. /// The prompt injection filter, which tells the person what it removed. /// The message bus, for what the person needs to know. /// The settings, which name the transcription provider and the confidence the lab demands. /// The transcription of recordings. /// The registry of running assistant sessions, which shows that the lab is at work. /// The logger. public sealed class BflMaterialService(BeyondFactsLabStore store, RustService rustService, PromptInjectionGuardService guardService, MessageBus messageBus, SettingsManager settingsManager, MediaTranscriptionService mediaTranscriptionService, AssistantSessionService sessionService, ILogger logger) { /// /// How many characters of the text of a document or a transcript the lab keeps at most. /// /// /// The size of a file says little about its text: slides with many pictures take hundreds of megabytes for a few /// pages of text. So the lab limits the text instead, to what a conversation can ever take of one material, see /// BflMaterialBudget. It keeps the beginning and leaves out the rest, which never reaches memory either. The /// transcript of a recording of three hours takes less than half of it. /// public const int MAX_TEXT_CHARACTERS = BflMaterialBudget.MAX_CHARACTERS; private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflMaterialService).Namespace, nameof(BflMaterialService)); private static string TA(string fallbackEN) => Tools.PluginSystem.I18N.I.TA(fallbackEN, typeof(BflMaterialService).Namespace, nameof(BflMaterialService)); /// /// Serializes the imports of each preparation, so that two of them never check the same file at once. /// private readonly ConcurrentDictionary importLocks = new(); /// /// The file the lab reads right now, by the ID of its preparation. /// private readonly ConcurrentDictionary materialsInProgress = new(); /// /// Happens whenever the material of a preparation changed, or an import began or ended. /// /// /// Called on the thread of the import. A page renders through InvokeAsync. /// public event Func? MaterialsChanged; /// /// Gets the file the lab reads for a preparation right now. /// /// The ID of the preparation. /// The file, or null while the lab reads nothing for the preparation. public BflMaterialInProgress? MaterialInProgress(Guid projectId) => this.materialsInProgress.GetValueOrDefault(projectId); /// /// Gets the confidence a transcription provider needs for the recordings of the lab: the same as the providers of /// its conversations, since a recording is as confidential as a paper. /// public ConfidenceLevel MinimumTranscriptionConfidence => settingsManager.GetMinimumConfidenceLevel(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT); /// /// Gets what the person needs to know when no transcription provider may hear the recordings of the lab. /// public string NoTranscriptionProviderMessage => string.Format(TA("Recordings need a transcription provider with at least the confidence '{0}'. Please select one in the transcription settings of AI Studio."), this.MinimumTranscriptionConfidence.Name); /// /// Gets the transcription provider which may hear the recordings of the lab. /// /// The provider of AI Studio, or null when there is none or it falls short of MinimumTranscriptionConfidence. public TranscriptionProvider? UsableTranscriptionProvider() { var provider = mediaTranscriptionService.GetUsableProvider(); return provider is not null && settingsManager.IsProviderUsable(provider.UsedLLMProvider, this.MinimumTranscriptionConfidence) ? provider : null; } /// /// Reads documents, recordings, and images and adds them to the material of a preparation. /// /// /// One action for the prompt injection filter, so that the person hears once about what it removed from all of /// them. A file the preparation has already and one which cannot be read are left out, and the person learns why; /// of a long text, the lab keeps the beginning, see MAX_TEXT_CHARACTERS. The method itself does not fail. /// /// The ID of the preparation. /// The documents, recordings, and images. /// Makes sure Pandoc is there for the formats which need it; it belongs to the window of the person. public async Task ImportAsync(Guid projectId, IReadOnlyList paths, PandocAvailabilityService pandocAvailability) { if (paths.Count is 0) return; var gate = this.importLocks.GetOrAdd(projectId, _ => new(1, 1)); await gate.WaitAsync(); var sessionKey = ImportSessionKey(projectId); var sessionId = Guid.Empty; try { // The navigation and the tile of the lab show that the lab is at work, as while it answers in a conversation. // Nothing cancels an import, so the source stays unused; the registry disposes of it when the import ends: sessionId = (await sessionService.TryBeginAsync(sessionKey, Tools.Components.BEYOND_FACTS_LAB_ASSISTANT.Name, new CancellationTokenSource(), null, new(StringComparer.Ordinal))).SessionId; HashSet knownHashes; try { knownHashes = (await store.ListMaterialsAsync(projectId)) .Select(entry => entry.Material?.SourceHash) .OfType() .ToHashSet(StringComparer.OrdinalIgnoreCase); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { logger.LogError(exception, "Could not list the material of the preparation '{ProjectId}' of the BeyondFacts Lab.", projectId); await messageBus.SendError(new(Icons.Material.Filled.Description, TA("AI Studio could not read the material of this preparation. Please try again."))); return; } await using var promptInjectionScope = guardService.BeginAction(); foreach (var path in paths) { // The page lets the person choose documents, recordings, and images only, see AttachDocuments; this keeps out whatever else arrives: if (KindOf(path) is not { } kind) { logger.LogWarning("The BeyondFacts Lab does not read the file '{Path}', since it is no document, recording, or image for models.", path); continue; } this.materialsInProgress[projectId] = new(BeyondFactsLabStore.MaterialName(path), kind); await this.NotifyAsync(projectId); await this.ImportFileAsync(projectId, path, kind, knownHashes, pandocAvailability, promptInjectionScope); } } finally { this.materialsInProgress.TryRemove(projectId, out _); if (sessionId != Guid.Empty) { await sessionService.CompleteAsync(sessionKey, sessionId, AssistantSessionStatus.COMPLETED, string.Empty, null, new(StringComparer.Ordinal)); // The material shows what came of the import; the registry only showed that the lab was at work: await sessionService.ClearAsync(sessionKey); } gate.Release(); await this.NotifyAsync(projectId); } } /// /// Deletes a material of a preparation. The file itself stays where it is. /// /// The ID of the preparation. /// The ID of the material. /// False when the material could not be deleted; the person learned why. public async Task DeleteAsync(Guid projectId, Guid materialId) { try { await store.DeleteMaterialAsync(projectId, materialId); } catch (FileNotFoundException) { // The person deleted the preparation meanwhile, with all its material. } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) { logger.LogError(exception, "Could not delete the material '{MaterialId}' of the preparation '{ProjectId}' of the BeyondFacts Lab.", materialId, projectId); await messageBus.SendError(new(Icons.Material.Filled.DeleteForever, TA("AI Studio could not delete the material. Please try again."))); return false; } await this.NotifyAsync(projectId); return true; } /// /// Gets what a file becomes as material. /// /// The file. /// The kind of material, or null for a file the lab does not read. private static BflMaterialKind? KindOf(string path) { if (FileTypes.IsAllowedPath(path, FileTypes.DOCUMENT)) return BflMaterialKind.DOCUMENT; if (PersistentMediaTranscript.IsMediaPath(path)) return BflMaterialKind.RECORDING; // Only the formats the runtime prepares for models, not HEIC, AVIF, or SVG: if (FileTypes.IsAllowedPath(path, FileTypes.MODEL_IMAGE)) return BflMaterialKind.IMAGE; return null; } /// /// Reads one document, recording, or image and adds it to the material of a preparation. /// /// The ID of the preparation. /// The file. /// What the file becomes. /// The hashes of the files the preparation has already; the new one joins them. /// Makes sure Pandoc is there for the formats which need it. /// The action the file is read for. private async Task ImportFileAsync(Guid projectId, string path, BflMaterialKind kind, HashSet knownHashes, PandocAvailabilityService pandocAvailability, PromptInjectionReportingScope promptInjectionScope) { var name = BeyondFactsLabStore.MaterialName(path); long size; string hash; try { size = new FileInfo(path).Length; hash = await FileContentHash.ComputeAsync(path, CancellationToken.None); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { logger.LogWarning(exception, "The BeyondFacts Lab could not open the file '{Path}'.", path); await messageBus.SendError(new(Icons.Material.Filled.Description, string.Format(TB("AI Studio could not open the file '{0}'."), name))); return; } // Before anything is read, and above all before a recording is paid for: if (!knownHashes.Add(hash)) { await messageBus.SendInfo(new(Icons.Material.Filled.Description, string.Format(TB("The material of this preparation contains '{0}' already."), name))); return; } if (kind is BflMaterialKind.IMAGE) { if (!await this.AddImageAsync(projectId, path, name, size, hash)) knownHashes.Remove(hash); return; } // Failures, missing pages, and a misleading file extension are reported there: var extraction = kind is BflMaterialKind.RECORDING ? await this.ReadRecordingAsync(path, name, promptInjectionScope) : await UserFile.LoadFileData(path, rustService, pandocAvailability, promptInjectionScope, MAX_TEXT_CHARACTERS); if (!extraction.HasUsableContent) { knownHashes.Remove(hash); return; } var material = new BflMaterial { Name = name, Kind = kind, SourceBytes = size, SourceHash = hash, IsIncomplete = extraction.Outcome is FileExtractionOutcome.PARTIAL, IsShortened = extraction.IsShortened, FailedPages = [.. extraction.FailedPages], FilteredPassages = extraction.PromptInjectionRedactedCount, }; try { var addition = await store.AddMaterialAsync(projectId, material, extraction.Content); if (addition.IsDuplicate) await messageBus.SendInfo(new(Icons.Material.Filled.Description, string.Format(TB("The material of this preparation contains '{0}' already."), addition.Material.Name))); else if (addition.Material.IsShortened) await messageBus.SendInfo(new(Icons.Material.Filled.ContentCut, string.Format(TB("The lab keeps only the beginning of '{0}': a conversation never takes more of one material."), addition.Material.Name))); } catch (FileNotFoundException) { // The person deleted the preparation while the lab read the file; nothing is left to add it to. knownHashes.Remove(hash); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) { knownHashes.Remove(hash); logger.LogError(exception, "The BeyondFacts Lab could not store the material '{Name}' of the preparation '{ProjectId}'.", name, projectId); await messageBus.SendError(new(Icons.Material.Filled.SaveAs, string.Format(TA("AI Studio could not save the material '{0}'. Please try again."), name))); } } /// /// Prepares an image for models and adds the copy to the material of a preparation. /// /// /// The runtime turns the image upright, scales it down to the edge models take, and encodes it anew as JPEG or PNG, /// which leaves out its metadata, such as the place where a photo was taken. The copy is as large as any provider /// takes; a provider which takes less gets a smaller one when the lab sends it, see ModelImageService. Like an image /// in a chat, it does not pass the prompt injection filter, which reads text only; see BflConversationService.WithImages. /// /// The ID of the preparation. /// The image. /// The name of the image as the person knows it. /// The size of the image in bytes. /// The hash of the image. /// True when the preparation has the image now, also as a material it had already. private async Task AddImageAsync(Guid projectId, string path, string name, long size, string hash) { var prepared = await rustService.PrepareImageForModelAsync(path, IImageSourceExtensions.MAX_IMAGE_SIZE_BYTES); if (!prepared.IsPrepared) { logger.LogWarning("The BeyondFacts Lab could not prepare the image '{Path}': {ErrorCode}.", path, prepared.ErrorCode); await messageBus.SendError(new(Icons.Material.Filled.Image, prepared.ErrorCode.ToUserMessage(name))); return false; } var material = new BflMaterial { Name = name, Kind = BflMaterialKind.IMAGE, Image = new() { Extension = prepared.MimeType is "image/png" ? "png" : "jpg", Width = (int)prepared.Width, Height = (int)prepared.Height, }, SourceBytes = size, SourceHash = hash, }; try { var addition = await store.AddImageMaterialAsync(projectId, material, Convert.FromBase64String(prepared.Base64)); if (addition.IsDuplicate) await messageBus.SendInfo(new(Icons.Material.Filled.Image, string.Format(TB("The material of this preparation contains '{0}' already."), addition.Material.Name))); return true; } catch (FileNotFoundException) { // The person deleted the preparation while the lab prepared the image; nothing is left to add it to. return false; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or FormatException or ArgumentException) { logger.LogError(exception, "The BeyondFacts Lab could not store the image '{Name}' of the preparation '{ProjectId}'.", name, projectId); await messageBus.SendError(new(Icons.Material.Filled.SaveAs, string.Format(TA("AI Studio could not save the material '{0}'. Please try again."), name))); return false; } } /// /// Transcribes a recording and reads its transcript through the prompt injection filter. /// /// /// The transcription provider is decided here again and passed on as it is, so that a change of the settings since /// the person confirmed cannot send the recording to a provider the lab does not trust. The transcript waits in a /// BflTranscriptLocation, which is gone afterward: the material keeps the filtered text only. /// /// The recording. /// The name of the recording as the person knows it. /// The action the recording is read for. /// The filtered text of the transcript; a failure the person learned about already. private async Task ReadRecordingAsync(string path, string name, PromptInjectionReportingScope promptInjectionScope) { if (this.UsableTranscriptionProvider() is not { } provider) { await messageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.NoTranscriptionProviderMessage)); return FileExtractionResult.Failed(FileExtractionErrorCode.INVALID_REQUEST, "No transcription provider may hear the recordings of the BeyondFacts Lab."); } if (SettingsManager.DataDirectory is not { } dataDirectory) return FileExtractionResult.Failed(FileExtractionErrorCode.INTERNAL, "The data directory of AI Studio is not known yet."); var location = new BflTranscriptLocation(dataDirectory); try { var transcript = await PersistentMediaTranscript.ReadOrCreateAsync(location, path, provider, mediaTranscriptionService.TranscribeAsync, logger, CancellationToken.None); if (transcript.Status is not PersistentMediaTranscriptStatus.READY) { logger.LogWarning("The BeyondFacts Lab could not transcribe the recording '{Path}': {Status}.", path, transcript.Status); await messageBus.SendError(new(Icons.Material.Filled.VoiceChat, string.Format(TB("AI Studio could not transcribe the recording '{0}': {1}"), name, transcript.UserMessage))); return FileExtractionResult.Failed(FileExtractionErrorCode.INTERNAL, transcript.UserMessage); } var extraction = await PersistentMediaTranscript.ReadTranscriptAsync(rustService, transcript, path, promptInjectionScope, CancellationToken.None, MAX_TEXT_CHARACTERS); if (!extraction.HasUsableContent) { logger.LogError("The BeyondFacts Lab could not read the transcript of the recording '{Path}': code={ErrorCode}, message='{ErrorMessage}'.", path, extraction.ErrorCode, extraction.ErrorMessage); await messageBus.SendError(new(Icons.Material.Filled.VoiceChat, extraction.ToUserMessage(name))); } return extraction; } finally { if (!location.TryDelete()) logger.LogWarning("Could not delete the transcript in '{Folder}'; the next start of AI Studio removes it.", location.FolderPath); } } /// /// Gets the slot in the registry of assistant sessions which shows that the lab reads material for a preparation. /// /// /// The conversations of the steps have slots of their own, keyed by the folders of their steps, see /// BflStepThreadLocation.Key; no step folder is called "materials". /// /// The ID of the preparation. /// The key of the slot. internal static AssistantSessionKey ImportSessionKey(Guid projectId) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, $"{projectId:D}/materials"); /// /// Tells every page which listens that the material of a preparation changed. /// /// The ID of the preparation. private async Task NotifyAsync(Guid projectId) { if (this.MaterialsChanged is not { } handlers) return; foreach (var handler in handlers.GetInvocationList().Cast>()) { try { await handler(projectId); } catch (Exception exception) { // A page which went away meanwhile must not stop the import: logger.LogDebug(exception, "A page of the BeyondFacts Lab could not show a change of the material."); } } } }