From 4ef6753707ea7ed854e47626dabe3aa854b9a50a Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 15 Sep 2026 13:10:44 +0200 Subject: [PATCH] Added the page of a passage to what the AI is told about it --- .../Tools/RAG/IRetrievalContextExtensions.cs | 43 +++++++--- .../Tools/RAG/RetrievalTextContext.cs | 10 +++ .../DataSourceLocalRetrievalService.cs | 11 ++- .../Tools/RetrievalContextDescriptionTests.cs | 84 +++++++++++++++++++ 4 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 app/Tests/Tools/RetrievalContextDescriptionTests.cs diff --git a/app/MindWork AI Studio/Tools/RAG/IRetrievalContextExtensions.cs b/app/MindWork AI Studio/Tools/RAG/IRetrievalContextExtensions.cs index 06ee5002..de48953b 100644 --- a/app/MindWork AI Studio/Tools/RAG/IRetrievalContextExtensions.cs +++ b/app/MindWork AI Studio/Tools/RAG/IRetrievalContextExtensions.cs @@ -8,7 +8,36 @@ namespace AIStudio.Tools.RAG; public static class IRetrievalContextExtensions { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); - + + /// + /// Writes what the AI is told about a retrieval context, before its content follows. + /// + /// + /// The location is what lets the AI say where an answer comes from. Naming only the file is + /// not enough in a document of two hundred pages, and we know the page: it travels from the + /// runtime through the index into the context. A slide or a sheet has no page, and then + /// nothing is claimed rather than something made up. + /// + /// The builder to write into. + /// The context to describe. + internal static void AppendContextDescription(StringBuilder contextBuilder, IRetrievalContext retrievalContext) + { + contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}"); + contextBuilder.AppendLine($"Content category: {retrievalContext.Category}"); + contextBuilder.AppendLine($"Content type: {retrievalContext.Type}"); + contextBuilder.AppendLine($"Content path: {retrievalContext.Path}"); + + if(retrievalContext is RetrievalTextContext { PageNumber: > 0 } locatedContext) + contextBuilder.AppendLine($"Content location: page {locatedContext.PageNumber}"); + + if(retrievalContext.Links.Count is 0) + return; + + contextBuilder.AppendLine("Additional links:"); + foreach(var link in retrievalContext.Links) + contextBuilder.AppendLine($"- {link}"); + } + public static async Task AsMarkdown(this IReadOnlyList retrievalContexts, StringBuilder? sb = null, CancellationToken token = default) { sb ??= new StringBuilder(); @@ -49,17 +78,7 @@ public static class IRetrievalContextExtensions break; } - contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}"); - contextBuilder.AppendLine($"Content category: {retrievalContext.Category}"); - contextBuilder.AppendLine($"Content type: {retrievalContext.Type}"); - contextBuilder.AppendLine($"Content path: {retrievalContext.Path}"); - - if(retrievalContext.Links.Count > 0) - { - contextBuilder.AppendLine("Additional links:"); - foreach(var link in retrievalContext.Links) - contextBuilder.AppendLine($"- {link}"); - } + AppendContextDescription(contextBuilder, retrievalContext); var guardService = Program.SERVICE_PROVIDER.GetRequiredService(); var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path); diff --git a/app/MindWork AI Studio/Tools/RAG/RetrievalTextContext.cs b/app/MindWork AI Studio/Tools/RAG/RetrievalTextContext.cs index 362acf99..e20075ce 100644 --- a/app/MindWork AI Studio/Tools/RAG/RetrievalTextContext.cs +++ b/app/MindWork AI Studio/Tools/RAG/RetrievalTextContext.cs @@ -50,4 +50,14 @@ public sealed class RetrievalTextContext : IRetrievalContext /// Optional link used when this context is displayed as a source reference. /// public string ReferenceLink { get; init; } = string.Empty; + + /// + /// The page this passage was found on, or null when it has none. + /// + /// + /// Kept as a number rather than only inside the reference title: the AI is told the page so it + /// can say where an answer comes from, and a source has to name a page a program can be sent + /// to. A slide or a sheet has no page and leaves this empty. + /// + public int? PageNumber { get; init; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs b/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs index 8b48e162..c8d12b68 100644 --- a/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs +++ b/app/MindWork AI Studio/Tools/Services/DataSourceLocalRetrievalService.cs @@ -395,6 +395,7 @@ public sealed class DataSourceLocalRetrievalService( SurroundingContent = [], ReferenceTitle = BuildReferenceTitle(hit), ReferenceLink = referenceLink, + PageNumber = hit.PageNumber is > 0 ? hit.PageNumber : null, }; } @@ -413,11 +414,19 @@ public sealed class DataSourceLocalRetrievalService( return $"{sourceName} ({location})"; } + /// + /// A known page is written as the fragment `#page=N`, which is what the PDF open parameters + /// call for: a program which understands them opens the document where the passage is. Without + /// a page there is nothing to send a program to, and the chunk stays in the link so the + /// reference still points at something. + /// private static string BuildReferenceLink(string path, LocalRetrievalHit hit) { var link = NormalizeLocalReferencePath(path); var separator = link.Contains('#', StringComparison.Ordinal) ? "&" : "#"; - return $"{link}{separator}chunk={hit.ChunkIndex}"; + return hit.PageNumber is > 0 + ? $"{link}{separator}page={hit.PageNumber}" + : $"{link}{separator}chunk={hit.ChunkIndex}"; } private static string NormalizeLocalReferencePath(string path) diff --git a/app/Tests/Tools/RetrievalContextDescriptionTests.cs b/app/Tests/Tools/RetrievalContextDescriptionTests.cs new file mode 100644 index 00000000..a7e8d244 --- /dev/null +++ b/app/Tests/Tools/RetrievalContextDescriptionTests.cs @@ -0,0 +1,84 @@ +using System.Text; + +using AIStudio.Tools.RAG; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks what the AI is told about a passage before it reads it. +/// +/// +/// The page a passage sits on travels from the runtime through the index into the retrieval +/// context, but it used to stop there: the AI was given the file and nothing else, so an answer +/// could name the document it rests on but never the place in it. A source which has no page, a +/// slide for instance, must stay silent rather than claim one. +/// +[TestFixture] +public sealed class RetrievalContextDescriptionTests +{ + [Test] + public void AKnownPageIsPartOfWhatTheAIIsTold() + { + var description = Describe(TextContext(pageNumber: 12)); + + Assert.That(description, Does.Contain("Content location: page 12"), "The AI is told the page, so it can say where an answer comes from."); + } + + [Test] + public void APassageWithoutAPageClaimsNone() + { + var description = Describe(TextContext(pageNumber: null)); + + Assert.That(description, Does.Not.Contain("Content location"), "A slide or a sheet has no page, and none is invented for it."); + } + + /// + /// The location belongs to the document, so it is stated with it and before the passage itself + /// follows further down. + /// + [Test] + public void ThePageIsStatedWithTheDocumentItBelongsTo() + { + var description = Describe(TextContext(pageNumber: 12)); + var lines = description.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToArray(); + + Assert.That(lines, Is.EqualTo(new[] + { + "Data source name: Handbooks", + "Content category: TEXT", + "Content type: TEXT_DOCUMENT", + "Content path: /docs/handbook.pdf", + "Content location: page 12", + }), "Name, kind, path and place of the document, in that order."); + } + + [Test] + public void AdditionalLinksStillFollowTheLocation() + { + var description = Describe(TextContext(pageNumber: 12, links: ["https://example.com/handbook"])); + + Assert.Multiple(() => + { + Assert.That(description, Does.Contain("Additional links:"), "The links a data source delivers are still passed on."); + Assert.That(description.IndexOf("Content location", StringComparison.Ordinal), Is.LessThan(description.IndexOf("Additional links", StringComparison.Ordinal)), "The place inside the document is stated before links pointing elsewhere."); + }); + } + + private static string Describe(IRetrievalContext retrievalContext) + { + var builder = new StringBuilder(); + IRetrievalContextExtensions.AppendContextDescription(builder, retrievalContext); + return builder.ToString(); + } + + private static RetrievalTextContext TextContext(int? pageNumber, IReadOnlyList? links = null) => new() + { + DataSourceName = "Handbooks", + Category = RetrievalContentCategory.TEXT, + Type = RetrievalContentType.TEXT_DOCUMENT, + Path = "/docs/handbook.pdf", + Links = links ?? [], + MatchedText = "The mixing console is described here.", + PageNumber = pageNumber, + }; +} \ No newline at end of file