using System.Text.Json; using AIStudio.Chat; namespace AIStudio.Assistants.ModelComparison; public sealed partial class ModelComparisonStore { /// /// The folder of the comparison series, one folder per series. /// private const string SERIES_DIRECTORY_NAME = "series"; /// /// The file of a series in its folder. /// private const string SERIES_FILE_NAME = "series.json"; /// /// The folder of the chats in the folder of a series. /// private const string CHATS_DIRECTORY_NAME = "chats"; /// /// Gets the folder of all comparison series. /// private string AllSeriesDirectory => Path.Combine(this.RootDirectory, SERIES_DIRECTORY_NAME); /// /// Gets the folder of a series. /// /// The ID of the series. private string SeriesDirectory(Guid seriesId) => Path.Combine(this.AllSeriesDirectory, seriesId.ToString("D")); /// /// Gets the file of a series. /// /// The ID of the series. private string SeriesPath(Guid seriesId) => Path.Combine(this.SeriesDirectory(seriesId), SERIES_FILE_NAME); /// /// Gets the file of a chat of a series. /// /// The ID of the series. /// The ID of the chat. private string SeriesChatPath(Guid seriesId, Guid chatId) => Path.Combine(this.SeriesDirectory(seriesId), CHATS_DIRECTORY_NAME, $"{chatId:D}.json"); /// /// Lists the comparison series which this version can read, the one the user last worked on first. /// /// The cancellation token. /// The series. public async Task> ListSeriesAsync(CancellationToken token = default) { await this.InitializeAsync(token); if (!Directory.Exists(this.AllSeriesDirectory)) return []; List series = []; foreach (var directory in Directory.EnumerateDirectories(this.AllSeriesDirectory)) { token.ThrowIfCancellationRequested(); if (Guid.TryParseExact(Path.GetFileName(directory), "D", out var seriesId) && await this.LoadSeriesAsync(seriesId, token) is { } loaded) series.Add(loaded); } return [.. series.OrderByDescending(entry => entry.LastActivityUtc)]; } /// /// Loads a comparison series. /// /// The ID of the series. /// The cancellation token. /// The series, or null when it does not exist or this version cannot read it. public async Task LoadSeriesAsync(Guid seriesId, CancellationToken token = default) { await this.InitializeAsync(token); // The ID inside the file must not lead to another folder than the one it was read from: return await this.ReadJsonAsync(this.SeriesPath(seriesId), token) is { } series ? series with { Id = seriesId } : null; } /// /// Stores a new comparison series. /// /// The series. /// The cancellation token. /// The series has no ID. public async Task CreateSeriesAsync(ModelComparisonSeries series, CancellationToken token = default) { if (series.Id == Guid.Empty) throw new ArgumentException("A comparison series needs an ID.", nameof(series)); await this.InitializeAsync(token); var gate = this.GetLock(series.Id); await gate.WaitAsync(token); try { await WriteJsonAtomicAsync(this.SeriesPath(series.Id), series, token); } finally { gate.Release(); } } /// /// Stores a comparison series which exists already, in place of what was stored of it before. /// /// /// Guarded by the lock of the series, so that a series deleted meanwhile does not come back, e.g., with the verdict /// of its judge. A new series goes through CreateSeriesAsync. /// /// The series. /// The cancellation token. /// The series has no ID. /// The series does not exist, e.g., because the user deleted it. public async Task SaveSeriesAsync(ModelComparisonSeries series, CancellationToken token = default) { if (series.Id == Guid.Empty) throw new ArgumentException("A comparison series needs an ID.", nameof(series)); await this.InitializeAsync(token); var gate = this.GetLock(series.Id); await gate.WaitAsync(token); try { var seriesPath = this.SeriesPath(series.Id); if (!File.Exists(seriesPath)) throw new FileNotFoundException("The comparison series does not exist.", seriesPath); await WriteJsonAtomicAsync(seriesPath, series, token); } finally { gate.Release(); } } /// /// Deletes a comparison series together with its chats. /// /// The ID of the series. /// The cancellation token. public async Task DeleteSeriesAsync(Guid seriesId, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(seriesId); await gate.WaitAsync(token); try { var directory = this.SeriesDirectory(seriesId); if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); } finally { gate.Release(); this.ForgetLock(seriesId); } } /// /// Deletes all comparison series together with their chats, the presets and the results of the mode Answer /// Variance aside. /// /// /// Unlike listing, deleting takes the series this version cannot read along: the user asked for all series to go. /// /// The cancellation token. public async Task DeleteAllSeriesAsync(CancellationToken token = default) { await this.InitializeAsync(token); if (!Directory.Exists(this.AllSeriesDirectory)) return; foreach (var directory in Directory.EnumerateDirectories(this.AllSeriesDirectory).ToList()) { token.ThrowIfCancellationRequested(); if (Guid.TryParseExact(Path.GetFileName(directory), "D", out var seriesId)) await this.DeleteSeriesAsync(seriesId, token); } } /// /// Loads a chat of a comparison series. /// /// The ID of the series. /// The ID of the chat. /// The cancellation token. /// The chat, or null when it has no file yet. /// This version cannot open the chat; the file stays as it is. public async Task LoadSeriesChatAsync(Guid seriesId, Guid chatId, CancellationToken token = default) { await this.InitializeAsync(token); var path = this.SeriesChatPath(seriesId, chatId); if (!File.Exists(path)) return null; ChatThread? thread; try { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); thread = await JsonSerializer.DeserializeAsync(stream, WorkspaceBehaviour.JSON_OPTIONS, token); } catch (JsonException exception) { throw new InvalidDataException("This version of AI Studio cannot open the chat.", exception); } return thread ?? throw new InvalidDataException("This version of AI Studio cannot open the chat."); } /// /// Stores a chat of a comparison series, in place of what was stored of it before. /// /// /// Guarded by the lock of the series, so that a series deleted meanwhile does not come back with a chat alone. /// /// The ID of the series. /// The chat, with its ID. /// The cancellation token. /// The series does not exist, e.g., because the user deleted it while the model answered. public async Task SaveSeriesChatAsync(Guid seriesId, ChatThread thread, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(seriesId); await gate.WaitAsync(token); try { var seriesPath = this.SeriesPath(seriesId); if (!File.Exists(seriesPath)) throw new FileNotFoundException("The comparison series of the chat does not exist.", seriesPath); await WriteJsonAtomicAsync(this.SeriesChatPath(seriesId, thread.ChatId), thread, token, WorkspaceBehaviour.JSON_OPTIONS); } finally { gate.Release(); } } /// /// Deletes a chat of a comparison series, e.g., because the user discarded its task. /// /// The ID of the series. /// The ID of the chat. /// The cancellation token. public async Task DeleteSeriesChatAsync(Guid seriesId, Guid chatId, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(seriesId); await gate.WaitAsync(token); try { var path = this.SeriesChatPath(seriesId, chatId); if (File.Exists(path)) File.Delete(path); } finally { gate.Release(); } } /// /// Reads which chats of comparison series lie on the disk, from the folders alone, e.g., for the cleanup of the /// provenance store at the start. /// /// The ID of the series of every chat, by the ID of the chat. public IReadOnlyDictionary ReadAllSeriesChatLocations() { var locations = new Dictionary(); if (!Directory.Exists(this.AllSeriesDirectory)) return locations; foreach (var directory in Directory.EnumerateDirectories(this.AllSeriesDirectory)) { var chatsDirectory = Path.Combine(directory, CHATS_DIRECTORY_NAME); if (!Guid.TryParseExact(Path.GetFileName(directory), "D", out var seriesId) || !Directory.Exists(chatsDirectory)) continue; foreach (var path in Directory.EnumerateFiles(chatsDirectory, "*.json")) if (Guid.TryParseExact(Path.GetFileNameWithoutExtension(path), "D", out var chatId)) locations[chatId] = seriesId; } return locations; } /// /// Tells when the file of a chat of a comparison series was last written. /// /// The ID of the series. /// The ID of the chat. /// The time, or null when there is no file. public DateTimeOffset? GetSeriesChatWrittenAt(Guid seriesId, Guid chatId) { var path = this.SeriesChatPath(seriesId, chatId); return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path)) : null; } }