mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-10 16:47:22 +00:00
381 lines
19 KiB
C#
381 lines
19 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace AIStudio.Assistants.BeyondFactsLab;
|
|
|
|
public sealed partial class BeyondFactsLabStore
|
|
{
|
|
/// <summary>
|
|
/// The folder of a preparation which holds its materials.
|
|
/// </summary>
|
|
private const string MATERIALS_DIRECTORY_NAME = "materials";
|
|
|
|
/// <summary>
|
|
/// The extension of the file which describes a material.
|
|
/// </summary>
|
|
private const string MATERIAL_FILE_EXTENSION = ".json";
|
|
|
|
/// <summary>
|
|
/// The extension of the file which holds the text of a material.
|
|
/// </summary>
|
|
private const string MATERIAL_TEXT_FILE_EXTENSION = ".md";
|
|
|
|
/// <summary>
|
|
/// How many characters a read of the text of a material takes at once.
|
|
/// </summary>
|
|
private const int MATERIAL_TEXT_READ_BLOCK = 65_536;
|
|
|
|
/// <summary>
|
|
/// Lists the materials of a preparation, also those this version cannot open, in the order the person added them.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <returns>The materials; those this version cannot open come last.</returns>
|
|
public async Task<IReadOnlyList<BflMaterialEntry>> ListMaterialsAsync(Guid projectId, CancellationToken token = default)
|
|
{
|
|
await this.InitializeAsync(token);
|
|
return await this.ListMaterialsWithoutInitializeAsync(projectId, token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a document or a recording to a preparation, unless it has a material with the same content already, and
|
|
/// counts that as a change of the preparation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The store gives the material its ID, the time it was added, and the length of its text. It keeps only the
|
|
/// name of the file, never a path, even when the name passed contains one.
|
|
/// </remarks>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="material">What the lab knows about the file: its kind, name, size, hash, and how it was read.</param>
|
|
/// <param name="text">The text of the document or the transcript of the recording, after the prompt injection filter.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <returns>The new material, or the one with the same content which the preparation had already.</returns>
|
|
/// <exception cref="ArgumentException">The name, the hash, or the text is empty, or the material is an image.</exception>
|
|
/// <exception cref="FileNotFoundException">The preparation does not exist.</exception>
|
|
/// <exception cref="InvalidDataException">This version cannot open the preparation, so it does not write it either.</exception>
|
|
public Task<BflMaterialAddition> AddMaterialAsync(Guid projectId, BflMaterial material, string text, CancellationToken token = default)
|
|
{
|
|
if (material.Kind is not (BflMaterialKind.DOCUMENT or BflMaterialKind.RECORDING) || material.Image is not null)
|
|
throw new ArgumentException("Only a document or a recording has a text; an image needs AddImageMaterialAsync.", nameof(material));
|
|
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
throw new ArgumentException("A material needs a text.", nameof(text));
|
|
|
|
return this.AddAsync(projectId, material, text.Length, (stored, writeToken) => WriteTextAtomicAsync(this.MaterialTextPath(projectId, stored.MaterialId), text, writeToken), token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an image to a preparation, unless it has a material with the same content already, and counts that as a
|
|
/// change of the preparation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The store gives the material its ID and the time it was added. It keeps only the name of the file, never a
|
|
/// path, even when the name passed contains one.
|
|
/// </remarks>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="material">What the lab knows about the file: its name, size, hash, and the description of the copy.</param>
|
|
/// <param name="image">The copy of the image, prepared for models.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <returns>The new material, or the one with the same content which the preparation had already.</returns>
|
|
/// <exception cref="ArgumentException">The name, the hash, or the copy is empty, or the material is no image.</exception>
|
|
/// <exception cref="FileNotFoundException">The preparation does not exist.</exception>
|
|
/// <exception cref="InvalidDataException">This version cannot open the preparation, so it does not write it either.</exception>
|
|
public Task<BflMaterialAddition> AddImageMaterialAsync(Guid projectId, BflMaterial material, ReadOnlyMemory<byte> image, CancellationToken token = default)
|
|
{
|
|
if (material.Kind is not BflMaterialKind.IMAGE || !BflMaterialImage.IsValid(material.Image))
|
|
throw new ArgumentException("An image material needs the description of its copy.", nameof(material));
|
|
|
|
if (image.IsEmpty)
|
|
throw new ArgumentException("An image material needs its copy.", nameof(image));
|
|
|
|
return this.AddAsync(projectId, material, 0, (stored, writeToken) => WriteBytesAtomicAsync(this.MaterialImagePath(projectId, stored.MaterialId, stored.Image!.Extension), image, writeToken), token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the copy of an image which the lab keeps as material.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="material">The material, an image.</param>
|
|
/// <returns>The path of the copy.</returns>
|
|
/// <exception cref="ArgumentException">The material is no image.</exception>
|
|
public string MaterialImagePath(Guid projectId, BflMaterial material)
|
|
{
|
|
if (material.Kind is not BflMaterialKind.IMAGE || !BflMaterialImage.IsValid(material.Image))
|
|
throw new ArgumentException("Only an image material has a copy of an image.", nameof(material));
|
|
|
|
return this.MaterialImagePath(projectId, material.MaterialId, material.Image!.Extension);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a material to a preparation, unless it has one with the same content already.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="material">What the lab knows about the file.</param>
|
|
/// <param name="textLength">How many characters the text of the material has; 0 for an image.</param>
|
|
/// <param name="writeContent">Writes the text or the copy of the image for the stored material.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <returns>The new material, or the one with the same content which the preparation had already.</returns>
|
|
private async Task<BflMaterialAddition> AddAsync(Guid projectId, BflMaterial material, int textLength, Func<BflMaterial, CancellationToken, Task> writeContent, CancellationToken token)
|
|
{
|
|
var name = MaterialName(material.Name);
|
|
if (name.Length is 0)
|
|
throw new ArgumentException("A material needs a name.", nameof(material));
|
|
|
|
if (string.IsNullOrWhiteSpace(material.SourceHash))
|
|
throw new ArgumentException("A material needs the hash of its file.", nameof(material));
|
|
|
|
await this.InitializeAsync(token);
|
|
var gate = this.GetLock(projectId);
|
|
await gate.WaitAsync(token);
|
|
try
|
|
{
|
|
var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token);
|
|
var existing = await this.ListMaterialsWithoutInitializeAsync(projectId, token);
|
|
if (existing.FirstOrDefault(entry => string.Equals(entry.Material?.SourceHash, material.SourceHash, StringComparison.OrdinalIgnoreCase))?.Material is { } duplicate)
|
|
return new(duplicate, IsDuplicate: true);
|
|
|
|
var stored = new BflMaterial
|
|
{
|
|
MaterialId = Guid.NewGuid(),
|
|
Name = name,
|
|
Kind = material.Kind,
|
|
Image = material.Image is { } image ? new() { Extension = image.Extension, Width = image.Width, Height = image.Height } : null,
|
|
AddedAtUtc = DateTimeOffset.UtcNow,
|
|
SourceBytes = Math.Max(0, material.SourceBytes),
|
|
SourceHash = material.SourceHash.ToLowerInvariant(),
|
|
TextLength = textLength,
|
|
IsIncomplete = material.IsIncomplete,
|
|
IsShortened = material.IsShortened,
|
|
FailedPages = [.. material.FailedPages],
|
|
FilteredPassages = Math.Max(0, material.FilteredPassages),
|
|
};
|
|
|
|
// The content comes first: a material whose description exists always has its content, even after a crash in between.
|
|
await writeContent(stored, token);
|
|
await WriteTextAtomicAsync(this.MaterialPath(projectId, stored.MaterialId), JsonSerializer.Serialize(stored, JSON_OPTIONS), token);
|
|
|
|
project.ModifiedAtUtc = DateTimeOffset.UtcNow;
|
|
await this.StoreProjectAsync(project, token);
|
|
return new(stored, IsDuplicate: false);
|
|
}
|
|
finally
|
|
{
|
|
gate.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a material of a preparation, also when this version cannot open it, and counts that as a change of the
|
|
/// preparation.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="materialId">The ID of the material.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <returns>The preparation with its new time of change.</returns>
|
|
/// <exception cref="FileNotFoundException">The preparation does not exist.</exception>
|
|
/// <exception cref="InvalidDataException">This version cannot open the preparation, so it does not write it either.</exception>
|
|
public async Task<BflProject> DeleteMaterialAsync(Guid projectId, Guid materialId, CancellationToken token = default)
|
|
{
|
|
await this.InitializeAsync(token);
|
|
var gate = this.GetLock(projectId);
|
|
await gate.WaitAsync(token);
|
|
try
|
|
{
|
|
var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token);
|
|
|
|
// The description goes first: content left behind by a crash in between belongs to no material and is never read.
|
|
DeleteIfExists(this.MaterialPath(projectId, materialId));
|
|
DeleteIfExists(this.MaterialTextPath(projectId, materialId));
|
|
|
|
// Also when the description is damaged, every copy of an image goes with it:
|
|
foreach (var extension in BflMaterialImage.EXTENSIONS)
|
|
DeleteIfExists(this.MaterialImagePath(projectId, materialId, extension));
|
|
|
|
project.ModifiedAtUtc = DateTimeOffset.UtcNow;
|
|
await this.StoreProjectAsync(project, token);
|
|
return project;
|
|
}
|
|
finally
|
|
{
|
|
gate.Release();
|
|
}
|
|
|
|
static void DeleteIfExists(string path)
|
|
{
|
|
if (File.Exists(path))
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the beginning of the text of a material, at most as many characters as given.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Only what a prompt takes is read, never more: a long document would otherwise lie in memory as a whole with
|
|
/// every request.
|
|
/// </remarks>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="materialId">The ID of the material.</param>
|
|
/// <param name="maxCharacters">How many characters to read at most.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <returns>The beginning of the text; the whole text when it is not longer.</returns>
|
|
/// <exception cref="InvalidDataException">The text cannot be read, e.g., because the person deleted the material meanwhile.</exception>
|
|
public async Task<string> ReadMaterialTextAsync(Guid projectId, Guid materialId, int maxCharacters, CancellationToken token = default)
|
|
{
|
|
if (maxCharacters <= 0)
|
|
return string.Empty;
|
|
|
|
await this.InitializeAsync(token);
|
|
try
|
|
{
|
|
await using var stream = new FileStream(this.MaterialTextPath(projectId, materialId), FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true);
|
|
using var reader = new StreamReader(stream, new UTF8Encoding(false));
|
|
var buffer = new char[Math.Min(maxCharacters, MATERIAL_TEXT_READ_BLOCK)];
|
|
var text = new StringBuilder();
|
|
while (text.Length < maxCharacters)
|
|
{
|
|
var read = await reader.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, maxCharacters - text.Length)), token);
|
|
if (read is 0)
|
|
break;
|
|
|
|
text.Append(buffer, 0, read);
|
|
}
|
|
|
|
return text.ToString();
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
|
{
|
|
throw new InvalidDataException("AI Studio cannot read the text of the material.", exception);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists the materials of a preparation without the first start of the store.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
private async Task<IReadOnlyList<BflMaterialEntry>> ListMaterialsWithoutInitializeAsync(Guid projectId, CancellationToken token)
|
|
{
|
|
var directory = this.MaterialsDirectory(projectId);
|
|
if (!Directory.Exists(directory))
|
|
return [];
|
|
|
|
List<BflMaterialEntry> materials = [];
|
|
foreach (var path in Directory.EnumerateFiles(directory, $"*{MATERIAL_FILE_EXTENSION}"))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (!Guid.TryParseExact(Path.GetFileNameWithoutExtension(path), "D", out var materialId))
|
|
continue;
|
|
|
|
materials.Add(new(materialId, await this.LoadMaterialAsync(projectId, materialId, path, token)));
|
|
}
|
|
|
|
return
|
|
[
|
|
.. materials
|
|
.OrderBy(entry => entry.IsAvailable ? 0 : 1)
|
|
.ThenBy(entry => entry.Material?.AddedAtUtc ?? DateTimeOffset.MaxValue)
|
|
.ThenBy(entry => entry.MaterialId)
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the description of a material.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="materialId">The ID the file names.</param>
|
|
/// <param name="path">The file of the description.</param>
|
|
/// <param name="token">The cancellation token.</param>
|
|
/// <returns>The material, or null when it is damaged, has no content, or a newer version stored it.</returns>
|
|
private async Task<BflMaterial?> LoadMaterialAsync(Guid projectId, Guid materialId, string path, CancellationToken token)
|
|
{
|
|
try
|
|
{
|
|
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true);
|
|
var material = await JsonSerializer.DeserializeAsync<BflMaterial>(stream, JSON_OPTIONS, token);
|
|
if (material is not null && IsValidMaterial(material, materialId) && File.Exists(this.MaterialContentPath(projectId, material)))
|
|
return material;
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
|
|
{
|
|
logger.LogWarning(exception, "Could not open the material '{MaterialId}' of the preparation '{ProjectId}' of the BeyondFacts Lab.", materialId, projectId);
|
|
return null;
|
|
}
|
|
|
|
logger.LogWarning("The material '{MaterialId}' of the preparation '{ProjectId}' of the BeyondFacts Lab is not valid, or its content is missing.", materialId, projectId);
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks a material which was read from a file.
|
|
/// </summary>
|
|
/// <param name="material">The material.</param>
|
|
/// <param name="expectedMaterialId">The ID its file names.</param>
|
|
private static bool IsValidMaterial(BflMaterial material, Guid expectedMaterialId) =>
|
|
material.SchemaVersion is >= 1 and <= BflMaterial.CURRENT_SCHEMA_VERSION &&
|
|
material.MaterialId == expectedMaterialId &&
|
|
!string.IsNullOrWhiteSpace(material.Name) &&
|
|
!string.IsNullOrWhiteSpace(material.SourceHash) &&
|
|
material is { SourceBytes: >= 0, TextLength: >= 0, FilteredPassages: >= 0 } &&
|
|
!IsNull(material.FailedPages) &&
|
|
material.Kind switch
|
|
{
|
|
BflMaterialKind.DOCUMENT or BflMaterialKind.RECORDING => material.Image is null,
|
|
BflMaterialKind.IMAGE => BflMaterialImage.IsValid(material.Image),
|
|
_ => false,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Gets the name under which the lab keeps a document: its file name without any folder, cleaned like the name of
|
|
/// a preparation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Both kinds of separators count, whatever the system: a path from Windows on a Mac still names its folders.
|
|
/// </remarks>
|
|
/// <param name="name">The name of the document, possibly with a path.</param>
|
|
/// <returns>The cleaned name, empty when nothing remains.</returns>
|
|
internal static string MaterialName(string? name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return string.Empty;
|
|
|
|
return SanitizeName(name[(name.LastIndexOfAny(['/', '\\']) + 1)..]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the folder of the materials of a preparation.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
private string MaterialsDirectory(Guid projectId) => Path.Combine(this.ProjectDirectory(projectId), MATERIALS_DIRECTORY_NAME);
|
|
|
|
/// <summary>
|
|
/// Gets the file which describes a material.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="materialId">The ID of the material.</param>
|
|
private string MaterialPath(Guid projectId, Guid materialId) => Path.Combine(this.MaterialsDirectory(projectId), $"{materialId:D}{MATERIAL_FILE_EXTENSION}");
|
|
|
|
/// <summary>
|
|
/// Gets the file which holds the text of a material.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="materialId">The ID of the material.</param>
|
|
private string MaterialTextPath(Guid projectId, Guid materialId) => Path.Combine(this.MaterialsDirectory(projectId), $"{materialId:D}{MATERIAL_TEXT_FILE_EXTENSION}");
|
|
|
|
/// <summary>
|
|
/// Gets the file which holds the copy of an image.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="materialId">The ID of the material.</param>
|
|
/// <param name="extension">The file extension of the copy, without the dot.</param>
|
|
private string MaterialImagePath(Guid projectId, Guid materialId, string extension) => Path.Combine(this.MaterialsDirectory(projectId), $"{materialId:D}.{extension}");
|
|
|
|
/// <summary>
|
|
/// Gets the file which holds the content of a material: its text, or the copy of an image.
|
|
/// </summary>
|
|
/// <param name="projectId">The ID of the preparation.</param>
|
|
/// <param name="material">A valid material.</param>
|
|
private string MaterialContentPath(Guid projectId, BflMaterial material) => material.Kind is BflMaterialKind.IMAGE
|
|
? this.MaterialImagePath(projectId, material)
|
|
: this.MaterialTextPath(projectId, material.MaterialId);
|
|
} |