mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 13:37:59 +00:00
Added the page of a passage to what the AI is told about it
This commit is contained in:
4 files changed
+135
-13
No files matched your search
@@ -8,7 +8,36 @@ namespace AIStudio.Tools.RAG;
|
|||||||
public static class IRetrievalContextExtensions
|
public static class IRetrievalContextExtensions
|
||||||
{
|
{
|
||||||
private static readonly ILogger<IRetrievalContext> LOGGER = Program.LOGGER_FACTORY.CreateLogger<IRetrievalContext>();
|
private static readonly ILogger<IRetrievalContext> LOGGER = Program.LOGGER_FACTORY.CreateLogger<IRetrievalContext>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes what the AI is told about a retrieval context, before its content follows.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="contextBuilder">The builder to write into.</param>
|
||||||
|
/// <param name="retrievalContext">The context to describe.</param>
|
||||||
|
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<string> AsMarkdown(this IReadOnlyList<IRetrievalContext> retrievalContexts, StringBuilder? sb = null, CancellationToken token = default)
|
public static async Task<string> AsMarkdown(this IReadOnlyList<IRetrievalContext> retrievalContexts, StringBuilder? sb = null, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
sb ??= new StringBuilder();
|
sb ??= new StringBuilder();
|
||||||
@@ -49,17 +78,7 @@ public static class IRetrievalContextExtensions
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}");
|
AppendContextDescription(contextBuilder, retrievalContext);
|
||||||
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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
|
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
|
||||||
var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path);
|
var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path);
|
||||||
|
|||||||
@@ -50,4 +50,14 @@ public sealed class RetrievalTextContext : IRetrievalContext
|
|||||||
/// Optional link used when this context is displayed as a source reference.
|
/// Optional link used when this context is displayed as a source reference.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ReferenceLink { get; init; } = string.Empty;
|
public string ReferenceLink { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The page this passage was found on, or null when it has none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public int? PageNumber { get; init; }
|
||||||
}
|
}
|
||||||
@@ -395,6 +395,7 @@ public sealed class DataSourceLocalRetrievalService(
|
|||||||
SurroundingContent = [],
|
SurroundingContent = [],
|
||||||
ReferenceTitle = BuildReferenceTitle(hit),
|
ReferenceTitle = BuildReferenceTitle(hit),
|
||||||
ReferenceLink = referenceLink,
|
ReferenceLink = referenceLink,
|
||||||
|
PageNumber = hit.PageNumber is > 0 ? hit.PageNumber : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,11 +414,19 @@ public sealed class DataSourceLocalRetrievalService(
|
|||||||
return $"{sourceName} ({location})";
|
return $"{sourceName} ({location})";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
private static string BuildReferenceLink(string path, LocalRetrievalHit hit)
|
private static string BuildReferenceLink(string path, LocalRetrievalHit hit)
|
||||||
{
|
{
|
||||||
var link = NormalizeLocalReferencePath(path);
|
var link = NormalizeLocalReferencePath(path);
|
||||||
var separator = link.Contains('#', StringComparison.Ordinal) ? "&" : "#";
|
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)
|
private static string NormalizeLocalReferencePath(string path)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using AIStudio.Tools.RAG;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks what the AI is told about a passage before it reads it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The location belongs to the document, so it is stated with it and before the passage itself
|
||||||
|
/// follows further down.
|
||||||
|
/// </remarks>
|
||||||
|
[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<string>? 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user