diff --git a/CrestApps.Core.slnx b/CrestApps.Core.slnx
index 3fb0b924..55b82165 100644
--- a/CrestApps.Core.slnx
+++ b/CrestApps.Core.slnx
@@ -44,13 +44,9 @@
-
-
-
+
-
-
-
+
@@ -59,9 +55,7 @@
-
-
-
+
diff --git a/src/CrestApps.Core.Docs/docs/changelog/2.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/2.0.0.md
index 6f3dcdd3..6698b582 100644
--- a/src/CrestApps.Core.Docs/docs/changelog/2.0.0.md
+++ b/src/CrestApps.Core.Docs/docs/changelog/2.0.0.md
@@ -1303,6 +1303,50 @@ microphone, desk speakers):
See [Document Processing](../core/document-processing.md#how-large-an-upload-may-be).
+## A spreadsheet you can look at
+
+Every tabular tool answered a question *about* the uploaded data. None of them showed it. A user who
+uploaded a workbook and asked what was in it was told about it in prose and had to take that on trust — that
+the right worksheet was read, that the header row was found where they expect it, that a column means what
+the answer says it means.
+
+The Tabular Data Agent now has a `preview_tabular_data` tool that draws the data as a picture of a
+spreadsheet: the lettered column band, the numbered rows, a filled header row, numbers aligned right. It is
+called whenever the user asks to see, view or preview a table, once after the first question about a newly
+uploaded file, and — with a `sql` argument — to show what an export will contain *before* the file is
+written.
+
+- **It is SVG, not a raster image.** Nothing in the process can rasterize one: there is no SkiaSharp, no
+ ImageSharp, no `System.Drawing`, and PDFsharp emits PDF. A vector picture needs no drawing library and no
+ fonts shipped with the host — the browser showing it already has both — so the preview is a few kilobytes
+ of markup that stays sharp at whatever size the chat surface gives it, and the feature ships without a new
+ package or per-RID native assets.
+- **It goes through the paths that already exist.** The picture is stored by `IGeneratedDocumentService` and
+ served by the same authorized document download endpoint as an export, so authorization, cleanup on
+ history clear, and exclusion from the tabular workspace all come for free. It is registered as a `[fig:N]`
+ reference — the mechanism a figure retrieved from a knowledge base already uses — so no chat surface
+ needed a change.
+- **It is bounded, and says so.** A grid large enough to hold a real workbook is scaled down by the chat
+ surface until none of it is readable, so a preview is capped at the first rows and columns and captioned
+ with what it left out: *"Showing 50 of 4,812 rows and 12 of 20 columns"*. `TabularPreviewOptions` moves the
+ caps.
+- **It previews the workspace, not the upload,** so a column added or a value corrected earlier in the
+ conversation is visible in it — consistent with every other tabular tool, and the more useful of the two.
+- **A host that cannot show a picture gets the same window as text.** Where no download endpoint is
+ registered there is no address to draw from, and a marker registered without one reaches the reader as a
+ broken image. The same applies where no writer is registered for the preview format. The tool falls back
+ whole to a Markdown table of the same rows and columns rather than answering with some pictures and some
+ markers. The fallback turns on whether the picture can be *delivered*, not on what the model can read: a
+ vision-capable deployment does not make a host able to draw.
+- **The preview format is resolvable but not requestable.** `AddGeneratedFileWriter` gained a `requestable`
+ overload, and the preview writer is registered with it turned off: the host can write the format, while
+ it stays out of `IGeneratedFileWriterResolver.SupportedExtensions`, which is what `generate_file` and
+ `export_tabular_data` gate on. A preview is markup this host generated and escaped; the same extension
+ reached through a tool whose `content` argument is written verbatim would serve model-supplied markup
+ from the application's own origin.
+
+See [AI Documents](../core/ai-documents.md#seeing-the-data).
+
## Change Logs
- **Fixed: several references written in one bracket reached the reader as a raw marker.** A reference is
diff --git a/src/CrestApps.Core.Docs/docs/core/ai-documents.md b/src/CrestApps.Core.Docs/docs/core/ai-documents.md
index e2d1203e..804687a5 100644
--- a/src/CrestApps.Core.Docs/docs/core/ai-documents.md
+++ b/src/CrestApps.Core.Docs/docs/core/ai-documents.md
@@ -63,6 +63,31 @@ This is the recommended path for tasks such as:
Under the hood, tabular workflows are handled by the built-in **Tabular Data Agent**. It is a code-defined, always-available **system agent** that stays hidden from the AI Profile and Chat Interaction agent pickers, yet still participates in orchestration and is exposed through the A2A host for remote clients.
+#### Seeing the data
+
+Everything else the agent does answers a question *about* the data. The hidden `preview_tabular_data` tool shows the data itself: a picture of the sheet, with its header row, lettered columns, numbered rows and numbers aligned right, drawn from the first rows and columns of whatever is currently loaded.
+
+It is what lets a user check for themselves that the right worksheet was read and the header row was found where they expect it, rather than taking the answer's word for it. The agent calls it whenever the user asks to see, view or preview a table, once after the first question about a newly uploaded file, and — given a `sql` argument — to show what an export will contain before the file is created.
+
+The picture is written as SVG and served through the same authorized document download endpoint as any other generated file, so it needs no drawing library, no fonts on the host, and no extra registration. The tool returns one `[fig:N]` marker per table, which the chat surfaces replace with the picture.
+
+Previews are bounded on purpose and say what they left out — *"Showing 50 of 4,812 rows and 12 of 20 columns"* — because a grid large enough to hold everything is scaled down by the chat surface until none of it is readable. The limits are configurable:
+
+```csharp
+builder.Services.Configure(options =>
+{
+ options.MaxRows = 50;
+ options.MaxColumns = 15;
+ options.MaxCellCharacters = 32;
+ options.MaxTables = 4;
+ options.MaxImageWidth = 1100;
+});
+```
+
+A host with no document download endpoint registered cannot serve the picture, and one with no writer registered for the preview format cannot write it; in either case the tool falls back to a Markdown table of the same window of the same data. The user can also ask for the text form directly.
+
+The preview format is registered as a writer the host can resolve but **not** as a format a caller may request, so `generate_file` and `export_tabular_data` will not produce one. A preview is markup this host generated and escaped; the same extension reached through a tool whose `content` argument is written verbatim would serve model-supplied markup from your origin.
+
#### Spreadsheet formatting
Exported `.xlsx` files are written with real cell types: a numeric column becomes numbers and a date column becomes date serials, so the recipient can sum, sort, filter, and chart the result rather than receiving a sheet of text. A column whose leading zeros matter — a postal code or an account number — is detected and kept as text.
diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs
index f83e4528..220cbb52 100644
--- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs
+++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs
@@ -1,5 +1,6 @@
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
+using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using CrestApps.Core.AI.Capabilities;
@@ -1657,6 +1658,8 @@ protected virtual async Task HandlePromptAsync(
CollectStreamingReferences(services, handlerContext, references, contentItemIds);
+ string lastResponseId = null;
+
await foreach (var chunk in handlerResult.ResponseStream.WithCancellation(cancellationToken))
{
if (string.IsNullOrEmpty(chunk.Text))
@@ -1665,6 +1668,7 @@ protected virtual async Task HandlePromptAsync(
}
builder.Append(chunk.Text);
+ lastResponseId = chunk.ResponseId;
CollectStreamingReferences(services, handlerContext, references, contentItemIds);
var partialMessage = new CompletionPartialMessage
@@ -1682,6 +1686,33 @@ protected virtual async Task HandlePromptAsync(
CollectStreamingReferences(services, handlerContext, references, contentItemIds);
+ // A picture the host has already drawn and stored must not depend on the model remembering to
+ // write its marker. This is the rule generated downloads already follow — IsGenerated exists so
+ // an exported file reaches the reader whether or not it was cited — applied to an image, which
+ // is shown where its marker sits rather than listed underneath. Observed repeatedly: the answer
+ // says "the previews are shown above" and writes no marker, so the reader is told about images
+ // that were built, stored and served, and sees none of them.
+ var trailingImageMarkers = BuildUncitedImageMarkers(builder.AsSpan(), references);
+
+ if (!string.IsNullOrEmpty(trailingImageMarkers))
+ {
+ // Streamed as one more chunk rather than only appended to the stored text, so the images
+ // appear in the answer being read now as well as after a reload.
+ builder.Append(trailingImageMarkers);
+
+ await writer.WriteAsync(
+ new CompletionPartialMessage
+ {
+ SessionId = interaction.ItemId,
+ MessageId = assistantPrompt.ItemId,
+ ResponseId = lastResponseId,
+ Content = trailingImageMarkers,
+ References = references,
+ Appearance = handlerContext.AssistantAppearance,
+ },
+ cancellationToken);
+ }
+
if (builder.Length > 0)
{
assistantPrompt.Text = builder.ToString();
@@ -2116,6 +2147,67 @@ await Clients.Caller.ReceiveConversationAssistantComplete(
}
}
+ ///
+ /// Returns the markers for every servable picture the answer did not name, ready to be appended to it.
+ ///
+ ///
+ /// Only a reference the host can actually turn into a picture is added: one flagged as an image and
+ /// carrying an address this host serves. A marker without a picture behind it would reach the reader as
+ /// the few characters the host typed, which is worse than the omission it was meant to repair.
+ ///
+ /// Ordered by the reference index so the pictures appear in the order the tool produced them rather
+ /// than in whatever order the map happens to enumerate.
+ ///
+ ///
+ /// The answer as written.
+ /// The references collected for the answer.
+ /// The text to append, or when every picture was named.
+ private static string BuildUncitedImageMarkers(
+ ReadOnlySpan text,
+ Dictionary references)
+ {
+ if (references is null || references.Count == 0)
+ {
+ return null;
+ }
+
+ List> uncited = null;
+
+ foreach (var reference in references)
+ {
+ if (reference.Value?.IsImage != true ||
+ string.IsNullOrWhiteSpace(reference.Value.Link) ||
+ string.IsNullOrEmpty(reference.Key) ||
+ text.Contains(reference.Key, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ (uncited ??= []).Add(reference);
+ }
+
+ if (uncited is null)
+ {
+ return null;
+ }
+
+ uncited.Sort((left, right) => left.Value.Index.CompareTo(right.Value.Index));
+
+ var builder = new StringBuilder(Environment.NewLine + Environment.NewLine);
+
+ for (var index = 0; index < uncited.Count; index++)
+ {
+ if (index > 0)
+ {
+ builder.Append(' ');
+ }
+
+ builder.Append(uncited[index].Key);
+ }
+
+ return builder.ToString();
+ }
+
private static async Task> GetPromptReferencesAsync(
IServiceProvider services,
string itemId,
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs
index cdd65106..60d14ed7 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs
@@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Documents.Endpoints;
@@ -43,8 +44,11 @@ private static async Task HandleAsync(
[FromServices] IAuthorizationService authorizationService,
[FromServices] ICatalogManager interactionManager,
[FromServices] IAIChatSessionManager sessionManager,
- [FromServices] IAIProfileManager profileManager)
+ [FromServices] IAIProfileManager profileManager,
+ [FromServices] ILoggerFactory loggerFactory)
{
+ var logger = loggerFactory.CreateLogger(typeof(DownloadAIDocument).FullName);
+
if (string.IsNullOrWhiteSpace(documentId))
{
return Results.BadRequest();
@@ -52,8 +56,25 @@ private static async Task HandleAsync(
var document = await documentStore.FindByIdAsync(documentId);
- if (document is null || string.IsNullOrWhiteSpace(document.StoredFilePath))
+ // Each reason is reported separately. A bare 404 says only that the address did not resolve, which
+ // is the same answer for a document that was never created, one whose row has not been committed
+ // yet, and one whose bytes are missing — three different faults that need three different fixes.
+ if (document is null)
{
+ logger.LogWarning(
+ "Download of AI document '{DocumentId}' returned 404: no document with that id is readable. If it was created during the request that produced this link, its row may not be committed yet.",
+ documentId);
+
+ return Results.NotFound();
+ }
+
+ if (string.IsNullOrWhiteSpace(document.StoredFilePath))
+ {
+ logger.LogWarning(
+ "Download of AI document '{DocumentId}' ('{FileName}') returned 404: the document record carries no stored file path.",
+ documentId,
+ document.FileName);
+
return Results.NotFound();
}
@@ -74,9 +95,25 @@ private static async Task HandleAsync(
if (stream is null)
{
+ logger.LogWarning(
+ "Download of AI document '{DocumentId}' ('{FileName}') returned 404: no file exists at '{StoredFilePath}'.",
+ documentId,
+ document.FileName,
+ document.StoredFilePath);
+
return Results.NotFound();
}
+ if (logger.IsEnabled(LogLevel.Debug))
+ {
+ logger.LogDebug(
+ "Serving AI document '{DocumentId}' ('{FileName}') as '{ContentType}', {FileSize} bytes.",
+ documentId,
+ document.FileName,
+ document.ContentType,
+ document.FileSize);
+ }
+
return Results.File(
stream,
string.IsNullOrWhiteSpace(document.ContentType) ? "application/octet-stream" : document.ContentType,
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Generation/DefaultGeneratedDocumentService.cs b/src/Primitives/CrestApps.Core.AI.Documents/Generation/DefaultGeneratedDocumentService.cs
index b18acfeb..a8206dd3 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Generation/DefaultGeneratedDocumentService.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Generation/DefaultGeneratedDocumentService.cs
@@ -1,6 +1,7 @@
using CrestApps.Core.AI.Ingestion;
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Orchestration;
+using CrestApps.Core.Services;
namespace CrestApps.Core.AI.Documents.Generation;
@@ -25,6 +26,7 @@ public sealed class DefaultGeneratedDocumentService : IGeneratedDocumentService
private readonly IAIDocumentStore _documentStore;
private readonly IDocumentFileStore _fileStore;
private readonly TimeProvider _timeProvider;
+ private readonly IStoreCommitter _committer;
///
/// Initializes a new instance of the class.
@@ -33,16 +35,19 @@ public sealed class DefaultGeneratedDocumentService : IGeneratedDocumentService
/// The document metadata store.
/// The document file store.
/// The time provider.
+ /// The store committer, when the host registered one.
public DefaultGeneratedDocumentService(
IGeneratedFileWriterResolver writerResolver,
IAIDocumentStore documentStore,
IDocumentFileStore fileStore,
- TimeProvider timeProvider)
+ TimeProvider timeProvider,
+ IStoreCommitter committer = null)
{
_writerResolver = writerResolver;
_documentStore = documentStore;
_fileStore = fileStore;
_timeProvider = timeProvider;
+ _committer = committer;
}
///
@@ -91,7 +96,20 @@ public async Task CreateAsync(GeneratedDocumentRequest
await _documentStore.CreateAsync(document, cancellationToken);
- var referenceToken = AddDownloadReference(document);
+ // The file is about to be handed out as an address, so the row that address resolves through has to
+ // be readable by the request that follows it. Staged in this request's session it is not: the
+ // conversation commits when the turn ends, and a preview image embedded in the answer is fetched by
+ // the browser the moment the message renders — measured here at 78 ms before that commit, which the
+ // endpoint answered with 404 for every image. A download link survives only because a person takes
+ // seconds to click it.
+ if (_committer is not null)
+ {
+ await _committer.CommitAsync(cancellationToken);
+ }
+
+ var referenceToken = request.RegisterDownloadReference
+ ? AddDownloadReference(document)
+ : null;
return new GeneratedDocumentResult(document, referenceToken);
}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedDocumentRequest.cs b/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedDocumentRequest.cs
index 35b81ed8..8478325b 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedDocumentRequest.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedDocumentRequest.cs
@@ -49,4 +49,15 @@ public GeneratedDocumentRequest(
/// Gets the content to write to the generated file.
///
public GeneratedFileContent Content { get; }
+
+ ///
+ /// Gets a value indicating whether the created document is registered on the active invocation as a
+ /// citable download. Defaults to .
+ ///
+ ///
+ /// A caller that presents the file some other way turns this off and registers its own reference — a
+ /// preview image, for instance, is shown where its marker sits rather than listed underneath the
+ /// answer. Leaving it on would offer the same file a second time as a download nobody asked for.
+ ///
+ public bool RegisterDownloadReference { get; init; } = true;
}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedFileWriterServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedFileWriterServiceCollectionExtensions.cs
index ae82ec12..3d003bf0 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedFileWriterServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Generation/GeneratedFileWriterServiceCollectionExtensions.cs
@@ -10,25 +10,52 @@ public static class GeneratedFileWriterServiceCollectionExtensions
{
///
/// Registers an implementation as a keyed singleton for each
- /// supplied file extension and records the extensions as supported output formats.
+ /// supplied file extension and records the extensions as output formats a caller may request.
///
/// The writer implementation type.
/// The service collection.
/// The file extensions handled by the writer, with or without a leading dot.
public static IServiceCollection AddGeneratedFileWriter(this IServiceCollection services, params string[] extensions)
where TWriter : class, IGeneratedFileWriter
+ {
+ return services.AddGeneratedFileWriter(requestable: true, extensions);
+ }
+
+ ///
+ /// Registers an implementation as a keyed singleton for each
+ /// supplied file extension, optionally without offering those extensions as formats a caller may ask
+ /// for.
+ ///
+ ///
+ /// An unrequestable format still resolves, so the host can write it, but it is absent from
+ /// and
+ /// , which is what the file-creation tools gate
+ /// on. That distinction matters for a format whose safety depends on who wrote it: a preview image is
+ /// markup this host generated and escaped, and the same extension reached through a tool whose content
+ /// argument is written verbatim would be markup a model supplied, served from this origin.
+ ///
+ /// The writer implementation type.
+ /// The service collection.
+ /// Whether callers may ask for these formats by name.
+ /// The file extensions handled by the writer, with or without a leading dot.
+ public static IServiceCollection AddGeneratedFileWriter(this IServiceCollection services, bool requestable, params string[] extensions)
+ where TWriter : class, IGeneratedFileWriter
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(extensions);
services.TryAddSingleton();
- services.Configure(options =>
+
+ if (requestable)
{
- foreach (var extension in extensions)
+ services.Configure(options =>
{
- options.Add(extension);
- }
- });
+ foreach (var extension in extensions)
+ {
+ options.Add(extension);
+ }
+ });
+ }
foreach (var extension in extensions)
{
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs
index 88c6e097..6734cb04 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs
@@ -60,6 +60,7 @@ public static IServiceCollection AddCoreAIDocumentProcessing(this IServiceCollec
// Per-prompt tabular workspace options + the system tabular data agent that queries it.
services.AddOptions();
+ services.AddOptions();
services.TryAddSingleton();
services.TryAddScoped();
services.AddTemplatesFromAssembly(typeof(ServiceCollectionExtensions).Assembly);
@@ -83,6 +84,13 @@ public static IServiceCollection AddCoreAIDocumentProcessing(this IServiceCollec
".yml",
".log");
+ // A drawn preview is markup, so the text writer already knows how to store it, and registering the
+ // extension is what gives the stored file its image content type. It is deliberately NOT requestable:
+ // the preview this host draws is markup it escaped itself, whereas generate_file writes its content
+ // argument verbatim, and the same extension reached that way would serve model-supplied markup from
+ // this origin.
+ services.AddGeneratedFileWriter(requestable: false, ".svg");
+
services.TryAddEnumerable(ServiceDescriptor.Scoped());
services.TryAddEnumerable(ServiceDescriptor.Scoped());
services.TryAddEnumerable(ServiceDescriptor.Scoped());
@@ -118,6 +126,12 @@ public static IServiceCollection AddCoreAIDocumentProcessing(this IServiceCollec
.WithCategory("Tabular Data")
.Hidden();
+ services.AddCoreAITool(PreviewTabularDataTool.TheName)
+ .WithTitle("Preview Tabular Data")
+ .WithDescription("Shows the uploaded tabular data as a picture of a spreadsheet, truncated to the first rows and columns.")
+ .WithCategory("Tabular Data")
+ .Hidden();
+
services.AddCoreAITool(ExecuteTabularCommandTool.TheName)
.WithTitle("Execute Tabular Command")
.WithDescription("Applies a SQL manipulation (INSERT, UPDATE, DELETE, ALTER) to the in-memory copy of uploaded tabular data, preserving the original file.")
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularDataAgentProvider.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularDataAgentProvider.cs
index 97ad9d91..430e83a1 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularDataAgentProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularDataAgentProvider.cs
@@ -85,6 +85,7 @@ private static AIProfile BuildAgent(string systemPrompt)
SystemToolNames.GetDocumentMetadata,
TabularToolNames.ListTabularData,
TabularToolNames.QueryTabularData,
+ TabularToolNames.PreviewTabularData,
TabularToolNames.ExecuteTabularCommand,
TabularToolNames.FillEmptyTabularCells,
TabularToolNames.FormatTabularData,
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularFormattingResolver.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularFormattingResolver.cs
new file mode 100644
index 00000000..69e7551a
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularFormattingResolver.cs
@@ -0,0 +1,196 @@
+using CrestApps.Core.AI.Documents.Generation.Spreadsheets;
+
+namespace CrestApps.Core.AI.Documents.Tabular;
+
+///
+/// Resolves the formatting that describes how a table is presented.
+///
+///
+/// The export writes this formatting into the workbook and the preview draws a picture of it, and the two
+/// have to agree: a preview showing a different header colour, or a raw number where the file shows a
+/// currency amount, is a picture of a document the reader never receives. Both therefore resolve the
+/// formatting here rather than each reading the recorded specification its own way.
+///
+internal static class TabularFormattingResolver
+{
+ ///
+ /// Loads the recorded formatting and aligns its column references with the headers that were
+ /// actually produced.
+ ///
+ /// A full export writes the original source headers while a query writes SQL column names, so a
+ /// specification recorded against one naming would silently format nothing against the other.
+ /// Translating the names keeps a formatting request working no matter how the file is exported.
+ ///
+ ///
+ /// The stored specification.
+ /// The header row that was produced.
+ /// The table the formatting was recorded against.
+ /// The formatting to apply, or when none is recorded.
+ public static SpreadsheetFormatting Resolve(
+ string specJson,
+ List header,
+ TabularTableInfo table)
+ {
+ var formatting = SpreadsheetFormattingJson.Deserialize(specJson);
+
+ if (table is null || header is null || header.Count == 0)
+ {
+ return formatting;
+ }
+
+ // The source file's own number formats are applied as defaults even when nothing was requested,
+ // so a column that was currency in the upload comes back as currency.
+ formatting = ApplySourceFormats(formatting, header, table);
+
+ if (formatting is null)
+ {
+ return null;
+ }
+
+ var aliases = BuildColumnAliases(header, table);
+
+ if (aliases.Count == 0)
+ {
+ return formatting;
+ }
+
+ foreach (var column in formatting.Columns)
+ {
+ column.Column = Translate(column.Column, aliases);
+ }
+
+ foreach (var conditional in formatting.ConditionalFormats)
+ {
+ conditional.Column = Translate(conditional.Column, aliases);
+ }
+
+ if (formatting.TotalRow?.Columns is not null)
+ {
+ foreach (var total in formatting.TotalRow.Columns)
+ {
+ total.Column = Translate(total.Column, aliases);
+ }
+ }
+
+ foreach (var chart in formatting.Charts)
+ {
+ chart.CategoryColumn = Translate(chart.CategoryColumn, aliases);
+
+ for (var index = 0; index < chart.ValueColumns.Count; index++)
+ {
+ chart.ValueColumns[index] = Translate(chart.ValueColumns[index], aliases);
+ }
+ }
+
+ return formatting;
+ }
+
+ ///
+ /// Seeds each column with the number format it had in the source file, for columns the caller did
+ /// not format explicitly.
+ ///
+ /// The formats are defaults, never overrides: a column the caller formatted keeps what they asked
+ /// for. Only columns that were actually produced are seeded, so a query that aliases or aggregates
+ /// a column does not inherit a format that no longer describes it.
+ ///
+ ///
+ /// The recorded formatting, which may be .
+ /// The header row that was produced.
+ /// The source table.
+ /// The formatting including the inherited defaults, or when there is nothing to apply.
+ private static SpreadsheetFormatting ApplySourceFormats(
+ SpreadsheetFormatting formatting,
+ List header,
+ TabularTableInfo table)
+ {
+ var inherited = new List();
+
+ foreach (var name in header)
+ {
+ if (string.IsNullOrWhiteSpace(name) || formatting?.FindColumn(name) is not null)
+ {
+ continue;
+ }
+
+ var column = table.Columns.FirstOrDefault(candidate =>
+ SpreadsheetFormatting.NameMatches(candidate.Name, name) ||
+ SpreadsheetFormatting.NameMatches(candidate.SourceName, name));
+
+ if (column is null || string.IsNullOrWhiteSpace(column.SourceFormat))
+ {
+ continue;
+ }
+
+ inherited.Add(new SpreadsheetColumnFormat
+ {
+ Column = name,
+ FormatCode = column.SourceFormat,
+ });
+ }
+
+ if (inherited.Count == 0)
+ {
+ return formatting;
+ }
+
+ formatting ??= new SpreadsheetFormatting();
+
+ foreach (var column in inherited)
+ {
+ formatting.Columns.Add(column);
+ }
+
+ return formatting;
+ }
+
+ private static Dictionary BuildColumnAliases(
+ List header,
+ TabularTableInfo table)
+ {
+ var headerNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var name in header)
+ {
+ if (!string.IsNullOrWhiteSpace(name))
+ {
+ headerNames.Add(name.Trim());
+ }
+ }
+
+ var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var column in table.Columns)
+ {
+ if (string.IsNullOrWhiteSpace(column.SourceName) ||
+ string.Equals(column.SourceName, column.Name, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ // Only map toward a name that was actually written, so a reference that already matches is
+ // never rewritten into one that does not.
+ if (headerNames.Contains(column.SourceName) && !headerNames.Contains(column.Name))
+ {
+ aliases[column.Name] = column.SourceName;
+ }
+ else if (headerNames.Contains(column.Name) && !headerNames.Contains(column.SourceName))
+ {
+ aliases[column.SourceName] = column.Name;
+ }
+ }
+
+ return aliases;
+ }
+
+ private static string Translate(string name, Dictionary aliases)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return name;
+ }
+
+ return aliases.TryGetValue(name.Trim(), out var alias)
+ ? alias
+ : name;
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewGrid.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewGrid.cs
new file mode 100644
index 00000000..f023b860
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewGrid.cs
@@ -0,0 +1,279 @@
+using System.Globalization;
+using CrestApps.Core.AI.Documents.Generation.Spreadsheets;
+using Cysharp.Text;
+
+namespace CrestApps.Core.AI.Documents.Tabular;
+
+///
+/// One column of a tabular preview, as the reader meets it.
+///
+/// The heading printed above the column.
+/// Whether the column holds numbers, which is what makes it right-aligned.
+internal readonly record struct TabularPreviewColumn(string Header, bool IsNumeric);
+
+///
+/// A bounded, display-ready window onto tabular data: the first rows and columns, with every cell already
+/// clipped to a length that fits a grid.
+///
+///
+/// The shaping happens once, here, so the drawn preview and the written-out one show the same window of the
+/// same data. A preview that dropped different rows depending on how it was rendered would be worse than no
+/// preview at all, because the reader has no way to tell which one they were handed.
+///
+internal sealed class TabularPreviewGrid
+{
+ private TabularPreviewGrid(
+ string title,
+ string subtitle,
+ IReadOnlyList columns,
+ IReadOnlyList rows,
+ long totalRowCount,
+ int totalColumnCount,
+ SpreadsheetFormatting formatting)
+ {
+ Title = title;
+ Subtitle = subtitle;
+ Columns = columns;
+ Rows = rows;
+ TotalRowCount = totalRowCount;
+ TotalColumnCount = totalColumnCount;
+ Formatting = formatting;
+ }
+
+ ///
+ /// Gets the heading above the grid, usually the worksheet or table name.
+ ///
+ public string Title { get; }
+
+ ///
+ /// Gets the line under the title naming where the data came from, when there is one.
+ ///
+ public string Subtitle { get; }
+
+ ///
+ /// Gets the columns kept for display, in order.
+ ///
+ public IReadOnlyList Columns { get; }
+
+ ///
+ /// Gets the rows kept for display. Each row is aligned to and every cell is
+ /// already clipped.
+ ///
+ public IReadOnlyList Rows { get; }
+
+ ///
+ /// Gets the number of rows the underlying data holds, which is what the preview is a window onto.
+ ///
+ public long TotalRowCount { get; }
+
+ ///
+ /// Gets the number of columns the underlying data holds.
+ ///
+ public int TotalColumnCount { get; }
+
+ ///
+ /// Gets the formatting the exported workbook is written with, or when none is
+ /// recorded.
+ ///
+ ///
+ /// Carried so the drawn preview takes its header colour and row banding from the same description the
+ /// file is written from, rather than from a palette of its own that the reader never sees again.
+ ///
+ public SpreadsheetFormatting Formatting { get; }
+
+ ///
+ /// Builds the bounded window onto a result set.
+ ///
+ /// The heading above the grid.
+ /// The line naming the source, or .
+ /// The column headings, in result order.
+ /// The result rows, each aligned to .
+ ///
+ /// The number of rows the underlying data holds. Pass anything lower than the number of rows supplied
+ /// and the rows on hand are reported as the whole of it.
+ ///
+ ///
+ /// The SQLite storage type of each column, when the caller knows it. Where it is missing the type is
+ /// inferred from the values on hand, which is enough to decide alignment.
+ ///
+ /// The preview limits.
+ ///
+ /// The formatting the exported workbook is written with, or . Each column's
+ /// values are presented the way the file presents them, so the picture and the download agree.
+ ///
+ /// The shaped grid.
+ public static TabularPreviewGrid Create(
+ string title,
+ string subtitle,
+ IReadOnlyList headers,
+ IReadOnlyList
public const string QueryTabularData = "query_tabular_data";
+ ///
+ /// The tool that shows the tabular data to the reader as a picture of a spreadsheet, or as a written
+ /// table where the host cannot show a picture.
+ ///
+ public const string PreviewTabularData = "preview_tabular_data";
+
///
/// The tool that runs a manipulation or schema statement against the tabular workspace.
///
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Templates/Prompts/tabular-data-agent.md b/src/Primitives/CrestApps.Core.AI.Documents/Templates/Prompts/tabular-data-agent.md
index 76d7bd75..cb4e461e 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Templates/Prompts/tabular-data-agent.md
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Templates/Prompts/tabular-data-agent.md
@@ -20,7 +20,21 @@ How to work:
3. Use query_tabular_data to run read-only SQL (SQLite dialect) that directly answers the request.
Prefer aggregation, filtering, GROUP BY, and small LIMITs. Never try to read every row into your
answer — push the computation into SQL and return only the result the user needs.
-4. Use execute_tabular_command only when the user asks to modify the data (for example adding or
+4. Use preview_tabular_data whenever the user wants to SEE the data rather than be told about it: any
+ request to show, view, display, or preview a file or table, any "what does it look like", and any
+ request for a sample or the first rows. Call it once as well right after the first question about a
+ newly uploaded file, so the user can confirm the right sheet and header row were read before trusting
+ anything you say about them. Omit both arguments to show every loaded table; pass `table_name` for one
+ of them; pass `sql` to show a joined, filtered, or reshaped result — in particular, preview the export
+ query BEFORE calling export_tabular_data so the user sees what the file will contain. The preview is
+ truncated to the first rows and columns on purpose and says what it left out, so do not apologize for it
+ or try to widen it by calling the tool repeatedly. It returns one `[fig:N]` marker per table, and those
+ markers MUST appear in your reply text exactly as given — they are placeholders the host swaps for the
+ images, so a reply that leaves them out shows the user nothing. NEVER write "the preview images are shown
+ above", "see the previews below", or any other sentence that refers to the images instead of writing
+ their markers; the images exist only where a marker is. A preview is not a download; when the user wants
+ the file itself, use export_tabular_data.
+5. Use execute_tabular_command only when the user asks to modify the data (for example adding or
removing a column, updating values, or inserting rows). These changes apply to the in-memory copy
and persist for the rest of the conversation so they can be exported later; the originally uploaded
file itself is never modified. Always apply every requested change with execute_tabular_command
@@ -31,7 +45,7 @@ How to work:
large files. When a request needs several different changes, put all of them in ONE
execute_tabular_command call by separating the statements with semicolons (they run together in a
single transaction). Do not make many separate execute_tabular_command calls.
-5. Use format_tabular_data whenever the user asks for anything about how the file LOOKS rather than
+6. Use format_tabular_data whenever the user asks for anything about how the file LOOKS rather than
what it contains: currency/number/percent/date formatting, decimal places, colors, bold text,
column widths, a frozen header, filters, conditional formatting (including a gradient or "heat
map" across a column, data bars, or highlighting negatives in red), a calculated column, a total
@@ -59,7 +73,7 @@ How to work:
values in SQL and exporting them as numbers gives the reader a dead figure that stops agreeing
with the sheet the moment they change anything; only do that when the value cannot be expressed
from the row (for example it comes from another table or a window function).
-6. Use export_tabular_data when the user asks for a downloadable/new version of a tabular file (for
+7. Use export_tabular_data when the user asks for a downloadable/new version of a tabular file (for
example a sorted file, filtered file, or file with generated columns). To give the user the file
with their updated data, call export_tabular_data WITHOUT a sql argument: this exports the entire
current in-memory table (all rows and all columns, including every change you applied). The export
@@ -84,7 +98,7 @@ How to work:
changed since the last export, and never call generate_file after a tabular export. However, if the
user later mutates the data and requests a new download in a follow-up message, you should call
export_tabular_data again to produce the updated file.
-7. Use generate_chart when the user wants to SEE a chart in the conversation, as opposed to a chart
+8. Use generate_chart when the user wants to SEE a chart in the conversation, as opposed to a chart
embedded in a downloadable workbook (which is format_tabular_data's `charts`). Always pass the
real values: put the category names in `labels` and the numbers in `series`, taken from a query
you just ran. Never describe the data in prose and ask the tool to reconstruct it — that loses
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tooling/FigureReferenceMarker.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tooling/FigureReferenceMarker.cs
new file mode 100644
index 00000000..ee341ab4
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tooling/FigureReferenceMarker.cs
@@ -0,0 +1,66 @@
+using CrestApps.Core.AI.Orchestration;
+
+namespace CrestApps.Core.AI.Documents.Tooling;
+
+///
+/// The short label a tool hands the model so the host can draw a picture where the label sits.
+///
+///
+/// A model asked to reproduce a picture's address reproduces its shape and varies the digits, so the
+/// address is never given to it. It writes this label instead, the reference is registered under the very
+/// same string, and the host substitutes the picture back in.
+///
+/// Every tool that shows a picture draws its numbers from here. The first registration of a key wins, so a
+/// second tool restarting at one would silently take over a marker the first had already claimed and the
+/// reader would be shown a picture the answer never named.
+///
+///
+internal static class FigureReferenceMarker
+{
+ ///
+ /// What a figure marker opens with.
+ ///
+ public const string Prefix = "[fig:";
+
+ ///
+ /// Builds the marker for a figure number.
+ ///
+ /// The figure number.
+ /// The marker, for example [fig:1].
+ public static string Format(int index)
+ {
+ return $"{Prefix}{index}]";
+ }
+
+ ///
+ /// Returns the first figure number nothing in the invocation has claimed yet.
+ ///
+ /// The invocation context, or when there is none.
+ /// The first free marker number.
+ public static int NextIndex(AIInvocationContext context)
+ {
+ if (context is null)
+ {
+ return 1;
+ }
+
+ var highest = 0;
+
+ foreach (var key in context.ToolReferences.Keys)
+ {
+ if (key.Length <= Prefix.Length + 1 ||
+ !key.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) ||
+ key[^1] != ']')
+ {
+ continue;
+ }
+
+ if (int.TryParse(key.AsSpan(Prefix.Length, key.Length - Prefix.Length - 1), out var index) && index > highest)
+ {
+ highest = index;
+ }
+ }
+
+ return highest + 1;
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tooling/KnowledgeObjectListToolFunction.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tooling/KnowledgeObjectListToolFunction.cs
index 01721b84..b42223df 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Tooling/KnowledgeObjectListToolFunction.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tooling/KnowledgeObjectListToolFunction.cs
@@ -27,8 +27,6 @@ namespace CrestApps.Core.AI.Documents.Tooling;
///
public sealed class KnowledgeObjectListToolFunction : AIFunction
{
- private const string FigureLabelPrefix = "[fig:";
-
private const string ParentRelation = "parent";
private const string SiblingsRelation = "siblings";
@@ -369,7 +367,7 @@ private async Task BuildWalkAsync(
private KnowledgeObjectListToolResult Render(Listing listing, IServiceProvider services, ILogger logger)
{
var invocationContext = AIInvocationScope.Current;
- var nextLabel = NextFigureLabelIndex(invocationContext);
+ var nextLabel = FigureReferenceMarker.NextIndex(invocationContext);
var entries = new List(listing.Objects.Count);
var unshowable = new List();
@@ -389,7 +387,7 @@ private KnowledgeObjectListToolResult Render(Listing listing, IServiceProvider s
// Registered under the very marker printed below, so what the model is shown and what the
// host looks up cannot drift apart. This is the mechanism a [doc:n] citation already
// uses, with the client substituting a picture for the marker.
- label = $"{FigureLabelPrefix}{nextLabel}]";
+ label = FigureReferenceMarker.Format(nextLabel);
invocationContext.ToolReferences[label] = new AICompletionReference
{
@@ -573,44 +571,6 @@ private static void AppendKindDetail(StringBuilder builder, KnowledgeObject entr
}
}
- ///
- /// Works out where this listing's figure markers should start counting from.
- ///
- /// The active invocation, or when there is none.
- /// The first free marker number.
- ///
- /// Markers are handed out from one run of numbers across everything registered in an invocation.
- /// Restarting at one would collide with a figures block that retrieval already registered, and the
- /// collision is silent - the first registration wins - so the picture shown under a marker would be one
- /// this listing never named.
- ///
- private static int NextFigureLabelIndex(AIInvocationContext context)
- {
- if (context is null)
- {
- return 1;
- }
-
- var highest = 0;
-
- foreach (var key in context.ToolReferences.Keys)
- {
- if (key.Length <= FigureLabelPrefix.Length + 1 ||
- !key.StartsWith(FigureLabelPrefix, StringComparison.OrdinalIgnoreCase) ||
- key[^1] != ']')
- {
- continue;
- }
-
- if (int.TryParse(key.AsSpan(FigureLabelPrefix.Length, key.Length - FigureLabelPrefix.Length - 1), out var index) && index > highest)
- {
- highest = index;
- }
- }
-
- return highest + 1;
- }
-
///
/// Resolves the address a person can open for a figure, through the same link resolver citations use.
///
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ExportTabularDataTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ExportTabularDataTool.cs
index f73d9e07..c39b7a28 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ExportTabularDataTool.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ExportTabularDataTool.cs
@@ -1,4 +1,4 @@
-using System.Globalization;
+using System.Globalization;
using System.Text;
using System.Text.Json;
using CrestApps.Core.AI.Documents.Generation;
@@ -487,14 +487,13 @@ private static TabularTableInfo ResolveFormattingTable(IReadOnlyList
- /// Loads the recorded formatting and aligns its column references with the headers this export
- /// actually produced.
- ///
- /// A full export writes the original source headers while a query writes SQL column names, so a
- /// specification recorded against one naming would silently format nothing against the other.
- /// Translating the names keeps a formatting request working no matter how the file is exported.
- ///
+ /// Loads the recorded formatting for this export.
///
+ ///
+ /// The resolution lives in because the preview draws a
+ /// picture of this same formatting. Resolving it in two places is how a preview ends up showing a
+ /// different header colour, or a raw number where the file shows a currency amount.
+ ///
/// The stored specification.
/// The header row this export produced.
/// The table the formatting was recorded against.
@@ -504,167 +503,7 @@ private static SpreadsheetFormatting ResolveFormatting(
List header,
TabularTableInfo table)
{
- var formatting = SpreadsheetFormattingJson.Deserialize(specJson);
-
- if (table is null || header is null || header.Count == 0)
- {
- return formatting;
- }
-
- // The source file's own number formats are applied as defaults even when nothing was requested,
- // so a column that was currency in the upload comes back as currency.
- formatting = ApplySourceFormats(formatting, header, table);
-
- if (formatting is null)
- {
- return null;
- }
-
- var aliases = BuildColumnAliases(header, table);
-
- if (aliases.Count == 0)
- {
- return formatting;
- }
-
- foreach (var column in formatting.Columns)
- {
- column.Column = Translate(column.Column, aliases);
- }
-
- foreach (var conditional in formatting.ConditionalFormats)
- {
- conditional.Column = Translate(conditional.Column, aliases);
- }
-
- if (formatting.TotalRow?.Columns is not null)
- {
- foreach (var total in formatting.TotalRow.Columns)
- {
- total.Column = Translate(total.Column, aliases);
- }
- }
-
- foreach (var chart in formatting.Charts)
- {
- chart.CategoryColumn = Translate(chart.CategoryColumn, aliases);
-
- for (var index = 0; index < chart.ValueColumns.Count; index++)
- {
- chart.ValueColumns[index] = Translate(chart.ValueColumns[index], aliases);
- }
- }
-
- return formatting;
- }
-
- ///
- /// Seeds each exported column with the number format it had in the source file, for columns the
- /// caller did not format explicitly.
- ///
- /// The formats are defaults, never overrides: a column the caller formatted keeps what they asked
- /// for. Only columns the export actually produced are seeded, so a query that aliases or aggregates
- /// a column does not inherit a format that no longer describes it.
- ///
- ///
- /// The recorded formatting, which may be .
- /// The header row this export produced.
- /// The source table.
- /// The formatting including the inherited defaults, or when there is nothing to apply.
- private static SpreadsheetFormatting ApplySourceFormats(
- SpreadsheetFormatting formatting,
- List header,
- TabularTableInfo table)
- {
- var inherited = new List();
-
- foreach (var name in header)
- {
- if (string.IsNullOrWhiteSpace(name) || formatting?.FindColumn(name) is not null)
- {
- continue;
- }
-
- var column = table.Columns.FirstOrDefault(candidate =>
- SpreadsheetFormatting.NameMatches(candidate.Name, name) ||
- SpreadsheetFormatting.NameMatches(candidate.SourceName, name));
-
- if (column is null || string.IsNullOrWhiteSpace(column.SourceFormat))
- {
- continue;
- }
-
- inherited.Add(new SpreadsheetColumnFormat
- {
- Column = name,
- FormatCode = column.SourceFormat,
- });
- }
-
- if (inherited.Count == 0)
- {
- return formatting;
- }
-
- formatting ??= new SpreadsheetFormatting();
-
- foreach (var column in inherited)
- {
- formatting.Columns.Add(column);
- }
-
- return formatting;
- }
-
- private static Dictionary BuildColumnAliases(
- List header,
- TabularTableInfo table)
- {
- var headerNames = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (var name in header)
- {
- if (!string.IsNullOrWhiteSpace(name))
- {
- headerNames.Add(name.Trim());
- }
- }
-
- var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- foreach (var column in table.Columns)
- {
- if (string.IsNullOrWhiteSpace(column.SourceName) ||
- string.Equals(column.SourceName, column.Name, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- // Only map toward a name the export actually wrote, so a reference that already matches is
- // never rewritten into one that does not.
- if (headerNames.Contains(column.SourceName) && !headerNames.Contains(column.Name))
- {
- aliases[column.Name] = column.SourceName;
- }
- else if (headerNames.Contains(column.Name) && !headerNames.Contains(column.SourceName))
- {
- aliases[column.SourceName] = column.Name;
- }
- }
-
- return aliases;
- }
-
- private static string Translate(string name, Dictionary aliases)
- {
- if (string.IsNullOrWhiteSpace(name))
- {
- return name;
- }
-
- return aliases.TryGetValue(name.Trim(), out var alias)
- ? alias
- : name;
+ return TabularFormattingResolver.Resolve(specJson, header, table);
}
///
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/PreviewTabularDataTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/PreviewTabularDataTool.cs
new file mode 100644
index 00000000..ce0116fc
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/PreviewTabularDataTool.cs
@@ -0,0 +1,715 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using CrestApps.Core.AI.Documents.Endpoints;
+using CrestApps.Core.AI.Documents.Generation;
+using CrestApps.Core.AI.Documents.Generation.Spreadsheets;
+using CrestApps.Core.AI.Documents.Tabular;
+using CrestApps.Core.AI.Documents.Tooling;
+using CrestApps.Core.AI.Extensions;
+using CrestApps.Core.AI.Models;
+using CrestApps.Core.AI.Orchestration;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace CrestApps.Core.AI.Documents.Tools;
+
+///
+/// Tool that shows the reader what the tabular data looks like: a picture of the first rows and columns,
+/// drawn as a spreadsheet, or a written-out table where the host cannot show a picture.
+///
+///
+/// Everything else the tabular agent does answers a question about the data. Nothing showed the data, so a
+/// reader who uploaded a workbook and asked what was in it was told about it in prose and had to take that
+/// on trust. A preview is what lets them check for themselves that the right file was read, that the header
+/// row was found where they expect it, and that the columns mean what the answer says they mean.
+///
+/// The preview is of the workspace as it currently stands, not of the uploaded file, so a column added or a
+/// value corrected earlier in the conversation is visible in it. That is the more useful of the two and the
+/// only one consistent with every other tabular tool, and the caption says which it is.
+///
+///
+public sealed class PreviewTabularDataTool : AIFunction
+{
+ public const string TheName = TabularToolNames.PreviewTabularData;
+
+ private const string ImageFormat = "image";
+ private const string TableFormat = "table";
+ private const string PreviewExtension = ".svg";
+ private const string InvocationResultCacheKey = nameof(PreviewTabularDataTool) + ".Results";
+
+ private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize(
+ """
+ {
+ "type": "object",
+ "properties": {
+ "table_name": {
+ "type": "string",
+ "description": "Optional name of one loaded table to preview, exactly as list_tabular_data reports it. Omit to preview every loaded table, which is what a reader asking to see their uploaded file wants."
+ },
+ "sql": {
+ "type": "string",
+ "description": "Optional single read-only SQL query (SELECT or WITH ... SELECT) in SQLite dialect to preview instead of a loaded table. Use this to show a joined, filtered, or reshaped result - in particular to show what an export will contain before creating the file. Ignored when 'table_name' is also supplied."
+ },
+ "format": {
+ "type": "string",
+ "description": "Optional. 'image' (the default) draws the data as a picture of a spreadsheet. 'table' writes it out as a Markdown table instead; use it only when the reader explicitly asks for text.",
+ "enum": ["image", "table"]
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ """);
+
+ ///
+ /// Gets the name.
+ ///
+ public override string Name => TheName;
+
+ ///
+ /// Gets the description.
+ ///
+ public override string Description => "Shows the user what the tabular data looks like, as a picture of a spreadsheet with its header row, lettered columns and numbered rows. Call this whenever the user asks to see, view, preview, or 'show me' an uploaded or exported table, and once after loading a file so they can confirm the right data was read. The preview is truncated to the first rows and columns so it stays readable, and says what it left out. It reflects the current in-memory data, including every change applied with execute_tabular_command. Returns one [fig:N] marker per previewed table that MUST be included exactly as-is in your response so the picture is drawn; never write the file name in brackets or invent your own link. This is not a download: use export_tabular_data when the user wants the file itself.";
+
+ ///
+ /// Gets the json Schema.
+ ///
+ public override JsonElement JsonSchema => _jsonSchema;
+
+ ///
+ /// Gets the additional Properties.
+ ///
+ public override IReadOnlyDictionary AdditionalProperties { get; } =
+ new Dictionary()
+ {
+ ["Strict"] = false,
+ };
+
+ ///
+ /// Invokes core.
+ ///
+ /// The arguments.
+ /// The cancellation token.
+ protected override async ValueTask