From 4aed43d057a7a14503276574ccf45d5ad8585dd3 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Fri, 18 Sep 2026 13:58:18 +0200 Subject: [PATCH] Lock data sources with an unreadable index in the selection --- .../Components/DataSourceBlockReason.cs | 29 +++++++++++ .../Components/DataSourceSelection.razor | 4 +- .../Components/DataSourceSelection.razor.cs | 31 +++++++++-- .../Components/DataSourceSelectionRow.razor | 8 +-- .../DataSourceSelectionRow.razor.cs | 35 +++++++++++-- .../Tools/AllowedSelectedDataSources.cs | 9 +++- .../Services/DataSourceEmbeddingService.cs | 16 ++++++ .../DataSourceLocalRetrievalService.cs | 2 +- .../Tools/Services/DataSourceService.cs | 52 +++++++++++++++---- 9 files changed, 158 insertions(+), 28 deletions(-) create mode 100644 app/MindWork AI Studio/Components/DataSourceBlockReason.cs diff --git a/app/MindWork AI Studio/Components/DataSourceBlockReason.cs b/app/MindWork AI Studio/Components/DataSourceBlockReason.cs new file mode 100644 index 00000000..496e1c77 --- /dev/null +++ b/app/MindWork AI Studio/Components/DataSourceBlockReason.cs @@ -0,0 +1,29 @@ +namespace AIStudio.Components; + +/// +/// Why a data source is listed in the selection, but cannot be picked. +/// +/// +/// A reason rather than a yes or no, because the row has to say something different for each of +/// them: one asks the user to wait, the other one asks them to act. Asking somebody to wait for +/// something which will never happen on its own is the worse of the two mistakes. +/// +public enum DataSourceBlockReason +{ + /// + /// Nothing is in the way, the data source can be picked. + /// + NONE, + + /// + /// The index has to be built anew before this data source can answer a search. This passes by + /// itself, as soon as the background indexing has worked through the data source. + /// + AWAITING_REINDEX, + + /// + /// The index cannot be read anymore. This does not pass by itself: only the user can start the + /// rebuild, because it sends every document to the embedding provider once more. + /// + NEEDS_REPAIR, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor b/app/MindWork AI Studio/Components/DataSourceSelection.razor index 4a145120..11001174 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelection.razor +++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor @@ -92,7 +92,7 @@ @foreach (var source in this.GetListedDataSources()) { - + } @@ -104,7 +104,7 @@ @foreach (var source in this.GetListedDataSources()) { - + } diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs index b2f7b8ce..f1e619c0 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs +++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs @@ -51,6 +51,8 @@ public partial class DataSourceSelection : MSGComponentBase private IReadOnlyList availableDataSources = []; private IReadOnlyList dataSourcesAwaitingReindex = []; private HashSet dataSourceIdsAwaitingReindex = new(StringComparer.Ordinal); + private IReadOnlyList dataSourcesNeedingRepair = []; + private HashSet dataSourceIdsNeedingRepair = new(StringComparer.Ordinal); private IReadOnlyCollection selectedDataSources = []; private bool aiBasedSourceSelection; private bool aiBasedValidation; @@ -230,15 +232,33 @@ public partial class DataSourceSelection : MSGComponentBase this.availableDataSources = sources.AllowedDataSources; this.dataSourcesAwaitingReindex = sources.DataSourcesAwaitingReindex; this.dataSourceIdsAwaitingReindex = sources.DataSourcesAwaitingReindex.Select(source => source.Id).ToHashSet(StringComparer.Ordinal); + this.dataSourcesNeedingRepair = sources.DataSourcesNeedingRepair; + this.dataSourceIdsNeedingRepair = sources.DataSourcesNeedingRepair.Select(source => source.Id).ToHashSet(StringComparer.Ordinal); this.selectedDataSources = sources.SelectedDataSources; this.waitingForDataSources = false; this.StateHasChanged(); } - private bool IsAwaitingReindex(IDataSource dataSource) => this.dataSourceIdsAwaitingReindex.Contains(dataSource.Id); + /// + /// Why a data source is listed but cannot be picked, if it cannot. + /// + /// + /// The repair is asked about first. The service hands a data source to one of the two lists + /// only, but should that ever change, the reason the user can act on is the one worth showing. + /// + private DataSourceBlockReason GetBlockReason(IDataSource dataSource) + { + if (this.dataSourceIdsNeedingRepair.Contains(dataSource.Id)) + return DataSourceBlockReason.NEEDS_REPAIR; + + if (this.dataSourceIdsAwaitingReindex.Contains(dataSource.Id)) + return DataSourceBlockReason.AWAITING_REINDEX; + + return DataSourceBlockReason.NONE; + } /// - /// The data sources the list shows: the usable ones, plus the ones waiting for their index. + /// The data sources the list shows: the usable ones, plus the ones which cannot be searched. /// /// /// Kept in the order the data sources were configured in, rather than usable ones first. A row @@ -247,11 +267,12 @@ public partial class DataSourceSelection : MSGComponentBase /// private IReadOnlyList GetListedDataSources() { - if (this.dataSourcesAwaitingReindex.Count == 0) + if (this.dataSourcesAwaitingReindex.Count == 0 && this.dataSourcesNeedingRepair.Count == 0) return this.availableDataSources; var listedIds = this.availableDataSources.Select(source => source.Id).ToHashSet(StringComparer.Ordinal); listedIds.UnionWith(this.dataSourceIdsAwaitingReindex); + listedIds.UnionWith(this.dataSourceIdsNeedingRepair); return this.GetConfiguredDataSourcesSnapshot().Where(source => listedIds.Contains(source.Id)).ToList(); } @@ -259,11 +280,11 @@ public partial class DataSourceSelection : MSGComponentBase /// The preselected but unusable data sources the warning box lists. /// /// - /// The ones waiting for their index are left out: they have a row of their own in the list + /// The ones which are only blocked are left out: they have a row of their own in the list /// above, which says the same thing in the place the user is already looking. /// private IReadOnlyList GetUnavailablePreselectedDataSourcesToList() => - this.GetUnavailablePreselectedDataSources().Where(source => !this.IsAwaitingReindex(source)).ToList(); + this.GetUnavailablePreselectedDataSources().Where(source => this.GetBlockReason(source) is DataSourceBlockReason.NONE).ToList(); private async Task EnabledChanged(bool state) { diff --git a/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor b/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor index 837a1681..d31184fc 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor +++ b/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor @@ -2,8 +2,8 @@ @using AIStudio.Provider @inherits MSGComponentBase - - + + @this.DataSource.Name @@ -11,9 +11,9 @@ @if (this.DataSource is IInternalDataSource internalSource) { - @if (this.IsAwaitingReindex) + @if (this.IsBlocked) { - + } diff --git a/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor.cs b/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor.cs index 51f602a4..8560c289 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor.cs +++ b/app/MindWork AI Studio/Components/DataSourceSelectionRow.razor.cs @@ -10,9 +10,13 @@ namespace AIStudio.Components; /// can be used right now. /// /// -/// A data source waiting for its index stays in the list instead of disappearing from it, but -/// cannot be picked, and the tooltip says why. The tool selection next to it in the chat answers -/// the same question the same way. +/// A data source which cannot be used stays in the list instead of disappearing from it, but cannot +/// be picked, and the tooltip says why. The tool selection next to it in the chat answers the same +/// question the same way. +/// +/// Why it cannot be used decides what the row says and which icon it wears: an index being built +/// anew is a matter of waiting, an index which cannot be read is a matter of acting. Both are the +/// same row otherwise, which is why this is one component with a reason rather than two components. /// /// The tooltip sits around the list item rather than inside it: a disabled item has its pointer /// events switched off and would swallow the hover. @@ -26,10 +30,31 @@ public partial class DataSourceSelectionRow : MSGComponentBase public required IDataSource DataSource { get; set; } /// - /// Whether this data source has to be indexed anew before it can answer a search. + /// Why this data source cannot be picked right now, if it cannot. /// [Parameter] - public bool IsAwaitingReindex { get; set; } + public DataSourceBlockReason BlockReason { get; set; } = DataSourceBlockReason.NONE; + + private bool IsBlocked => this.BlockReason is not DataSourceBlockReason.NONE; + + private string GetBlockedTooltip() => this.BlockReason switch + { + DataSourceBlockReason.AWAITING_REINDEX => T("This data source is waiting to be indexed again. Until that is finished, it cannot be searched."), + DataSourceBlockReason.NEEDS_REPAIR => T("The index of this data source cannot be read anymore. Open your data source settings with the gear icon above, then use the repair action there."), + _ => string.Empty, + }; + + private string GetBlockedIcon() => this.BlockReason switch + { + DataSourceBlockReason.NEEDS_REPAIR => Icons.Material.Filled.ReportProblem, + _ => Icons.Material.Filled.HourglassTop, + }; + + private Color GetBlockedIconColor() => this.BlockReason switch + { + DataSourceBlockReason.NEEDS_REPAIR => Color.Error, + _ => Color.Warning, + }; private string GetConfidenceIconStyle(IInternalDataSource dataSource) => $"{dataSource.ConfidenceLevel.SetColorStyle(this.SettingsManager)} flex-shrink: 0;"; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AllowedSelectedDataSources.cs b/app/MindWork AI Studio/Tools/AllowedSelectedDataSources.cs index b2cf5d9c..ac932e37 100644 --- a/app/MindWork AI Studio/Tools/AllowedSelectedDataSources.cs +++ b/app/MindWork AI Studio/Tools/AllowedSelectedDataSources.cs @@ -3,7 +3,7 @@ using AIStudio.Settings; namespace AIStudio.Tools; /// -/// Contains the allowed and selected data sources, plus the ones waiting for their index. +/// Contains the allowed and selected data sources, plus the ones which cannot be searched right now. /// /// /// The selected data sources are a subset of the allowed data sources. @@ -13,8 +13,13 @@ namespace AIStudio.Tools; /// -- takes it to mean "may be used to answer with", and a source whose index is being rebuilt /// cannot answer anything. It is listed separately so the user interface can still show it and say /// why it is greyed out, instead of letting it vanish without a word. +/// +/// The same holds for the ones waiting for a repair, and they are a list of their own because the +/// two reasons call for different words: one passes by itself, the other one waits for the user. +/// A data source is in at most one of the two lists. /// /// The allowed data sources. /// The selected data sources, which are a subset of the allowed data sources. /// The data sources which passed every check but cannot be searched until their index has been rebuilt. -public readonly record struct AllowedSelectedDataSources(IReadOnlyList AllowedDataSources, IReadOnlyList SelectedDataSources, IReadOnlyList DataSourcesAwaitingReindex); \ No newline at end of file +/// The data sources which passed every check but whose index cannot be read anymore, so that only the user can get them back. +public readonly record struct AllowedSelectedDataSources(IReadOnlyList AllowedDataSources, IReadOnlyList SelectedDataSources, IReadOnlyList DataSourcesAwaitingReindex, IReadOnlyList DataSourcesNeedingRepair); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/DataSourceEmbeddingService.cs b/app/MindWork AI Studio/Tools/Services/DataSourceEmbeddingService.cs index b85ee951..01ea7b7c 100644 --- a/app/MindWork AI Studio/Tools/Services/DataSourceEmbeddingService.cs +++ b/app/MindWork AI Studio/Tools/Services/DataSourceEmbeddingService.cs @@ -317,6 +317,22 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM return runState is not DataSourceEmbeddingState.FAILED; } + /// + /// Whether a data source cannot be searched because its vector store cannot be read anymore. + /// + /// + /// Unlike the re-index check above, this reads no database at all: the state comes from the run + /// or the search which ran into the unreadable store, and is kept in memory only. That it does + /// not survive a restart is deliberate. The very same store may well open on the next start, + /// and a mark written to disk would then be wrong with nobody noticing. Until something touches + /// the store again, the data source counts as usable, and a failing search says so on its own. + /// + /// The data source to ask about. + /// True when the data source waits for the user to have its index rebuilt. + public bool NeedsIndexRepair(IDataSource dataSource) => + this.statuses.TryGetValue(dataSource.Id, out var status) && + status is { State: DataSourceEmbeddingState.FAILED, VectorStoreUnreadable: true }; + public Task QueueDataSourceAsync(IDataSource dataSource) { return this.QueueDataSourceAsync(dataSource, true, DataSourceEmbeddingRefreshMode.HASH_CHECK); diff --git a/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs b/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs index 56890389..744c67a4 100644 --- a/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs +++ b/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs @@ -188,7 +188,7 @@ public sealed class DataSourceLocalRetrievalService( // answer into one the user can do something about. // logger.LogWarning(exception, "Vector retrieval failed for data source '{DataSourceName}' ({DataSourceId}) because its vector store cannot be read.", dataSource.Name, dataSource.Id); - await this.ReportRetrievalGapAsync(dataSource, "vector-store-unreadable", string.Format(TB("The data source '{0}' was left out of the answer: its index cannot be read anymore. You can repair it on the background embeddings page."), dataSource.Name)); + await this.ReportRetrievalGapAsync(dataSource, "vector-store-unreadable", string.Format(TB("The data source '{0}' was left out of the answer: its index cannot be read anymore. You can repair it in your data source settings."), dataSource.Name)); return []; } catch (Exception exception) diff --git a/app/MindWork AI Studio/Tools/Services/DataSourceService.cs b/app/MindWork AI Studio/Tools/Services/DataSourceService.cs index 8c70e82b..ce18b3b6 100644 --- a/app/MindWork AI Studio/Tools/Services/DataSourceService.cs +++ b/app/MindWork AI Studio/Tools/Services/DataSourceService.cs @@ -51,7 +51,7 @@ public sealed class DataSourceService if (selectedLLMProvider == Settings.Provider.NONE) { this.logger.LogWarning("The selected LLM provider is not set. We cannot filter the data sources by any means."); - return new([], [], []); + return new([], [], [], []); } var usingTrustedProvider = selectedLLMProvider.IsTrustedForDataSourceSecurityChecks(this.settingsManager); @@ -83,12 +83,13 @@ public sealed class DataSourceService var allowedDataSources = await this.GetAllowedDataSources(usingTrustedProvider, participatingProviders, requestedDataSources); // - // Whoever asks this way has no list to show, so a data source waiting for its index is + // Whoever asks this way has no list to show, so a data source which cannot be searched is // dropped rather than marked. Handing it back would start a chat with a data source which // finds nothing -- the very thing being greyed out elsewhere is meant to prevent. // - var awaitingReindexIds = (await this.GetDataSourcesAwaitingReindex(allowedDataSources)).Select(source => source.Id).ToHashSet(StringComparer.Ordinal); - return allowedDataSources.Where(source => !awaitingReindexIds.Contains(source.Id)).ToList(); + var unsearchableIds = (await this.GetDataSourcesAwaitingReindex(allowedDataSources)).Select(source => source.Id).ToHashSet(StringComparer.Ordinal); + unsearchableIds.UnionWith(this.GetDataSourcesNeedingRepair(allowedDataSources).Select(source => source.Id)); + return allowedDataSources.Where(source => !unsearchableIds.Contains(source.Id)).ToList(); } /// @@ -109,7 +110,7 @@ public sealed class DataSourceService if (selectedLLMProvider is NoProvider) { this.logger.LogWarning("The selected LLM provider is the default provider. We cannot filter the data sources by any means."); - return new([], [], []); + return new([], [], [], []); } var usingTrustedProvider = selectedLLMProvider.IsTrustedForDataSourceSecurityChecks(this.settingsManager); @@ -159,12 +160,21 @@ public sealed class DataSourceService // being rebuilt is usable again in a while, and saying so on its own row beats letting it // disappear from the selection without a word. // - var awaitingReindex = await this.GetDataSourcesAwaitingReindex(filteredDataSources); - var awaitingReindexIds = awaitingReindex.Select(source => source.Id).ToHashSet(StringComparer.Ordinal); - var usableDataSources = filteredDataSources.Where(source => !awaitingReindexIds.Contains(source.Id)).ToList(); + // A source whose index cannot be read is asked about first and then kept out of the other + // list: both reasons can be true at once, and of the two it is the only one the user can do + // anything about. Telling them to wait instead would be telling them to wait forever. + // + var needingRepair = this.GetDataSourcesNeedingRepair(filteredDataSources); + var needingRepairIds = needingRepair.Select(source => source.Id).ToHashSet(StringComparer.Ordinal); + var awaitingReindex = (await this.GetDataSourcesAwaitingReindex(filteredDataSources)).Where(source => !needingRepairIds.Contains(source.Id)).ToList(); + + var blockedIds = awaitingReindex.Select(source => source.Id).ToHashSet(StringComparer.Ordinal); + blockedIds.UnionWith(needingRepairIds); + + var usableDataSources = filteredDataSources.Where(source => !blockedIds.Contains(source.Id)).ToList(); var filteredSelectedDataSources = usableDataSources.Where(source => previousSelectedDataSourceIds.Contains(source.Id)).ToList(); - return new(usableDataSources, filteredSelectedDataSources, awaitingReindex); + return new(usableDataSources, filteredSelectedDataSources, awaitingReindex, needingRepair); } /// @@ -195,6 +205,30 @@ public sealed class DataSourceService return awaitingReindex; } + /// + /// Picks out the data sources whose index cannot be read anymore, so that they wait for a repair. + /// + /// + /// Reads nothing from a database, unlike the re-index check above: the state is held in memory + /// by the embedding service, which is why this one needs no parallelism and no timeout. + /// + /// The data sources which passed every other check. + /// Those of them which wait for a repair, in the order they came in. + private IReadOnlyList GetDataSourcesNeedingRepair(IReadOnlyList dataSources) + { + var needingRepair = new List(); + foreach (var dataSource in dataSources) + { + if (!this.embeddingService.NeedsIndexRepair(dataSource)) + continue; + + this.logger.LogInformation("The index of data source '{DataSourceName}' ({DataSourceId}) cannot be read. It is shown, but cannot be selected until it was repaired.", dataSource.Name, dataSource.Id); + needingRepair.Add(dataSource); + } + + return needingRepair; + } + private async Task> GetAllowedDataSources(bool usingTrustedProvider, IReadOnlyList participatingProviders, IReadOnlyCollection requestedDataSources) { var filteredDataSources = new List(requestedDataSources.Count);