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,201 @@
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.ProvenanceStore;
using AIStudio.Tools.RAG;
using AIStudio.Tools.Rust;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows one passage the AI received from a data source, in full and the way the AI read it.
/// </summary>
/// <remarks>
/// The content is the filtered one, see SourcePassage. The bar above it says where in the document
/// the passage stands, and whether it is the transcript of a recording, since the AI then read the
/// words of the recording rather than the recording itself. An image the AI was only told about is
/// said to be one, rather than shown as if the AI had seen it.<br/><br/>
///
/// The texts of a passage lie in the provenance store once its answer is complete, and are read
/// from there. Should the store no longer hold them, the view says so instead of showing nothing.
/// A store which cannot be read, e.g., while it is still starting, is no proof of that: the view
/// then offers to read the texts again.
/// </remarks>
public partial class SourcePassageView : MSGComponentBase
{
/// <summary>
/// What stands between the parts of the text, as Markdown: a horizontal rule.
/// </summary>
private const string PART_SEPARATOR = "\n\n---\n\n";
/// <summary>
/// The passage to show.
/// </summary>
[Parameter]
public SourcePassage Passage { get; set; } = new();
/// <summary>
/// The document the passage is from, which can be opened at its page, or null when there is no
/// document to open.
/// </summary>
[Parameter]
public SourceDocumentLocation? Document { get; set; }
/// <summary>
/// How high the text may grow before it scrolls, as a CSS length.
/// </summary>
[Parameter]
public string MaxHeight { get; set; } = ContentViewer.DEFAULT_MAX_HEIGHT;
[Inject]
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
[Inject]
private ILogger<SourcePassageView> Logger { get; init; } = null!;
private string heading = string.Empty;
private string text = string.Empty;
/// <summary>
/// What the bar above the text opens: the document, or the web page of the passage; empty when there is neither.
/// </summary>
private string openPath = string.Empty;
/// <summary>
/// The page of the passage, which a document opens at, or null.
/// </summary>
private int? openPage;
/// <summary>
/// The passage whose texts are shown or being read, so that a render with the same passage does not read them again.
/// </summary>
private SourcePassage? shownPassage;
/// <summary>
/// Numbers the reads, so that a read which another passage overtook leaves the texts alone.
/// </summary>
private int readNumber;
private bool isReadingText;
/// <summary>
/// Whether the provenance store no longer holds the texts of the passage.
/// </summary>
private bool isTextMissing;
/// <summary>
/// Whether the provenance store could not be read, so that it is unknown whether it holds the texts of the passage.
/// </summary>
private bool isStoreUnavailable;
#region Overrides of ComponentBase
protected override async Task OnParametersSetAsync()
{
var parts = new[] { this.Passage.GetLocationLabel(), this.GetTranscriptLabel() }.Where(part => part.Length > 0).ToList();
this.heading = parts.Count > 0 ? string.Join(" · ", parts) : T("Text the AI received");
//
// A document opens at the page of the passage. One without pages, such as a CSV file, opens
// at its beginning: no program can be sent to a part, but the user may still want to look.
// Without a document, the passage of a web page opens its page; any other passage, such as
// that of a mail, has nothing to open, see FileActions:
//
var page = this.Passage.PageNumber is > 0 ? this.Passage.PageNumber : null;
this.openPath = this.Document?.Path ?? this.Passage.Path;
this.openPage = this.Document is null ? null : page;
if (!ReferenceEquals(this.shownPassage, this.Passage))
{
this.shownPassage = this.Passage;
await this.ReadTextAsync(this.Passage);
}
await base.OnParametersSetAsync();
}
#endregion
/// <summary>
/// Reads the texts of a passage: from the provenance store once they lie there, otherwise from the passage itself.
/// </summary>
/// <param name="passage">The passage.</param>
private async Task ReadTextAsync(SourcePassage passage)
{
var currentRead = ++this.readNumber;
this.isTextMissing = false;
this.isStoreUnavailable = false;
if (passage.ProvenanceId is not { } provenanceId)
{
this.isReadingText = false;
this.text = this.ComposeText(passage.Text, passage.SurroundingContent);
return;
}
this.isReadingText = true;
this.text = string.Empty;
ProvenanceEntry? entry = null;
bool storeCannotBeRead;
try
{
// A store which is not available answers every read with nothing, which would read as a deleted text:
var store = await this.DatabaseClientProvider.GetProvenanceStoreAsync();
storeCannotBeRead = !store.IsAvailable;
if (!storeCannotBeRead)
entry = (await store.GetEntriesAsync([provenanceId], CancellationToken.None)).GetValueOrDefault(provenanceId);
}
catch (Exception exception)
{
this.Logger.LogError(exception, "Failed to read the passage '{ProvenanceId}' from the provenance store.", provenanceId);
storeCannotBeRead = true;
}
// The user went on to another passage meanwhile, which shows its own texts:
if (currentRead != this.readNumber)
return;
this.isReadingText = false;
this.isStoreUnavailable = storeCannotBeRead;
this.isTextMissing = !storeCannotBeRead && entry is null;
this.text = entry is null ? string.Empty : this.ComposeText(entry.Content, entry.SurroundingContent);
}
/// <summary>
/// Reads the texts of the shown passage once more, after the provenance store could not be read.
/// </summary>
private Task ReadTextAgainAsync() => this.ReadTextAsync(this.Passage);
/// <summary>
/// Writes the texts of a passage as one text.
/// </summary>
/// <remarks>
/// The surrounding content follows the matched text, the way the AI received both, so that all
/// of it reads in one place.
/// </remarks>
private string ComposeText(string matchedText, IReadOnlyList<string> surroundingContent) => surroundingContent.Count is 0
? matchedText
: $"{matchedText}{PART_SEPARATOR}**{T("Surrounding content")}**{PART_SEPARATOR}{string.Join(PART_SEPARATOR, surroundingContent)}";
/// <summary>
/// Says what the passage is a transcript of, or nothing when it is no transcript.
/// </summary>
/// <remarks>
/// Asked by the extension of the path only: the file may be gone by now, and reading it would
/// tell nothing more about what the AI received.
/// </remarks>
private string GetTranscriptLabel()
{
if (!this.Passage.IsTranscript())
return string.Empty;
var extension = Path.GetExtension(this.Passage.Path);
if (FileTypes.IsAllowedExtension(extension, FileTypes.VIDEO))
return T("Transcript of a video file");
return FileTypes.IsAllowedExtension(extension, FileTypes.AUDIO)
? T("Transcript of an audio file")
: T("Transcript");
}
}