Added a way to read what the AI received from your sources (#1100)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul KoudelkaandThorsten Sommer authored and GitHub committed 2026-10-26 10:00:01 +01:00
1 parent e0d8e18f34
commit cbc4ce5413
229 files changed
+12739 -1269

No files matched your search

@@ -0,0 +1,43 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.ProvenanceStore.Migrations;
/// <summary>
/// Creates the table provenance, which holds what chats received besides their messages.
/// </summary>
[DbContext(typeof(ProvenanceStoreDbContext))]
[Migration("20261024000000_InitialProvenanceStore")]
public partial class InitialProvenanceStore : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "provenance",
columns: table => new
{
chat_id = table.Column<string>(type: "TEXT", nullable: false),
id = table.Column<string>(type: "TEXT", nullable: false),
kind = table.Column<string>(type: "TEXT", nullable: false),
content = table.Column<string>(type: "TEXT", nullable: false),
surrounding_content = table.Column<string>(type: "TEXT", nullable: true),
created_at_utc = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_provenance", entry => new { entry.chat_id, entry.id });
});
migrationBuilder.CreateIndex(
name: "idx_provenance_id",
table: "provenance",
column: "id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "provenance");
}
}
@@ -0,0 +1,60 @@
#nullable disable
using AIStudio.Tools.Databases.IndexStore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace AIStudio.Tools.Databases.ProvenanceStore.Migrations;
// EF Core writes this file and declares the type as partial. Dropping the keyword would only
// last until the next migration is added, so the inspection is silenced instead.
// ReSharper disable once PartialTypeWithSinglePart
[DbContext(typeof(ProvenanceStoreDbContext))]
partial class ProvenanceStoreDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.18");
var utcDateTimeOffsetConverter = new IndexStoreDateTimeOffsetConverter();
modelBuilder.Entity("AIStudio.Tools.Databases.ProvenanceStore.ProvenanceEntity", entity =>
{
entity.Property<string>("ChatId")
.HasColumnType("TEXT")
.HasColumnName("chat_id");
entity.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
entity.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("content");
entity.Property<DateTimeOffset>("CreatedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("created_at_utc");
entity.Property<string>("Kind")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kind");
entity.Property<string>("SurroundingContent")
.HasColumnType("TEXT")
.HasColumnName("surrounding_content");
entity.HasKey("ChatId", "Id");
entity.HasIndex("Id")
.HasDatabaseName("idx_provenance_id");
entity.ToTable("provenance");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,69 @@
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Databases.ProvenanceStore;
/// <summary>
/// Stands in for the provenance store while it cannot serve: it keeps nothing and finds nothing.
/// </summary>
/// <remarks>
/// Storing answers with no id, which tells the caller to keep the content in the chat itself.
/// </remarks>
public sealed class NoProvenanceStoreClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : ProvenanceStoreClient(name, string.Empty)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoProvenanceStoreClient).Namespace, nameof(NoProvenanceStoreClient));
public override DatabaseClientStatus Status => status;
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
yield return (TB("Status"), status switch
{
DatabaseClientStatus.STARTING => TB("Starting"),
_ => TB("Unavailable")
});
if (!string.IsNullOrWhiteSpace(unavailableReason))
yield return (TB("Reason"), unavailableReason);
// As in NoIndexStoreClient: which library this process bound to matters most when SQLite cannot be loaded at all.
yield return (TB("Native library"), OrUnknown(SqliteRuntimeInfo.GetNativeLibraryName()));
yield return (TB("Process architecture"), OrUnknown(SqliteRuntimeInfo.GetProcessArchitecture()));
await Task.CompletedTask;
}
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
public override Task<Guid?> StoreAsync(Guid chatId, ProvenanceKind kind, string content, IReadOnlyList<string>? surroundingContent, CancellationToken token) => Task.FromResult<Guid?>(null);
public override Task<IReadOnlyDictionary<Guid, ProvenanceEntry>> GetEntriesAsync(IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.FromResult<IReadOnlyDictionary<Guid, ProvenanceEntry>>(new Dictionary<Guid, ProvenanceEntry>());
public override Task CopyChatAsync(Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.CompletedTask;
public override Task<int> DeleteChatsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.FromResult(0);
public override Task<int> DeleteChatEntriesExceptAsync(Guid chatId, IReadOnlyCollection<Guid> keptIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.FromResult(0);
public override Task<int> DeleteEntriesAsync(Guid chatId, IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.FromResult(0);
public override Task<IReadOnlyList<Guid>> GetChatIdsAsync(CancellationToken token, DateTimeOffset? createdSince = null) => Task.FromResult<IReadOnlyList<Guid>>([]);
public override Task<DateTimeOffset?> GetLastCleanupAsync(CancellationToken token) => Task.FromResult<DateTimeOffset?>(null);
public override Task SetLastCleanupAsync(DateTimeOffset startedAt, CancellationToken token) => Task.CompletedTask;
/// <inheritdoc />
/// <remarks>
/// Unlike the other methods, this one does not pretend success: a backup would take an empty
/// copy for the real one. Whoever makes the backup decides what to do instead.
/// </remarks>
public override Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token) =>
Task.FromException<string>(new InvalidOperationException(unavailableReason ?? "The provenance store is not available."));
public override Task<int> ImportChatAsync(string sourceDatabasePath, Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.FromResult(0);
public override void Dispose()
{
}
}
@@ -0,0 +1,30 @@
namespace AIStudio.Tools.Databases.ProvenanceStore;
/// <summary>
/// One row of the table provenance.
/// </summary>
/// <remarks>
/// The key is the chat together with the id. A chat which is copied, e.g., an assistant's
/// conversation which the user carries over into a chat, takes its rows along under the same ids,
/// so its references stay valid, and deleting one of the two chats leaves the other one whole.
/// </remarks>
internal sealed class ProvenanceEntity
{
public string ChatId { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
/// <summary>
/// The ProvenanceKind, stored by name.
/// </summary>
public string Kind { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
/// <summary>
/// The content around an ERI passage as a JSON array of strings, or null when there is none.
/// </summary>
public string? SurroundingContent { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
}
@@ -0,0 +1,16 @@
namespace AIStudio.Tools.Databases.ProvenanceStore;
/// <summary>
/// One piece of content a chat received besides its messages, exactly as the AI received it.
/// </summary>
/// <remarks>
/// Where the content comes from, such as the file, the page, or the tool, is kept by the chat
/// itself, next to the id of this entry. The chat needs it to list its sources even without this
/// store, and it would only be stored twice here.
/// </remarks>
/// <param name="Id">The id the chat refers to the entry by.</param>
/// <param name="Kind">What sort of content this is.</param>
/// <param name="Content">The content, after the prompt injection filter.</param>
/// <param name="SurroundingContent">The content an ERI server sent along around its passage, empty for everything else.</param>
/// <param name="CreatedAtUtc">When the entry was stored.</param>
public sealed record ProvenanceEntry(Guid Id, ProvenanceKind Kind, string Content, IReadOnlyList<string> SurroundingContent, DateTimeOffset CreatedAtUtc);
@@ -0,0 +1,32 @@
namespace AIStudio.Tools.Databases.ProvenanceStore;
/// <summary>
/// What sort of content a chat received, apart from its messages.
/// </summary>
/// <remarks>
/// Stored by name, like the kinds of mail parts in the index: a new member may then go anywhere
/// in this list, and the database reads on its own.
/// </remarks>
public enum ProvenanceKind
{
/// <summary>
/// The content of a file the user attached to a message.
/// </summary>
ATTACHMENT,
/// <summary>
/// A passage a data source handed to the AI: a local one or an ERI server, through the classic
/// process or the semantic search.
/// </summary>
DATA_SOURCE_PASSAGE,
/// <summary>
/// A mail, or a part of one, which the AI received from a mailbox.
/// </summary>
MAIL,
/// <summary>
/// A web page the AI received, e.g., which a tool read for it.
/// </summary>
WEB_PAGE,
}
@@ -0,0 +1,129 @@
namespace AIStudio.Tools.Databases.ProvenanceStore;
/// <summary>
/// Keeps what chats received besides their messages: the content of attached files, the results
/// of tools, and the passages of data sources, each exactly as the AI received it.
/// </summary>
/// <remarks>
/// A chat keeps only the ids of its entries, next to where each one came from. The content lives
/// here, so that a chat file stays small and an attached file is read only once.<br/><br/>
///
/// A database of its own rather than a table of the index: the index of the data sources can be
/// rebuilt from the files and is treated that way, e.g., a backup may leave it out. What a chat
/// received cannot be rebuilt, since the files, pages and mails may have changed since.<br/><br/>
///
/// Every entry belongs to one chat. The same id may stand for an entry of several chats, after a
/// chat was copied, and then it holds the same content in each.
/// </remarks>
public abstract class ProvenanceStoreClient(string name, string path) : DatabaseClient(name, path)
{
/// <summary>
/// Stores one piece of content a chat received.
/// </summary>
/// <param name="chatId">The chat which received the content.</param>
/// <param name="kind">What sort of content it is.</param>
/// <param name="content">The content, exactly as the AI received it.</param>
/// <param name="surroundingContent">The content an ERI server sent along around its passage, or nothing.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The id of the new entry, or null when this store cannot keep anything right now.</returns>
public abstract Task<Guid?> StoreAsync(Guid chatId, ProvenanceKind kind, string content, IReadOnlyList<string>? surroundingContent, CancellationToken token);
/// <summary>
/// Reads entries by their ids.
/// </summary>
/// <remarks>
/// By the ids alone, without the chat: the place which shows an entry seldom knows its chat,
/// and every copy of an entry holds the same content.
/// </remarks>
/// <param name="ids">The ids to read.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The entries found, by their ids. An id without an entry is missing from it.</returns>
public abstract Task<IReadOnlyDictionary<Guid, ProvenanceEntry>> GetEntriesAsync(IReadOnlyCollection<Guid> ids, CancellationToken token);
/// <summary>
/// Copies every entry of a chat to another chat, keeping their ids.
/// </summary>
/// <remarks>
/// The copy is what a chat made from another one refers to. Entries the target holds already
/// stay as they are.
/// </remarks>
/// <param name="sourceChatId">The chat to copy from.</param>
/// <param name="targetChatId">The chat to copy to.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task CopyChatAsync(Guid sourceChatId, Guid targetChatId, CancellationToken token);
/// <summary>
/// Deletes every entry of some chats.
/// </summary>
/// <remarks>
/// All of them at once: deleting a workspace or cleaning up at the start may concern many chats.
/// </remarks>
/// <param name="chatIds">The chats.</param>
/// <param name="token">The cancellation token.</param>
/// <param name="createdBefore">Deletes only the entries stored before this moment, or all of them when null.</param>
/// <returns>How many entries were deleted.</returns>
public abstract Task<int> DeleteChatsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken token, DateTimeOffset? createdBefore = null);
/// <summary>
/// Deletes the entries of a chat which it no longer refers to.
/// </summary>
/// <param name="chatId">The chat.</param>
/// <param name="keptIds">The ids the chat still refers to.</param>
/// <param name="token">The cancellation token.</param>
/// <param name="createdBefore">Deletes only the entries stored before this moment, or all of them when null.</param>
/// <returns>How many entries were deleted.</returns>
public abstract Task<int> DeleteChatEntriesExceptAsync(Guid chatId, IReadOnlyCollection<Guid> keptIds, CancellationToken token, DateTimeOffset? createdBefore = null);
/// <summary>
/// Deletes some entries of a chat, e.g., texts which a preview kept for a message that went without them.
/// </summary>
/// <param name="chatId">The chat.</param>
/// <param name="ids">The ids of the entries.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>How many entries were deleted.</returns>
public abstract Task<int> DeleteEntriesAsync(Guid chatId, IReadOnlyCollection<Guid> ids, CancellationToken token);
/// <summary>
/// Lists the chats this store holds entries of.
/// </summary>
/// <param name="token">The cancellation token.</param>
/// <param name="createdSince">Lists only the chats with an entry stored at or after this moment, or all of them when null.</param>
/// <returns>The ids of the chats, Guid.Empty included when entries were stored for a chat without an id.</returns>
public abstract Task<IReadOnlyList<Guid>> GetChatIdsAsync(CancellationToken token, DateTimeOffset? createdSince = null);
/// <summary>
/// Reads when the last complete cleanup of this store began, see ProvenanceCleanup.
/// </summary>
/// <param name="token">The cancellation token.</param>
/// <returns>The moment, or null when no cleanup was noted, e.g., for a store a restore brought back.</returns>
public abstract Task<DateTimeOffset?> GetLastCleanupAsync(CancellationToken token);
/// <summary>
/// Notes when a complete cleanup of this store began, see ProvenanceCleanup.
/// </summary>
/// <param name="startedAt">The moment the cleanup began.</param>
/// <param name="token">The cancellation token.</param>
public abstract Task SetLastCleanupAsync(DateTimeOffset startedAt, CancellationToken token);
/// <summary>
/// Writes a copy of this store into a directory, for a backup.
/// </summary>
/// <param name="targetDirectory">The directory of the copy. A copy written there before is replaced.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The path of the copy.</returns>
public abstract Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token);
/// <summary>
/// Adds the entries of one chat from a copy of this store, such as the one a backup holds.
/// </summary>
/// <remarks>
/// The entries keep their ids, so the chat's references stay valid, also when the chat is added
/// under another id. Entries the target chat holds already stay as they are.
/// </remarks>
/// <param name="sourceDatabasePath">The copy to read from. It is only read.</param>
/// <param name="sourceChatId">The chat in the copy.</param>
/// <param name="targetChatId">The chat in this store.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>How many entries were added.</returns>
public abstract Task<int> ImportChatAsync(string sourceDatabasePath, Guid sourceChatId, Guid targetChatId, CancellationToken token);
}
@@ -0,0 +1,45 @@
using AIStudio.Tools.Databases.IndexStore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.ProvenanceStore;
internal sealed class ProvenanceStoreDbContext(DbContextOptions<ProvenanceStoreDbContext> options) : DbContext(options)
{
private static readonly SecureDeleteInterceptor SECURE_DELETE = new();
public static DbContextOptions<ProvenanceStoreDbContext> CreateOptions(string databasePath) => new DbContextOptionsBuilder<ProvenanceStoreDbContext>()
.UseSqlite(BuildConnectionString(databasePath))
.AddInterceptors(SECURE_DELETE)
.Options;
public DbSet<ProvenanceEntity> Entries => this.Set<ProvenanceEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProvenanceEntity>(entity =>
{
entity.ToTable("provenance");
entity.HasKey(entry => new { entry.ChatId, entry.Id });
entity.Property(entry => entry.ChatId).HasColumnName("chat_id");
entity.Property(entry => entry.Id).HasColumnName("id");
entity.Property(entry => entry.Kind).HasColumnName("kind").IsRequired();
entity.Property(entry => entry.Content).HasColumnName("content").IsRequired();
entity.Property(entry => entry.SurroundingContent).HasColumnName("surrounding_content");
entity.Property(entry => entry.CreatedAtUtc).HasColumnName("created_at_utc").HasConversion(new IndexStoreDateTimeOffsetConverter()).IsRequired();
// A chat reads its entries by their ids alone, see ProvenanceStoreClient.GetEntriesAsync:
entity.HasIndex(entry => entry.Id).HasDatabaseName("idx_provenance_id");
});
}
private static string BuildConnectionString(string databasePath) => new SqliteConnectionStringBuilder
{
DataSource = databasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
DefaultTimeout = 30,
}.ToString();
}
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore.Design;
namespace AIStudio.Tools.Databases.ProvenanceStore;
internal sealed class ProvenanceStoreDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ProvenanceStoreDbContext>
{
public ProvenanceStoreDbContext CreateDbContext(string[] args)
{
var databasePath = args.FirstOrDefault(argument => argument.EndsWith(".sqlite3", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(databasePath))
databasePath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-provenance-design.sqlite3");
return new ProvenanceStoreDbContext(ProvenanceStoreDbContext.CreateOptions(databasePath));
}
}
@@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.ProvenanceStore;
internal static class ProvenanceStoreSchemaMigrator
{
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.InitialProvenanceStore))]
public static async Task MigrateAsync(ProvenanceStoreDbContext context, CancellationToken token)
{
await context.Database.MigrateAsync(token);
await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", token);
}
}
@@ -0,0 +1,36 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace AIStudio.Tools.Databases.ProvenanceStore;
/// <summary>
/// Turns on secure_delete for every connection to the provenance store.
/// </summary>
/// <remarks>
/// SQLite only marks the space of a deleted row as free, and its bytes stay in the file until
/// another row takes their place. The provenance store holds whole documents, mails, and web pages,
/// so the text of a deleted chat would stay readable in the file, perhaps for months. With
/// secure_delete, SQLite overwrites it with zeros while deleting. The write-ahead log keeps the old
/// pages until its next checkpoint, which SQLite makes by itself.<br/><br/>
///
/// The setting lasts as long as its connection, hence it is made for every connection anew.
/// </remarks>
internal sealed class SecureDeleteInterceptor : DbConnectionInterceptor
{
private const string PRAGMA = "PRAGMA secure_delete = ON;";
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
{
using var command = connection.CreateCommand();
command.CommandText = PRAGMA;
command.ExecuteNonQuery();
}
public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
{
await using var command = connection.CreateCommand();
command.CommandText = PRAGMA;
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
@@ -0,0 +1,459 @@
using System.Data;
using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Json;
using AIStudio.Settings;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.PluginSystem;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.ProvenanceStore;
/// <summary>
/// The provenance store in SQLite, see ProvenanceStoreClient.
/// </summary>
/// <remarks>
/// Every reading and writing of entries runs on a thread of its own. SQLite works synchronously
/// behind its async methods, cf. the Databases AGENTS.md, and nearly everything this store holds is
/// a whole document, a web page, or a mail. Many callers sit in the user interface, e.g., a preview
/// which keeps its text, or a click which deletes a chat, and would freeze the window meanwhile.
/// </remarks>
public sealed class SqliteProvenanceStoreClientImplementation(string name, string databasePath, string basePath, string version) : ProvenanceStoreClient(name, basePath)
{
private const string DATABASE_NAME = "SQLite";
/// <summary>
/// The file of the store, in its folder in the data folder and in a backup alike.
/// </summary>
public const string DATABASE_FILENAME = "provenance.sqlite3";
/// <summary>
/// The file which notes when the last complete cleanup began, see GetLastCleanupAsync.
/// </summary>
private const string LAST_CLEANUP_FILENAME = "last-cleanup.txt";
/// <summary>
/// Writes the content around a passage the way it reads: the default encoder would turn every
/// character outside ASCII into an escape, cf. ToolExecutionResult.MODEL_CONTENT_OPTIONS.
/// </summary>
private static readonly JsonSerializerOptions JSON_OPTIONS = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
private readonly string databasePath = databasePath;
private readonly string lastCleanupPath = Path.Join(basePath, LAST_CLEANUP_FILENAME);
private readonly DbContextOptions<ProvenanceStoreDbContext> dbContextOptions = ProvenanceStoreDbContext.CreateOptions(databasePath);
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SqliteProvenanceStoreClientImplementation).Namespace, nameof(SqliteProvenanceStoreClientImplementation));
public override string CacheKey => $"{this.Name}:{this.databasePath}:{version}";
public override string Version => version;
public static async Task<DatabaseClient> CreateAsync(
ILogger logger,
ILogger<DatabaseClient> databaseClientLogger,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(SettingsManager.DataDirectory))
return CreateNoProvenanceStoreClient(DATABASE_NAME, "The application data directory is not available yet.", DatabaseClientStatus.STARTING, databaseClientLogger);
try
{
SQLitePCL.Batteries_V2.Init();
var basePath = Path.Combine(SettingsManager.DataDirectory, "databases", "provenance");
Directory.CreateDirectory(basePath);
var databasePath = Path.Combine(basePath, DATABASE_FILENAME);
var client = new SqliteProvenanceStoreClientImplementation(DATABASE_NAME, databasePath, basePath, string.Empty);
await client.InitializeAsync(cancellationToken);
var version = await client.GetSqliteVersionAsync(cancellationToken);
client = new SqliteProvenanceStoreClientImplementation(DATABASE_NAME, databasePath, basePath, version);
client.SetLogger(databaseClientLogger);
return client;
}
catch (Exception exception)
{
logger.LogWarning(exception, "The provenance store ({DatabaseName}) is not available. Chats keep what they received in their own files.", DATABASE_NAME);
return CreateNoProvenanceStoreClient(DATABASE_NAME, exception.Message, DatabaseClientStatus.UNAVAILABLE, databaseClientLogger);
}
}
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
//
// Everything is read before the first line, since a yield return cannot stand in a try
// block. Each probe catches its own failure, cf. the Databases AGENTS.md:
//
var schemaVersion = string.Empty;
var journalMode = string.Empty;
IReadOnlyDictionary<string, int>? countsByKind = null;
int? chatCount = null;
try
{
await using var context = this.CreateContext();
schemaVersion = await GetSchemaVersionAsync(context);
journalMode = (await QueryScalarTextAsync(context, "PRAGMA journal_mode;")).ToUpperInvariant();
countsByKind = await TryCountByKindAsync(context);
chatCount = await TryCountAsync(context.Entries.Select(entry => entry.ChatId).Distinct());
}
catch (Exception exception)
{
this.Logger?.LogWarning(exception, "Failed to read the display details of the provenance store.");
}
yield return (TB("Reported version"), version);
yield return (TB("Journal mode"), OrUnknown(journalMode));
yield return (TB("Schema version"), OrUnknown(schemaVersion));
yield return (TB("Storage size"), this.GetStorageSize());
// The total counts an entry of a kind this version does not know as well, e.g., one written by a newer version:
yield return (TB("Stored items across all chats"), countsByKind?.Values.Sum().CompactCount() ?? TB("unknown"));
yield return (TB("Chats with stored items"), chatCount?.CompactCount() ?? TB("unknown"));
// One line per kind which any chat received, in the order of the kinds. A kind no chat received would only say zero:
if (countsByKind is null)
yield break;
foreach (var kind in Enum.GetValues<ProvenanceKind>())
if (countsByKind.TryGetValue(kind.ToString(), out var count) && count > 0)
yield return (GetKindLabel(kind), count.CompactCount());
}
private static string GetKindLabel(ProvenanceKind kind) => kind switch
{
ProvenanceKind.ATTACHMENT => TB("Attached files"),
ProvenanceKind.DATA_SOURCE_PASSAGE => TB("Passages of data sources"),
ProvenanceKind.MAIL => TB("Mails"),
ProvenanceKind.WEB_PAGE => TB("Web pages"),
_ => kind.ToString(),
};
public override Task<Guid?> StoreAsync(Guid chatId, ProvenanceKind kind, string content, IReadOnlyList<string>? surroundingContent, CancellationToken token) => Task.Run<Guid?>(async () =>
{
var id = Guid.NewGuid();
await using var context = this.CreateContext();
context.Entries.Add(new ProvenanceEntity
{
ChatId = ToKey(chatId),
Id = ToKey(id),
Kind = kind.ToString(),
Content = content,
SurroundingContent = surroundingContent is { Count: > 0 } ? JsonSerializer.Serialize(surroundingContent, JSON_OPTIONS) : null,
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await context.SaveChangesAsync(token);
return id;
}, token);
public override Task<IReadOnlyDictionary<Guid, ProvenanceEntry>> GetEntriesAsync(IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.Run<IReadOnlyDictionary<Guid, ProvenanceEntry>>(async () =>
{
var entries = new Dictionary<Guid, ProvenanceEntry>();
if (ids.Count is 0)
return entries;
var keys = ids.Select(ToKey).Distinct().ToList();
await using var context = this.CreateContext();
var rows = await context.Entries
.AsNoTracking()
.Where(entry => keys.Contains(entry.Id))
.ToListAsync(token);
// Copies of an entry hold the same content, so the first one found stands for all of them:
foreach (var row in rows)
{
if (!Guid.TryParse(row.Id, out var id) || entries.ContainsKey(id))
continue;
if (!Enum.TryParse<ProvenanceKind>(row.Kind, out var kind))
{
this.Logger?.LogWarning("The provenance entry '{EntryId}' has the unknown kind '{Kind}', e.g., from a newer version of AI Studio, and is left out.", row.Id, row.Kind);
continue;
}
entries[id] = new ProvenanceEntry(id, kind, row.Content, this.ReadSurroundingContent(row), row.CreatedAtUtc);
}
return entries;
}, token);
public override Task CopyChatAsync(Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.Run(async () =>
{
var sourceKey = ToKey(sourceChatId);
var targetKey = ToKey(targetChatId);
await using var context = this.CreateContext();
//
// In one statement, so no row passes through this process. The time of each row stays,
// since it says when the AI received the content, not when the chat was copied:
//
await context.Database.ExecuteSqlAsync(
$"""
INSERT OR IGNORE INTO provenance (chat_id, id, kind, content, surrounding_content, created_at_utc)
SELECT {targetKey}, id, kind, content, surrounding_content, created_at_utc
FROM provenance
WHERE chat_id = {sourceKey}
""",
token);
}, token);
public override Task<int> DeleteChatsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.Run(async () =>
{
if (chatIds.Count is 0)
return 0;
var chatKeys = chatIds.Select(ToKey).Distinct().ToList();
await using var context = this.CreateContext();
return await CreatedBefore(context.Entries.Where(entry => chatKeys.Contains(entry.ChatId)), createdBefore).ExecuteDeleteAsync(token);
}, token);
public override Task<int> DeleteChatEntriesExceptAsync(Guid chatId, IReadOnlyCollection<Guid> keptIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.Run(async () =>
{
var chatKey = ToKey(chatId);
var keptKeys = keptIds.Select(ToKey).Distinct().ToList();
await using var context = this.CreateContext();
return await CreatedBefore(context.Entries.Where(entry => entry.ChatId == chatKey && !keptKeys.Contains(entry.Id)), createdBefore).ExecuteDeleteAsync(token);
}, token);
public override Task<int> DeleteEntriesAsync(Guid chatId, IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.Run(async () =>
{
if (ids.Count is 0)
return 0;
var chatKey = ToKey(chatId);
var keys = ids.Select(ToKey).Distinct().ToList();
await using var context = this.CreateContext();
return await context.Entries.Where(entry => entry.ChatId == chatKey && keys.Contains(entry.Id)).ExecuteDeleteAsync(token);
}, token);
/// <summary>
/// Narrows entries down to those stored before a moment.
/// </summary>
/// <remarks>
/// The time is stored as text in the round-trip format of UTC, whose order is the order of time,
/// so SQLite compares it as text, after the parameter went through the same conversion.
/// </remarks>
private static IQueryable<ProvenanceEntity> CreatedBefore(IQueryable<ProvenanceEntity> entries, DateTimeOffset? createdBefore) => createdBefore is { } moment
? entries.Where(entry => entry.CreatedAtUtc < moment)
: entries;
public override Task<IReadOnlyList<Guid>> GetChatIdsAsync(CancellationToken token, DateTimeOffset? createdSince = null) => Task.Run<IReadOnlyList<Guid>>(async () =>
{
await using var context = this.CreateContext();
var entries = createdSince is { } moment ? context.Entries.Where(entry => entry.CreatedAtUtc >= moment) : context.Entries;
var chatKeys = await entries.Select(entry => entry.ChatId).Distinct().ToListAsync(token);
// Guid.Empty included: entries stored for a chat without an id belong to no chat on the disk, so the cleanup takes them:
return chatKeys
.Select(chatKey => Guid.TryParse(chatKey, out var chatId) ? chatId : (Guid?)null)
.OfType<Guid>()
.ToList();
}, token);
/// <inheritdoc />
/// <remarks>
/// Kept in a file next to the database rather than in it: the copy of the store which a backup
/// holds comes without it, so the first cleanup of a store brought back from a backup reads every chat.
/// </remarks>
public override async Task<DateTimeOffset?> GetLastCleanupAsync(CancellationToken token)
{
if (!File.Exists(this.lastCleanupPath))
return null;
// A file which cannot be read costs one cleanup which reads every chat, and then it is written anew:
try
{
var text = await File.ReadAllTextAsync(this.lastCleanupPath, token);
return DateTimeOffset.TryParse(text.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var moment) ? moment : null;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
this.Logger?.LogWarning(exception, "Failed to read when the provenance store was cleaned up last.");
return null;
}
}
public override Task SetLastCleanupAsync(DateTimeOffset startedAt, CancellationToken token) => File.WriteAllTextAsync(this.lastCleanupPath, IndexStoreDateTimeOffset.ToUtcText(startedAt), token);
/// <inheritdoc />
/// <remarks>
/// A consistent snapshot which holds back no writer, made on a thread of its own, cf. SqliteDatabaseCopy.
/// </remarks>
public override Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token) => Task.Run(async () =>
{
var targetPath = Path.Join(targetDirectory, DATABASE_FILENAME);
await SqliteDatabaseCopy.CopyAndCheckAsync(this.databasePath, targetPath, token);
return targetPath;
}, token);
/// <inheritdoc />
/// <remarks>
/// The copy is read on a connection of its own, read-only and without a pool, so nothing keeps it
/// open afterward and the caller can delete it. Only the columns this version knows are read: a
/// copy made by a newer version may hold more.<br/><br/>
///
/// The entries pass one at a time, since each one may be a whole document and a chat may hold
/// many of them. All of them go in one transaction, so a chat arrives with all of its entries
/// or with none. The time of each entry stays, as in CopyChatAsync.
/// </remarks>
public override Task<int> ImportChatAsync(string sourceDatabasePath, Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.Run(async () =>
{
var targetKey = ToKey(targetChatId);
await using var source = new SqliteConnection(SqliteDatabaseCopy.BuildConnectionString(sourceDatabasePath, SqliteOpenMode.ReadOnly));
await source.OpenAsync(token);
await using var command = source.CreateCommand();
command.CommandText = "SELECT id, kind, content, surrounding_content, created_at_utc FROM provenance WHERE chat_id = $chatId;";
command.Parameters.AddWithValue("$chatId", ToKey(sourceChatId));
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
await using var reader = await command.ExecuteReaderAsync(token);
var added = 0;
while (await reader.ReadAsync(token))
{
var id = reader.GetString(0);
var kind = reader.GetString(1);
var content = reader.GetString(2);
var surroundingContent = reader.IsDBNull(3) ? null : reader.GetString(3);
var createdAtUtc = reader.GetString(4);
// Entries the chat holds already stay as they are:
added += await context.Database.ExecuteSqlAsync(
$"""
INSERT OR IGNORE INTO provenance (chat_id, id, kind, content, surrounding_content, created_at_utc)
VALUES ({targetKey}, {id}, {kind}, {content}, {surroundingContent}, {createdAtUtc})
""",
token);
}
await transaction.CommitAsync(token);
return added;
}, token);
public override void Dispose()
{
}
/// <summary>
/// Writes an id the way this store keeps it: as text, lowercase and with hyphens.
/// </summary>
private static string ToKey(Guid id) => id.ToString("D");
private IReadOnlyList<string> ReadSurroundingContent(ProvenanceEntity row)
{
if (string.IsNullOrWhiteSpace(row.SurroundingContent))
return [];
try
{
return JsonSerializer.Deserialize<List<string>>(row.SurroundingContent) ?? [];
}
catch (JsonException exception)
{
this.Logger?.LogWarning(exception, "The content around the provenance entry '{EntryId}' cannot be read and is left out.", row.Id);
return [];
}
}
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
private static async Task<string> QueryScalarTextAsync(ProvenanceStoreDbContext context, string sql)
{
try
{
// The raw connection rather than SqlQueryRaw, which cannot run a PRAGMA, cf. the Databases AGENTS.md:
var connection = context.Database.GetDbConnection();
if (connection.State is not ConnectionState.Open)
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = sql;
var result = await command.ExecuteScalarAsync();
return result?.ToString() ?? string.Empty;
}
catch
{
return string.Empty;
}
}
private static async Task<string> GetSchemaVersionAsync(ProvenanceStoreDbContext context)
{
try
{
var appliedMigrations = (await context.Database.GetAppliedMigrationsAsync()).ToList();
if (appliedMigrations.Count == 0)
return TB("no migration applied");
var pendingMigrations = (await context.Database.GetPendingMigrationsAsync()).ToList();
return pendingMigrations.Count == 0
? string.Format(I18N.I.Culture, TB("{0} ({1} applied)"), appliedMigrations[^1], appliedMigrations.Count.CompactCount())
: string.Format(I18N.I.Culture, TB("{0} ({1} applied, {2} pending)"), appliedMigrations[^1], appliedMigrations.Count.CompactCount(), pendingMigrations.Count.CompactCount());
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Counts the entries of every kind, by the name each kind is stored with.
/// </summary>
/// <returns>The counts by kind, or null when the count failed.</returns>
private static async Task<IReadOnlyDictionary<string, int>?> TryCountByKindAsync(ProvenanceStoreDbContext context)
{
try
{
return await context.Entries
.GroupBy(entry => entry.Kind)
.Select(group => new { Kind = group.Key, Count = group.Count() })
.ToDictionaryAsync(group => group.Kind, group => group.Count);
}
catch
{
return null;
}
}
private static async Task<int?> TryCountAsync<T>(IQueryable<T> query)
{
try
{
return await query.CountAsync();
}
catch
{
return null;
}
}
private async Task InitializeAsync(CancellationToken token)
{
await using var context = this.CreateContext();
await ProvenanceStoreSchemaMigrator.MigrateAsync(context, token);
}
private async Task<string> GetSqliteVersionAsync(CancellationToken token)
{
await using var context = this.CreateContext();
var versions = await context.Database
.SqlQueryRaw<string>("SELECT sqlite_version() AS Value")
.ToListAsync(token);
return versions.FirstOrDefault() ?? string.Empty;
}
private ProvenanceStoreDbContext CreateContext() => new(this.dbContextOptions);
private static NoProvenanceStoreClient CreateNoProvenanceStoreClient(string name, string? unavailableReason, DatabaseClientStatus status, ILogger<DatabaseClient> databaseClientLogger)
{
var client = new NoProvenanceStoreClient(name, unavailableReason, status);
client.SetLogger(databaseClientLogger);
return client;
}
}