From 992f40e111d9b6e5918f7c438aa62a16ed05f6f6 Mon Sep 17 00:00:00 2001 From: wipke Date: Mon, 21 Sep 2026 16:26:50 -0400 Subject: [PATCH 1/2] Show a tabular file as a picture, drawn the way the export writes it A reader handed a spreadsheet had no way to see it without downloading it first. Each worksheet is now drawn as a figure in the answer: a header band, a lettered column strip, a numbered row gutter, and a caption saying plainly what was left out of the window. The picture is drawn from the same formatting the workbook is written with. Resolving that in two places is how a preview ends up showing a different header colour, or a raw 74612.15999999999 where the file shows $74,612.16, so the resolution both tools need now lives in TabularFormattingResolver and the export delegates to it. Column formats inherited from the uploaded file are applied to the drawing too, which is what makes a column that was currency in the upload read as currency in the preview. The value a workbook stores is a number and a format code, and the presenting is left to whatever opens it, so the codes are read here. Currency, accounting, plain number, percent and date are covered; a code outside that vocabulary falls back to the general presentation rather than being guessed at. That general presentation now rounds to the significant digits a spreadsheet shows instead of printing a double to full binary precision. Two faults found while building it: The figures were addressed before the rows that back them were committed, so every picture arrived at an address that did not resolve yet. The document is now committed before its address is handed out. The model would not write the markers that place the pictures, through three rounds of asking. The host now appends a marker for any servable image the answer did not name, which is the rule this codebase already applies to a generated file: it is surfaced even when the model does not cite it. A picture is bounded to a fraction of its drawn size so a stack of sheets does not push the conversation off screen, which leaves a spreadsheet grid at about half size. Clicking one enlarges it. The download sits over the picture's corner rather than on a line of its own, and saves a PNG converted from the picture the page has already loaded, so nothing is fetched twice and nothing is converted for a picture nobody saves. --- CrestApps.Core.slnx | 12 +- .../docs/changelog/2.0.0.md | 44 ++ .../docs/core/ai-documents.md | 25 + .../Hubs/ChatInteractionHubBase.cs | 92 +++ .../Endpoints/DownloadAIDocument.cs | 41 +- .../DefaultGeneratedDocumentService.cs | 22 +- .../Generation/GeneratedDocumentRequest.cs | 11 + ...edFileWriterServiceCollectionExtensions.cs | 39 +- .../ServiceCollectionExtensions.cs | 14 + .../Services/TabularDataAgentProvider.cs | 1 + .../Tabular/TabularFormattingResolver.cs | 196 +++++ .../Tabular/TabularPreviewGrid.cs | 279 +++++++ .../Tabular/TabularPreviewOptions.cs | 39 + .../Tabular/TabularPreviewSvgRenderer.cs | 723 ++++++++++++++++++ .../Tabular/TabularPreviewTableRenderer.cs | 83 ++ .../Tabular/TabularPreviewValueFormat.cs | 487 ++++++++++++ .../Tabular/TabularToolNames.cs | 6 + .../Templates/Prompts/tabular-data-agent.md | 22 +- .../Tooling/FigureReferenceMarker.cs | 66 ++ .../KnowledgeObjectListToolFunction.cs | 44 +- .../Tools/ExportTabularDataTool.cs | 177 +---- .../Tools/PreviewTabularDataTool.cs | 715 +++++++++++++++++ .../MediaTypeHelper.cs | 1 + .../Templates/Prompts/agent-availability.md | 1 + .../Views/ChatInteraction/Chat.cshtml | 212 ++++- .../Tabular/TabularDataAgentProviderTests.cs | 1 + 26 files changed, 3115 insertions(+), 238 deletions(-) create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularFormattingResolver.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewGrid.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewOptions.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewSvgRenderer.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewTableRenderer.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewValueFormat.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tooling/FigureReferenceMarker.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Documents/Tools/PreviewTabularDataTool.cs 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 rows, + long totalRowCount, + IReadOnlyList declaredTypes, + TabularPreviewOptions options, + SpreadsheetFormatting formatting = null) + { + ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(options); + + var columnCount = Math.Min(headers.Count, Math.Max(1, options.MaxColumns)); + var rowCount = Math.Min(rows.Count, Math.Max(1, options.MaxRows)); + var maxCellCharacters = Math.Max(4, options.MaxCellCharacters); + + // Looked up once per column rather than once per cell: a column's format does not vary down the + // column, and a preview of the row cap is thousands of lookups. + var columnFormats = new SpreadsheetColumnFormat[columnCount]; + + if (formatting is not null) + { + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + var header = columnIndex < headers.Count ? headers[columnIndex] : null; + + columnFormats[columnIndex] = string.IsNullOrWhiteSpace(header) + ? null + : formatting.FindColumn(header); + } + } + + var cells = new List(rowCount); + + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) + { + var source = rows[rowIndex]; + var row = new string[columnCount]; + + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + var value = source is not null && columnIndex < source.Length + ? TabularPreviewValueFormat.Format(source[columnIndex], columnFormats[columnIndex]) + : string.Empty; + + row[columnIndex] = Clip(value, maxCellCharacters); + } + + cells.Add(row); + } + + var columns = new List(columnCount); + + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + var declaredType = declaredTypes is not null && columnIndex < declaredTypes.Count + ? declaredTypes[columnIndex] + : null; + + columns.Add(new TabularPreviewColumn( + Clip(headers[columnIndex] ?? string.Empty, maxCellCharacters), + IsNumericColumn(declaredType, cells, columnIndex))); + } + + return new TabularPreviewGrid( + title, + subtitle, + columns, + cells, + Math.Max(totalRowCount, cells.Count), + headers.Count, + formatting); + } + + /// + /// Describes what the reader is looking at and, plainly, what was left out of it. + /// + /// + /// How many columns were actually shown. This can be fewer than holds, because a + /// renderer working to a width drops the columns that do not fit. + /// + /// The caption printed under the grid. + public string BuildCaption(int shownColumnCount) + { + using var builder = ZString.CreateStringBuilder(); + + builder.Append("Showing "); + builder.Append(Rows.Count.ToString("N0", CultureInfo.InvariantCulture)); + + if (TotalRowCount > Rows.Count) + { + builder.Append(" of "); + builder.Append(TotalRowCount.ToString("N0", CultureInfo.InvariantCulture)); + } + + builder.Append(TotalRowCount == 1 ? " row" : " rows"); + builder.Append(" and "); + builder.Append(shownColumnCount.ToString("N0", CultureInfo.InvariantCulture)); + + if (TotalColumnCount > shownColumnCount) + { + builder.Append(" of "); + builder.Append(TotalColumnCount.ToString("N0", CultureInfo.InvariantCulture)); + } + + builder.Append(TotalColumnCount == 1 ? " column" : " columns"); + + return builder.ToString(); + } + + /// + /// Clips a value to a length a grid cell can hold, marking that it was clipped. + /// + /// The value. + /// The budget. + /// The value, or as much of it as fits followed by an ellipsis. + private static string Clip(string value, int maxCharacters) + { + // A newline or a tab is one character that takes a whole line or a whole column once it is drawn, + // so they are folded to spaces before the budget is applied rather than after it. + var flattened = value.AsSpan().IndexOfAny('\r', '\n', '\t') >= 0 + ? value.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ') + : value; + + return flattened.Length <= maxCharacters + ? flattened + : string.Concat(flattened.AsSpan(0, maxCharacters - 1).TrimEnd(), "…"); + } + + /// + /// Decides whether a column reads as numbers, which is what makes it right-aligned the way a + /// spreadsheet shows it. + /// + /// The storage type the workspace declared, when it is known. + /// The shaped rows. + /// The column. + /// when the column should be right-aligned. + private static bool IsNumericColumn(string declaredType, List rows, int columnIndex) + { + if (declaredType is "INTEGER" or "REAL") + { + return true; + } + + if (declaredType is "TEXT") + { + return false; + } + + // Nothing was declared, which is the case for a query result. Every value present has to parse as a + // number, so a single label anywhere in the column keeps the whole column left-aligned. + var sawValue = false; + + foreach (var row in rows) + { + var value = row[columnIndex]; + + if (string.IsNullOrEmpty(value)) + { + continue; + } + + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + { + return false; + } + + sawValue = true; + } + + return sawValue; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewOptions.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewOptions.cs new file mode 100644 index 00000000..9c1aac5d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewOptions.cs @@ -0,0 +1,39 @@ +namespace CrestApps.Core.AI.Documents.Tabular; + +/// +/// Options that bound a tabular preview to something a reader can take in at a glance. +/// +/// +/// A preview is a look at the data, not a copy of it. Every limit here exists because the alternative is a +/// picture so large that the chat surface scales it down to an unreadable smear, so the caps are applied +/// even when the caller asks for more and the preview says what it left out. +/// +public sealed class TabularPreviewOptions +{ + /// + /// Gets or sets the maximum number of data rows shown in a preview. Default is 50. + /// + public int MaxRows { get; set; } = 50; + + /// + /// Gets or sets the maximum number of columns shown in a preview. Default is 15. + /// + public int MaxColumns { get; set; } = 15; + + /// + /// Gets or sets the maximum number of characters shown for a single cell before the value is + /// clipped with an ellipsis. Default is 32. + /// + public int MaxCellCharacters { get; set; } = 32; + + /// + /// Gets or sets the maximum number of tables previewed when the caller names none. Default is 4. + /// + public int MaxTables { get; set; } = 4; + + /// + /// Gets or sets the widest the drawn grid may be, in pixels. Columns that do not fit are dropped and + /// reported rather than drawn off the edge. Default is 1100. + /// + public int MaxImageWidth { get; set; } = 1100; +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewSvgRenderer.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewSvgRenderer.cs new file mode 100644 index 00000000..edb6960f --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewSvgRenderer.cs @@ -0,0 +1,723 @@ +using System.Globalization; +using System.Text; + +namespace CrestApps.Core.AI.Documents.Tabular; + +/// +/// Draws a as an SVG picture of a spreadsheet. +/// +/// +/// SVG rather than a raster image because nothing in this process can rasterize one. A vector picture needs +/// no drawing library and no fonts shipped with the host: the browser that shows it already has both, so the +/// preview is a few kilobytes of markup that stays sharp at whatever size the chat surface gives it. +/// +/// It is drawn to look like the sheet the reader uploaded — the lettered column band, the numbered rows, the +/// filled header, numbers on the right — because the point of a preview is recognition. A reader who cannot +/// tell at a glance that this is their file has not been shown anything. +/// +/// +internal static class TabularPreviewSvgRenderer +{ + private const double Padding = 16; + private const double TitleFontSize = 14; + private const double SubtitleFontSize = 11; + private const double CaptionFontSize = 11; + private const double CellFontSize = 11.5; + private const double BandFontSize = 10; + private const double RowHeight = 22; + private const double BandHeight = 18; + private const double CellPadding = 8; + private const double MinColumnWidth = 52; + private const double MaxColumnWidth = 230; + private const double MinGutterWidth = 34; + + private const string FontFamily = "Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif"; + private const string PageFill = "#ffffff"; + private const string BandFill = "#eef1f5"; + private const string BandText = "#5b6673"; + // The writer's own defaults, so a table nobody formatted is still drawn the way it will be written. + // These are the values SpreadsheetGeneratedFileWriter applies when no header style is recorded. + private const string DefaultHeaderFill = "#1F4E79"; + private const string DefaultHeaderText = "#FFFFFF"; + private const string DefaultBandFill = "#F2F2F2"; + private const string GridLine = "#d5dbe3"; + private const string OuterLine = "#b9c2cd"; + private const string BodyText = "#1f2933"; + + /// + /// Takes a recorded colour, or the default when none was recorded. + /// + /// + /// A recorded colour is written the way a workbook writes one, which may carry a leading alpha pair + /// (FF1F4E79) and may omit the hash. Both are normalised to what SVG accepts; anything that is + /// not a colour at all is ignored rather than drawn as a broken fill. + /// + private static string ResolveColor(string recorded, string fallback) + { + if (string.IsNullOrWhiteSpace(recorded)) + { + return fallback; + } + + var value = recorded.Trim().TrimStart('#'); + + // A workbook writes ARGB; SVG wants RGB, and the alpha a spreadsheet fill carries is always opaque. + if (value.Length == 8) + { + value = value[2..]; + } + + if (value.Length is not (3 or 6)) + { + return fallback; + } + + foreach (var character in value) + { + if (!Uri.IsHexDigit(character)) + { + return fallback; + } + } + + return "#" + value; + } + private const string MutedText = "#57606a"; + + /// + /// Draws the grid. + /// + /// The shaped grid. + /// The preview limits, which bound how wide the picture may get. + /// The SVG markup and the number of columns it was able to draw. + public static (string Markup, int ShownColumnCount) Render(TabularPreviewGrid grid, TabularPreviewOptions options) + { + ArgumentNullException.ThrowIfNull(grid); + ArgumentNullException.ThrowIfNull(options); + + var widths = MeasureColumns(grid); + var gutterWidth = MeasureGutter(grid); + var shownColumnCount = FitColumns(widths, gutterWidth, options.MaxImageWidth); + var gridWidth = gutterWidth; + + for (var index = 0; index < shownColumnCount; index++) + { + gridWidth += widths[index]; + } + + // A narrow table must not be given a heading wider than its own picture. The file name a subtitle + // carries is routinely longer than two columns of data, and text drawn past the edge of the canvas + // is cut off at whatever character the viewport lands on. So the canvas grows to fit the heading, + // and a heading too long even for the widest allowed canvas is truncated to end deliberately. + var caption = grid.BuildCaption(shownColumnCount); + var contentWidth = Math.Min( + Math.Max(gridWidth, Math.Max( + MeasureText(grid.Title, TitleFontSize, bold: true), + Math.Max( + MeasureText(grid.Subtitle, SubtitleFontSize, bold: false), + MeasureText(caption, CaptionFontSize, bold: false)))), + Math.Max(gridWidth, options.MaxImageWidth - (Padding * 2))); + + var title = Fit(grid.Title, TitleFontSize, bold: true, contentWidth); + var subtitle = Fit(grid.Subtitle, SubtitleFontSize, bold: false, contentWidth); + caption = Fit(caption, CaptionFontSize, bold: false, contentWidth); + + var titleHeight = string.IsNullOrWhiteSpace(title) ? 0 : TitleFontSize + 8; + var subtitleHeight = string.IsNullOrWhiteSpace(subtitle) ? 0 : SubtitleFontSize + 6; + + // The header takes a row of its own, and an empty result still gets one so the reader is shown an + // empty sheet rather than a header floating over nothing. + var bodyRowCount = Math.Max(grid.Rows.Count, 1); + var gridHeight = BandHeight + ((bodyRowCount + 1) * RowHeight); + var gridTop = Padding + titleHeight + subtitleHeight; + var width = Math.Ceiling(contentWidth + (Padding * 2)); + var height = Math.Ceiling(gridTop + gridHeight + CaptionFontSize + 14 + Padding); + + var builder = new StringBuilder(4096); + + builder + .Append(""); + + Rect(builder, 0, 0, width, height, PageFill, null); + + AppendClipPaths(builder, widths, shownColumnCount, Padding + gutterWidth, height); + AppendHeadings(builder, title, subtitle); + AppendColumnBand(builder, widths, shownColumnCount, gutterWidth, gridTop, gridWidth); + AppendRows(builder, grid, widths, shownColumnCount, gutterWidth, gridTop, gridWidth, bodyRowCount); + AppendGridLines(builder, widths, shownColumnCount, gutterWidth, gridTop, gridWidth, gridHeight, bodyRowCount); + + Text( + builder, + Padding, + gridTop + gridHeight + CaptionFontSize + 6, + caption, + CaptionFontSize, + MutedText, + bold: false, + anchor: null, + clipId: -1); + + builder.Append(""); + + return (builder.ToString(), shownColumnCount); + } + + /// + /// Sizes every column to its widest value, within bounds that keep one long cell from crowding out the + /// rest of the sheet. + /// + /// The grid. + /// The column widths, in order. + private static double[] MeasureColumns(TabularPreviewGrid grid) + { + var widths = new double[grid.Columns.Count]; + + for (var index = 0; index < grid.Columns.Count; index++) + { + widths[index] = MeasureText(grid.Columns[index].Header, CellFontSize, bold: true); + } + + foreach (var row in grid.Rows) + { + for (var index = 0; index < widths.Length && index < row.Length; index++) + { + var measured = MeasureText(row[index], CellFontSize, bold: false); + + if (measured > widths[index]) + { + widths[index] = measured; + } + } + } + + for (var index = 0; index < widths.Length; index++) + { + widths[index] = Math.Clamp(Math.Ceiling(widths[index] + (CellPadding * 2)), MinColumnWidth, MaxColumnWidth); + } + + return widths; + } + + private static double MeasureGutter(TabularPreviewGrid grid) + { + var lastRowNumber = (grid.Rows.Count + 1).ToString(CultureInfo.InvariantCulture); + + return Math.Max(MinGutterWidth, Math.Ceiling(MeasureText(lastRowNumber, BandFontSize, bold: false) + 16)); + } + + /// + /// Returns how many columns fit inside the allowed width. + /// + /// + /// A picture wider than this is scaled down by the chat surface to fit its column, and past a certain + /// width that scaling makes every cell unreadable — so the columns that do not fit are left out and + /// counted in the caption instead of being drawn too small to read. + /// + /// The measured column widths. + /// The width of the row-number gutter. + /// The widest the picture may be. + /// The number of leading columns to draw; always at least one. + private static int FitColumns(double[] widths, double gutterWidth, int maxImageWidth) + { + var budget = Math.Max(MinColumnWidth + gutterWidth, maxImageWidth - (Padding * 2)); + var used = gutterWidth; + + for (var index = 0; index < widths.Length; index++) + { + used += widths[index]; + + if (used > budget) + { + return Math.Max(1, index); + } + } + + return Math.Max(1, widths.Length); + } + + /// + /// Declares one clip region per column, so a value the width estimate got wrong is cut off at its own + /// column edge rather than printed across the next one. + /// + private static void AppendClipPaths(StringBuilder builder, double[] widths, int shownColumnCount, double firstColumnX, double height) + { + builder.Append(""); + + var x = firstColumnX; + + for (var index = 0; index < shownColumnCount; index++) + { + builder + .Append(""); + + x += widths[index]; + } + + builder.Append(""); + } + + private static void AppendHeadings(StringBuilder builder, string title, string subtitle) + { + var y = Padding; + + if (!string.IsNullOrWhiteSpace(title)) + { + Text(builder, Padding, y + TitleFontSize, title, TitleFontSize, BodyText, bold: true, anchor: null, clipId: -1); + y += TitleFontSize + 8; + } + + if (!string.IsNullOrWhiteSpace(subtitle)) + { + Text(builder, Padding, y + SubtitleFontSize - 2, subtitle, SubtitleFontSize, MutedText, bold: false, anchor: null, clipId: -1); + } + } + + /// + /// Shortens a heading until it fits the width available to it. + /// + /// The heading. + /// The font size it is drawn at. + /// Whether it is drawn bold. + /// The width it has to fit into. + /// The heading, or as much of it as fits followed by an ellipsis. + private static string Fit(string text, double fontSize, bool bold, double maxWidth) + { + if (string.IsNullOrEmpty(text) || MeasureText(text, fontSize, bold) <= maxWidth) + { + return text; + } + + var length = text.Length; + + while (length > 1 && MeasureText(string.Concat(text.AsSpan(0, length), "…"), fontSize, bold) > maxWidth) + { + length--; + } + + return string.Concat(text.AsSpan(0, length).TrimEnd(), "…"); + } + + /// + /// Draws the lettered band across the top — one of the two things that make a grid read as a + /// spreadsheet rather than as a table. + /// + private static void AppendColumnBand( + StringBuilder builder, + double[] widths, + int shownColumnCount, + double gutterWidth, + double gridTop, + double gridWidth) + { + Rect(builder, Padding, gridTop, gridWidth, BandHeight, BandFill, null); + + var x = Padding + gutterWidth; + + for (var index = 0; index < shownColumnCount; index++) + { + Text( + builder, + x + (widths[index] / 2), + gridTop + BandHeight - 5, + ToColumnLetter(index), + BandFontSize, + BandText, + bold: false, + anchor: "middle", + clipId: -1); + + x += widths[index]; + } + } + + private static void AppendRows( + StringBuilder builder, + TabularPreviewGrid grid, + double[] widths, + int shownColumnCount, + double gutterWidth, + double gridTop, + double gridWidth, + int bodyRowCount) + { + var headerTop = gridTop + BandHeight; + + // The gutter is painted for the whole grid in one go, so the row numbers sit on an unbroken band the + // way they do in a spreadsheet instead of on a stripe that changes colour with the rows. + // The header's colours and the row banding are taken from the formatting the workbook is written + // with, so the picture is of the file the reader downloads rather than of a palette only the + // preview knows about. Where nothing is recorded these fall back to the writer's own defaults. + var headerFill = ResolveColor(grid.Formatting?.HeaderStyle?.BackgroundColor, DefaultHeaderFill); + var headerText = ResolveColor(grid.Formatting?.HeaderStyle?.FontColor, DefaultHeaderText); + var banded = grid.Formatting?.BandedRows == true; + var bandFill = ResolveColor(grid.Formatting?.BandColor, DefaultBandFill); + + Rect(builder, Padding, headerTop, gutterWidth, (bodyRowCount + 1) * RowHeight, BandFill, null); + Rect(builder, Padding + gutterWidth, headerTop, gridWidth - gutterWidth, RowHeight, headerFill, null); + + AppendRowNumber(builder, 1, gutterWidth, headerTop); + + var x = Padding + gutterWidth; + + for (var index = 0; index < shownColumnCount; index++) + { + AppendCell(builder, grid.Columns[index].Header, x, widths[index], headerTop, headerText, bold: true, rightAlign: false, clipId: index); + x += widths[index]; + } + + for (var rowIndex = 0; rowIndex < grid.Rows.Count; rowIndex++) + { + var top = headerTop + ((rowIndex + 1) * RowHeight); + var row = grid.Rows[rowIndex]; + + if (banded && rowIndex % 2 == 1) + { + Rect(builder, Padding + gutterWidth, top, gridWidth - gutterWidth, RowHeight, bandFill, null); + } + + AppendRowNumber(builder, rowIndex + 2, gutterWidth, top); + + x = Padding + gutterWidth; + + for (var index = 0; index < shownColumnCount; index++) + { + var value = index < row.Length ? row[index] : string.Empty; + + AppendCell(builder, value, x, widths[index], top, BodyText, bold: false, rightAlign: grid.Columns[index].IsNumeric, clipId: index); + x += widths[index]; + } + } + + if (grid.Rows.Count == 0) + { + Text( + builder, + Padding + gutterWidth + CellPadding, + headerTop + RowHeight + (RowHeight / 2) + 4, + "This table has no rows.", + CellFontSize, + MutedText, + bold: false, + anchor: null, + clipId: -1); + } + } + + private static void AppendRowNumber(StringBuilder builder, int number, double gutterWidth, double top) + { + Text( + builder, + Padding + gutterWidth - 6, + top + (RowHeight / 2) + 3.5, + number.ToString(CultureInfo.InvariantCulture), + BandFontSize, + BandText, + bold: false, + anchor: "end", + clipId: -1); + } + + private static void AppendCell( + StringBuilder builder, + string value, + double x, + double columnWidth, + double top, + string fill, + bool bold, + bool rightAlign, + int clipId) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + Text( + builder, + rightAlign ? x + columnWidth - CellPadding : x + CellPadding, + top + (RowHeight / 2) + 4, + value, + CellFontSize, + fill, + bold, + rightAlign ? "end" : null, + clipId); + } + + private static void AppendGridLines( + StringBuilder builder, + double[] widths, + int shownColumnCount, + double gutterWidth, + double gridTop, + double gridWidth, + double gridHeight, + int bodyRowCount) + { + builder + .Append(""); + + var bodyTop = gridTop + BandHeight + RowHeight; + + for (var rowIndex = 0; rowIndex <= bodyRowCount; rowIndex++) + { + var y = bodyTop + (rowIndex * RowHeight); + + Line(builder, Padding, y, Padding + gridWidth, y, null); + } + + var x = Padding + gutterWidth; + + Line(builder, x, gridTop, x, gridTop + gridHeight, null); + + for (var index = 0; index < shownColumnCount; index++) + { + x += widths[index]; + + Line(builder, x, gridTop, x, gridTop + gridHeight, null); + } + + builder.Append(""); + + Rect(builder, Padding, gridTop, gridWidth, gridHeight, "none", OuterLine); + Line(builder, Padding, gridTop + BandHeight, Padding + gridWidth, gridTop + BandHeight, OuterLine); + } + + private static void Rect(StringBuilder builder, double x, double y, double width, double height, string fill, string stroke) + { + builder + .Append(""); + } + + private static void Line(StringBuilder builder, double x1, double y1, double x2, double y2, string stroke) + { + builder + .Append(""); + } + + private static void Text( + StringBuilder builder, + double x, + double y, + string value, + double fontSize, + string fill, + bool bold, + string anchor, + int clipId) + { + builder + .Append("= 0) + { + builder + .Append("\" clip-path=\"url(#c") + .Append(clipId.ToString(CultureInfo.InvariantCulture)) + .Append(')'); + } + + builder.Append("\">"); + AppendEscaped(builder, value); + builder.Append(""); + } + + /// + /// Writes a number the way SVG reads it: invariant, and without a trailing run of zeros on every + /// coordinate in the file. + /// + private static string Number(double value) + { + return Math.Round(value, 2).ToString("0.##", CultureInfo.InvariantCulture); + } + + /// + /// Escapes the characters that would otherwise close an element or an attribute early. + /// + /// + /// Cell values are other people's data. A value containing </text> written straight into + /// the markup would not merely break the picture: it is served from this host's own origin, so it has + /// to be markup this host wrote all of. + /// + private static void AppendEscaped(StringBuilder builder, string value) + { + foreach (var character in value) + { + switch (character) + { + case '&': + builder.Append("&"); + break; + case '<': + builder.Append("<"); + break; + case '>': + builder.Append(">"); + break; + case '"': + builder.Append("""); + break; + case '\'': + builder.Append("'"); + break; + default: + // A control character is not legal XML content and would make the whole document + // unparseable, so it is dropped rather than carried into the file. + if (character >= ' ') + { + builder.Append(character); + } + + break; + } + } + } + + /// + /// Returns the spreadsheet letter for a column position: A, B, … Z, AA, AB, and so on. + /// + /// The zero-based column position. + /// The column letter. + private static string ToColumnLetter(int index) + { + Span letters = stackalloc char[8]; + var position = letters.Length; + var remaining = index; + + do + { + letters[--position] = (char)('A' + (remaining % 26)); + remaining = (remaining / 26) - 1; + } + while (remaining >= 0 && position > 0); + + return new string(letters[position..]); + } + + /// + /// Estimates how wide a string will be drawn. + /// + /// + /// There is no font to measure against here, so the widths are per-character approximations for a + /// typical UI sans-serif. They only decide column widths, and every cell is clipped to its column + /// anyway, so an estimate that runs a little wide costs some whitespace and an estimate that runs a + /// little narrow costs the last character or two — neither can spill one column into the next. + /// + /// The text. + /// The font size it is drawn at. + /// Whether it is drawn bold. + /// The estimated width in pixels. + private static double MeasureText(string text, double fontSize, bool bold) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var units = 0d; + + foreach (var character in text) + { + units += CharacterWidth(character); + } + + return units * fontSize * (bold ? 1.06 : 1); + } + + private static double CharacterWidth(char character) + { + if (char.IsAsciiDigit(character)) + { + return 0.56; + } + + return character switch + { + ' ' => 0.27, + 'i' or 'j' or 'l' or 'I' or '.' or ',' or ':' or ';' or '\'' or '|' or '!' or '`' => 0.28, + 'f' or 't' or 'r' or '(' or ')' or '[' or ']' or '{' or '}' or '/' or '\\' or '-' => 0.36, + 'm' or 'w' => 0.85, + 'M' or 'W' => 0.92, + '@' or '%' => 0.95, + >= 'A' and <= 'Z' => 0.66, + >= 'a' and <= 'z' => 0.53, + _ => 0.56, + }; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewTableRenderer.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewTableRenderer.cs new file mode 100644 index 00000000..a56dec37 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewTableRenderer.cs @@ -0,0 +1,83 @@ +using Cysharp.Text; + +namespace CrestApps.Core.AI.Documents.Tabular; + +/// +/// Writes a out as a Markdown table, for when the host cannot show a +/// picture. +/// +/// +/// Markdown rather than hand-written HTML: every chat surface renders an assistant message through the same +/// markdown parser and then wraps each <table> it produced in the site's own table styling. A +/// table written as markdown therefore arrives looking like the rest of the page, whereas raw markup has to +/// survive the sanitizer and then misses that styling. +/// +internal static class TabularPreviewTableRenderer +{ + /// + /// Renders the grid. + /// + /// The shaped grid. + /// The markdown table, with its heading and caption. + public static string Render(TabularPreviewGrid grid) + { + ArgumentNullException.ThrowIfNull(grid); + + using var builder = ZString.CreateStringBuilder(); + + if (!string.IsNullOrWhiteSpace(grid.Title)) + { + builder.Append("**"); + builder.Append(grid.Title); + builder.AppendLine("**"); + builder.AppendLine(); + } + + builder.Append("| "); + builder.Append(string.Join(" | ", grid.Columns.Select(column => Escape(column.Header)))); + builder.AppendLine(" |"); + + // The alignment row carries the same left/right split the drawn preview uses, so numbers line up on + // their last digit in either rendering. + builder.Append("| "); + builder.Append(string.Join(" | ", grid.Columns.Select(column => column.IsNumeric ? "---:" : "---"))); + builder.AppendLine(" |"); + + foreach (var row in grid.Rows) + { + builder.Append("| "); + builder.Append(string.Join(" | ", row.Select(Escape))); + builder.AppendLine(" |"); + } + + builder.AppendLine(); + builder.Append(grid.BuildCaption(grid.Columns.Count)); + + if (!string.IsNullOrWhiteSpace(grid.Subtitle)) + { + builder.Append(" — "); + builder.Append(grid.Subtitle); + } + + builder.Append('.'); + + return builder.ToString(); + } + + /// + /// Makes a value safe to sit inside a table cell. + /// + /// The value. + /// The escaped value, or a non-breaking placeholder when it is empty. + private static string Escape(string value) + { + if (string.IsNullOrEmpty(value)) + { + return " "; + } + + // A pipe ends the cell, so an unescaped one in the data silently shifts every value after it into + // the wrong column. + return value.Contains('|', StringComparison.Ordinal) ? value.Replace("|", "\\|", StringComparison.Ordinal) : value; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewValueFormat.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewValueFormat.cs new file mode 100644 index 00000000..9bbbf8d2 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularPreviewValueFormat.cs @@ -0,0 +1,487 @@ +using System.Globalization; +using System.Text; +using CrestApps.Core.AI.Documents.Generation.Spreadsheets; + +namespace CrestApps.Core.AI.Documents.Tabular; + +/// +/// Presents a cell value the way the exported workbook presents it. +/// +/// +/// A workbook stores a number and a format code and leaves the presenting to whatever opens it, so a +/// preview drawn from the stored numbers shows 74612.15999999999 where the reader's spreadsheet +/// shows $74,612.16 — the same figure, but not recognisably the same document. The format code is +/// therefore applied here, against the same specification the export writes into the file. +/// +/// This reads the format code rather than the semantic description because a column that took its format +/// from the uploaded file carries only the code. What is covered is the vocabulary those files actually +/// use — a currency or accounting amount, a plain number, a percentage, a date — and a code outside it +/// falls back to the general presentation rather than being guessed at. +/// +/// +internal static class TabularPreviewValueFormat +{ + /// + /// Presents a value using a column's recorded format. + /// + /// The stored value. + /// The column's format, or when it has none. + /// The text to draw in the cell. + public static string Format(object value, SpreadsheetColumnFormat format) + { + if (value is null or DBNull) + { + return string.Empty; + } + + if (format is null) + { + return General(value); + } + + var code = SpreadsheetNumberFormatCode.Resolve(format); + + if (string.IsNullOrWhiteSpace(code)) + { + return General(value); + } + + if (TryAsDateTime(value, out var date) && LooksLikeDate(code)) + { + return FormatDate(date, code); + } + + if (TryAsNumber(value, out var number)) + { + return FormatNumber(number, code); + } + + return General(value); + } + + /// + /// Presents a value that has no recorded format. + /// + /// The stored value. + /// The text to draw in the cell. + /// + /// A spreadsheet's general presentation never prints a number to full binary precision; it shows the + /// significant digits and leaves the rest. Printing the stored double verbatim is what puts + /// 74612.15999999999 and 184363.92000000004 in a cell the file shows as a plain amount, + /// so the general path rounds rather than round-trips. + /// + public static string General(object value) + { + return value switch + { + null or DBNull => string.Empty, + string text => text, + bool flag => flag ? "TRUE" : "FALSE", + double number => GeneralNumber(number), + float number => GeneralNumber(number), + decimal number => GeneralNumber((double)number), + long number => number.ToString(CultureInfo.InvariantCulture), + int number => number.ToString(CultureInfo.InvariantCulture), + DateTime date => date.TimeOfDay == TimeSpan.Zero + ? date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + : date.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), + byte[] bytes => $"({bytes.Length.ToString("N0", CultureInfo.InvariantCulture)} bytes)", + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, + }; + } + + private static string GeneralNumber(double number) + { + if (double.IsNaN(number) || double.IsInfinity(number)) + { + return number.ToString(CultureInfo.InvariantCulture); + } + + // Eleven significant digits is what a spreadsheet's general presentation shows, and it is also + // what turns the accumulated binary error at the tail of a sum back into the figure that was + // summed. "G11" then drops a trailing run of zeros that rounding leaves behind. + var rounded = number.ToString("G11", CultureInfo.InvariantCulture); + + return rounded.Contains('E', StringComparison.OrdinalIgnoreCase) + ? number.ToString(CultureInfo.InvariantCulture) + : rounded; + } + + private static string FormatNumber(double number, string code) + { + var section = NegativeSection(code, number); + var isPercent = section.Contains('%', StringComparison.Ordinal); + + if (isPercent) + { + number *= 100d; + } + + var decimals = DecimalPlaces(section); + var grouped = section.Contains("#,#", StringComparison.Ordinal) || section.Contains("0,0", StringComparison.Ordinal); + var magnitude = Math.Abs(number); + + var text = magnitude.ToString( + (grouped ? "#,##0" : "0") + (decimals > 0 ? "." + new string('0', decimals) : string.Empty), + CultureInfo.InvariantCulture); + + var symbol = CurrencySymbol(section); + + if (!string.IsNullOrEmpty(symbol)) + { + text = symbol + text; + } + + if (isPercent) + { + text += "%"; + } + + // An accounting code writes its negatives in brackets and says so in its own second section; any + // other code carries the sign. Zero is never signed. + if (number < 0 && magnitude > 0) + { + text = ParenthesizesNegatives(code) + ? "(" + text + ")" + : "-" + text; + } + + return text; + } + + private static string FormatDate(DateTime date, string code) + { + // The codes a spreadsheet writes use the same letters .NET does, with one collision: in a format + // code "m" after an hour marker is a minute and elsewhere a month, and a lone "d"/"m"/"y" run is + // case-insensitive. Lower-cased month/day/year runs are mapped across; the rest is left alone. + var builder = new StringBuilder(code.Length); + var seenHour = false; + + for (var index = 0; index < code.Length; index++) + { + var character = code[index]; + + switch (character) + { + case 'h': + case 'H': + seenHour = true; + builder.Append('H'); + break; + case 'y': + case 'Y': + builder.Append('y'); + break; + case 'd': + case 'D': + builder.Append('d'); + break; + case 'm': + case 'M': + builder.Append(seenHour ? 'm' : 'M'); + break; + case 's': + case 'S': + builder.Append('s'); + break; + case '\\': + if (index + 1 < code.Length) + { + builder.Append(code[++index]); + } + break; + case ';': + // Only the first section describes a date. + index = code.Length; + break; + default: + builder.Append(character); + break; + } + } + + var pattern = builder.ToString().Trim(); + + if (pattern.Length == 0) + { + return date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + + try + { + return date.ToString(pattern, CultureInfo.InvariantCulture); + } + catch (FormatException) + { + return date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + } + + /// + /// Returns the part of a format code that applies to a value of this sign. + /// + private static string NegativeSection(string code, double number) + { + var sections = SplitSections(code); + + if (number < 0 && sections.Count > 1) + { + return sections[1]; + } + + return sections.Count > 0 ? sections[0] : code; + } + + private static bool ParenthesizesNegatives(string code) + { + var sections = SplitSections(code); + + return sections.Count > 1 && sections[1].Contains('(', StringComparison.Ordinal); + } + + /// + /// Splits a format code on the semicolons that separate its positive, negative, zero and text parts, + /// ignoring the ones inside a quoted literal or escaped. + /// + private static List SplitSections(string code) + { + var sections = new List(4); + var builder = new StringBuilder(code.Length); + var quoted = false; + + for (var index = 0; index < code.Length; index++) + { + var character = code[index]; + + if (character == '"') + { + quoted = !quoted; + builder.Append(character); + continue; + } + + if (character == '\\' && index + 1 < code.Length) + { + builder.Append(character).Append(code[++index]); + continue; + } + + if (character == ';' && !quoted) + { + sections.Add(builder.ToString()); + builder.Clear(); + continue; + } + + builder.Append(character); + } + + sections.Add(builder.ToString()); + + return sections; + } + + /// + /// Counts the digit placeholders after the decimal point, ignoring quoted literals. + /// + private static int DecimalPlaces(string section) + { + var separator = IndexOfUnquoted(section, '.'); + + if (separator < 0) + { + return 0; + } + + var decimals = 0; + + for (var index = separator + 1; index < section.Length; index++) + { + var character = section[index]; + + if (character is '0' or '#' or '?') + { + decimals++; + continue; + } + + if (character == '"') + { + break; + } + } + + return decimals; + } + + /// + /// Reads the currency symbol a code prints ahead of its digits. + /// + /// + /// A symbol reaches a code two ways: quoted, as "$"#,##0.00, or escaped, as \$#,##0. + /// Only a symbol standing before the first digit placeholder is taken, so the "-" a zero + /// section prints and the _( padding of an accounting code are not mistaken for one. + /// + private static string CurrencySymbol(string section) + { + var symbol = new StringBuilder(4); + + for (var index = 0; index < section.Length; index++) + { + var character = section[index]; + + if (character is '0' or '#' or '?') + { + break; + } + + if (character == '"') + { + var close = section.IndexOf('"', index + 1); + + if (close < 0) + { + break; + } + + var literal = section[(index + 1)..close]; + + // A zero section prints a dash in place of the digits; that is not a currency symbol. + if (literal != "-") + { + symbol.Append(literal); + } + + index = close; + continue; + } + + // Only an escaped currency character counts. An accounting code escapes the bracket it wraps + // negatives in as "\(", and taking that as a symbol prints the bracket twice. + if (character == '\\' && index + 1 < section.Length) + { + var escaped = section[++index]; + + if (IsCurrency(escaped)) + { + symbol.Append(escaped); + } + + continue; + } + + if (IsCurrency(character)) + { + symbol.Append(character); + } + } + + return symbol.ToString(); + } + + private static bool IsCurrency(char character) + { + return character is '$' or '£' or '€' or '¥'; + } + + private static int IndexOfUnquoted(string value, char target) + { + var quoted = false; + + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + + if (character == '"') + { + quoted = !quoted; + continue; + } + + if (character == '\\') + { + index++; + continue; + } + + if (character == target && !quoted) + { + return index; + } + } + + return -1; + } + + private static bool LooksLikeDate(string code) + { + var section = SplitSections(code)[0]; + var quoted = false; + + foreach (var character in section) + { + if (character == '"') + { + quoted = !quoted; + continue; + } + + if (!quoted && character is 'y' or 'Y' or 'd' or 'D' or 'h' or 'H' or 's' or 'S') + { + return true; + } + } + + return false; + } + + private static bool TryAsNumber(object value, out double number) + { + switch (value) + { + case double typed: + number = typed; + return true; + case float typed: + number = typed; + return true; + case decimal typed: + number = (double)typed; + return true; + case long typed: + number = typed; + return true; + case int typed: + number = typed; + return true; + case short typed: + number = typed; + return true; + case string text when double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed): + number = parsed; + return true; + default: + number = 0; + return false; + } + } + + private static bool TryAsDateTime(object value, out DateTime date) + { + switch (value) + { + case DateTime typed: + date = typed; + return true; + case DateTimeOffset typed: + date = typed.DateTime; + return true; + case string text when DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed): + date = parsed; + return true; + default: + date = default; + return false; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularToolNames.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularToolNames.cs index 8fc7e49d..347cfe32 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularToolNames.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tabular/TabularToolNames.cs @@ -17,6 +17,12 @@ public static class TabularToolNames /// 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 InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + var logger = arguments.Services.GetRequiredService>(); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("AI tool '{ToolName}' invoked.", Name); + } + + arguments.TryGetFirstString("table_name", out var tableName); + arguments.TryGetFirstString("sql", out var sql); + arguments.TryGetFirstString("format", out var format); + + var asTable = string.Equals(format, TableFormat, StringComparison.OrdinalIgnoreCase); + + var preparation = await TabularToolRunner.PrepareAsync(arguments.Services, cancellationToken); + + if (preparation.Error is not null) + { + return preparation.Error; + } + + using var workspace = preparation.Workspace; + var options = arguments.Services.GetRequiredService>().Value; + + var cacheKey = BuildCacheKey(preparation.Context, tableName, sql, format, workspace.MutationVersion); + var cachedResponse = TryGetCachedResponse(cacheKey); + + if (!string.IsNullOrEmpty(cachedResponse)) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("AI tool '{ToolName}' returned a cached preview for this turn.", Name); + } + + return cachedResponse; + } + + List grids; + string skippedTables = null; + + try + { + if (!string.IsNullOrWhiteSpace(tableName)) + { + var table = FindTable(preparation.Tables, tableName); + + if (table is null) + { + return $"There is no loaded table named \"{tableName}\". The loaded tables are: {string.Join(", ", preparation.Tables.Select(candidate => candidate.TableName))}."; + } + + grids = [await BuildTableGridAsync(workspace, table, options, cancellationToken)]; + } + else if (!string.IsNullOrWhiteSpace(sql)) + { + grids = [await BuildQueryGridAsync(workspace, sql, options, cancellationToken)]; + } + else + { + var previewed = preparation.Tables.Take(Math.Max(1, options.MaxTables)).ToList(); + var built = new List(previewed.Count); + + foreach (var table in previewed) + { + built.Add(await BuildTableGridAsync(workspace, table, options, cancellationToken)); + } + + grids = built; + + if (preparation.Tables.Count > previewed.Count) + { + // Named rather than counted, so the next call can ask for one of them by name instead of + // the model guessing that there were more and what they were called. + skippedTables = string.Join( + ", ", + preparation.Tables.Skip(previewed.Count).Select(table => table.TableName)); + } + } + } + catch (TabularSqlException ex) + { + return ex.Message; + } + catch (SqliteException ex) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug(ex, "Tabular preview failed for tool '{ToolName}'.", Name); + } + + return $"The preview query could not be executed: {ex.Message}"; + } + + var response = asTable + ? BuildTableResponse(grids, skippedTables) + : await BuildImageResponseAsync(arguments.Services, preparation.Context, grids, options, skippedTables, logger, cancellationToken); + + CacheResponse(cacheKey, response); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug( + "AI tool '{ToolName}' completed. Grids={GridCount}, Format={Format}, MutationVersion={MutationVersion}.", + Name, + grids.Count, + asTable ? TableFormat : ImageFormat, + workspace.MutationVersion); + } + + return response; + } + + /// + /// Builds the window onto one loaded table, headed by the names the uploaded file used. + /// + /// The workspace. + /// The table. + /// The preview limits. + /// The cancellation token. + /// The shaped grid. + private static async Task BuildTableGridAsync( + TabularWorkspace workspace, + TabularTableInfo table, + TabularPreviewOptions options, + CancellationToken cancellationToken) + { + var maxRows = Math.Max(1, options.MaxRows); + var quoted = TabularWorkspaceSqliteHelpers.QuoteIdentifier(table.TableName); + var result = await workspace.QueryAsync( + $"SELECT * FROM {quoted} LIMIT {maxRows.ToString(CultureInfo.InvariantCulture)}", + maxRows, + cancellationToken); + + var headers = new List(result.Columns.Count); + var declaredTypes = new List(result.Columns.Count); + + foreach (var column in result.Columns) + { + var info = table.Columns.FirstOrDefault(candidate => string.Equals(candidate.Name, column, StringComparison.OrdinalIgnoreCase)); + + // The header the reader knows is the one their file printed, not the identifier the column was + // given to make it safe to write in SQL. + headers.Add(string.IsNullOrWhiteSpace(info?.SourceName) ? column : info.SourceName); + declaredTypes.Add(info?.DeclaredType); + } + + var formatting = await ResolveFormattingAsync(workspace, table, headers, cancellationToken); + + return TabularPreviewGrid.Create( + string.IsNullOrWhiteSpace(table.WorksheetName) ? table.TableName : table.WorksheetName, + BuildSubtitle(table), + headers, + result.Rows, + table.RowCount, + declaredTypes, + options, + formatting); + } + + /// + /// Loads the formatting the exported workbook would be written with for this table. + /// + /// + /// Resolved through , the same path the export takes, so the + /// picture shows the reader's own header colour and their own currency and date formats rather than a + /// look the preview invented. The precedence matches the export's: formatting recorded against the + /// table wins, and the specification recorded for the workspace as a whole stands in when there is none. + /// + /// The workspace. + /// The table being previewed. + /// The headers this preview is drawing. + /// The cancellation token. + /// The formatting, or when none applies. + private static async Task ResolveFormattingAsync( + TabularWorkspace workspace, + TabularTableInfo table, + List headers, + CancellationToken cancellationToken) + { + var stored = await workspace.GetFormattingAsync(table.TableName, cancellationToken); + + if (string.IsNullOrEmpty(stored.SpecJson)) + { + stored = await workspace.GetFormattingAsync( + TabularToolNames.WorkspaceFormattingKey, + cancellationToken); + } + + return TabularFormattingResolver.Resolve(stored.SpecJson, headers, table); + } + + /// + /// Builds the window onto a query result. + /// + /// The workspace. + /// The read-only query. + /// The preview limits. + /// The cancellation token. + /// The shaped grid. + private static async Task BuildQueryGridAsync( + TabularWorkspace workspace, + string sql, + TabularPreviewOptions options, + CancellationToken cancellationToken) + { + var maxRows = Math.Max(1, options.MaxRows); + var result = await workspace.QueryAsync(sql, maxRows, cancellationToken); + var totalRowCount = result.Truncated + ? await CountQueryRowsAsync(workspace, sql, result.Rows.Count, cancellationToken) + : result.Rows.Count; + + return TabularPreviewGrid.Create( + "Query result", + null, + result.Columns, + result.Rows, + totalRowCount, + null, + options); + } + + /// + /// Counts the rows a truncated query would have returned, so the caption can say what the reader is + /// seeing a part of. + /// + /// + /// Best effort. A query the count cannot be wrapped around still gets its preview; it just reports the + /// rows on hand rather than claiming a total it does not know. + /// + /// The workspace. + /// The read-only query. + /// The row count to report when the total cannot be established. + /// The cancellation token. + /// The total row count. + private static async Task CountQueryRowsAsync( + TabularWorkspace workspace, + string sql, + int fallback, + CancellationToken cancellationToken) + { + try + { + var counted = await workspace.QueryAsync($"SELECT COUNT(*) FROM ({sql.TrimEnd().TrimEnd(';')})", 1, cancellationToken); + + if (counted.Rows.Count > 0 && counted.Rows[0].Length > 0 && + long.TryParse(Convert.ToString(counted.Rows[0][0], CultureInfo.InvariantCulture), out var total)) + { + return total; + } + } + catch (Exception exception) when (exception is TabularSqlException or SqliteException) + { + // Nothing to report but the rows already read. + } + + return fallback; + } + + /// + /// Draws each grid, stores it as a document the host can serve, and registers it under the marker the + /// model is asked to repeat. + /// + /// + /// Falls back to the written-out table whenever the host cannot actually show a picture here — no + /// conversation to attach the file to, or no endpoint to serve it from. A marker registered without a + /// link the host can serve reaches the reader as a broken image, so the choice is made on whether the + /// picture can be delivered rather than on whether it could be drawn. + /// + /// The request services. + /// The tabular tool context. + /// The shaped grids. + /// The preview limits. + /// The tables that were not previewed, when some were left out. + /// The logger. + /// The cancellation token. + /// The tool response. + private static async Task BuildImageResponseAsync( + IServiceProvider services, + TabularToolContext context, + List grids, + TabularPreviewOptions options, + string skippedTables, + ILogger logger, + CancellationToken cancellationToken) + { + var invocationContext = AIInvocationScope.Current; + + if (invocationContext is null || + string.IsNullOrEmpty(context.ExportReferenceId) || + string.IsNullOrEmpty(context.ExportReferenceType)) + { + return BuildTableResponse(grids, skippedTables); + } + + var documentService = services.GetService(); + var writerResolver = services.GetService(); + + // Resolved rather than asked whether it is supported: the preview format is deliberately absent + // from the formats a caller may request, so IsSupported reports false for it by design. + if (documentService is null || writerResolver?.TryResolve(PreviewExtension, out _) != true) + { + return BuildTableResponse(grids, skippedTables); + } + + if (grids.Count == 0) + { + return "There was no tabular data to preview."; + } + + var figureIndex = FigureReferenceMarker.NextIndex(invocationContext); + var pending = new List<(string Marker, AICompletionReference Reference)>(grids.Count); + var summary = new StringBuilder(); + + foreach (var grid in grids) + { + var (markup, shownColumnCount) = TabularPreviewSvgRenderer.Render(grid, options); + + var result = await documentService.CreateAsync( + new GeneratedDocumentRequest( + context.ExportReferenceId, + context.ExportReferenceType, + BuildFileName(grid), + new GeneratedFileContent + { + Title = grid.Title, + Text = markup, + }) + { + // The preview is shown where its marker sits, so it must not also be listed underneath + // the answer as a file the reader asked to download. + RegisterDownloadReference = false, + }, + cancellationToken); + + var link = ResolveLink(services, result.Document.ItemId); + + if (string.IsNullOrEmpty(link)) + { + // Without an address this host serves there is no picture, only a marker the reader is left + // looking at. Nothing is registered until every grid has one, so a host that cannot serve + // them falls back whole rather than answering with some pictures and some markers. + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug( + "Tabular preview fell back to a written table: no '{RouteName}' endpoint is registered to serve the picture from.", + DownloadAIDocument.DefaultRouteName); + } + + return BuildTableResponse(grids, skippedTables); + } + + var title = string.IsNullOrWhiteSpace(grid.Subtitle) ? grid.Title : $"{grid.Title} — {grid.Subtitle}"; + var marker = FigureReferenceMarker.Format(figureIndex); + + // The figure number, not a citation number. Figures count separately from [doc:n], so this + // deliberately does not draw from NextReferenceIndex and does not consume a download's number — + // an export in the same turn still gets [doc:1]. The two numberings cannot be confused with each + // other downstream because the marker is replaced by its picture before anything reads a + // reference as a citation. + pending.Add((marker, new AICompletionReference + { + Text = title, + Title = title, + Link = link, + IsImage = true, + Index = figureIndex, + ReferenceId = result.Document.ItemId, + ReferenceType = AIReferenceTypes.DataSource.Document, + })); + + // The caption is numbered rather than carrying the marker again. A marker sitting inside a + // descriptive line reads as a list that has already been rendered, and the answer that follows + // says the images are "shown above" while writing none of them. + summary + .Append(pending.Count) + .Append(". ") + .Append(title) + .Append(": ") + .Append(grid.BuildCaption(shownColumnCount)) + .AppendLine("."); + + // Logged as the marker paired with the address it resolves through, so a picture that does not + // appear can be told apart from one that was never registered by grepping this line against the + // download endpoint's for the same document id. + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug( + "Tabular preview registered marker '{Marker}' for document '{DocumentId}' at '{Link}': '{FileName}', {Rows} of {TotalRows} row(s), {Columns} of {TotalColumns} column(s), {ByteCount} bytes of markup.", + marker, + result.Document.ItemId, + link, + result.Document.FileName, + grid.Rows.Count, + grid.TotalRowCount, + shownColumnCount, + grid.TotalColumnCount, + markup.Length); + } + + figureIndex++; + } + + foreach (var (marker, reference) in pending) + { + invocationContext.ToolReferences[marker] = reference; + } + + var response = new StringBuilder(); + + response + .AppendLine("WRITE THE FOLLOWING LINE IN YOUR ANSWER, EXACTLY AS SHOWN, ON A LINE OF ITS OWN:") + .AppendLine() + .AppendLine(string.Join(' ', pending.Select(entry => entry.Marker))) + .AppendLine() + .AppendLine("That line is a placeholder the host replaces with the actual images. The reader sees no images at all unless it appears in your answer character for character. Do NOT describe it, renumber it, turn it into a link or a list, or write that the images are \"shown above\" in place of it. Do not call preview_tabular_data again for data that has not changed.") + .AppendLine() + .AppendLine("What each image shows, in order, for your own wording only:") + .Append(summary); + + AppendSkippedTables(response, skippedTables); + + return response.ToString(); + } + + /// + /// Writes the preview out as text, for when a picture cannot be shown or was not asked for. + /// + /// The shaped grids. + /// The tables that were not previewed, when some were left out. + /// The tool response. + private static string BuildTableResponse(List grids, string skippedTables) + { + if (grids.Count == 0) + { + return "There was no tabular data to preview."; + } + + var builder = new StringBuilder(); + + builder.AppendLine("Include the table(s) below in your answer exactly as written, so the user can see the data:"); + builder.AppendLine(); + + foreach (var grid in grids) + { + builder.AppendLine(TabularPreviewTableRenderer.Render(grid)); + builder.AppendLine(); + } + + AppendSkippedTables(builder, skippedTables); + + return builder.ToString().TrimEnd(); + } + + private static void AppendSkippedTables(StringBuilder builder, string skippedTables) + { + if (string.IsNullOrEmpty(skippedTables)) + { + return; + } + + builder + .Append("Not previewed: ") + .Append(skippedTables) + .AppendLine(". Say so, and pass 'table_name' to preview one of them."); + } + + /// + /// Builds the address the host serves the preview from, when it registered the endpoint. + /// + /// The request services. + /// The generated document identifier. + /// The link, or when the host exposes none. + private static string ResolveLink(IServiceProvider services, string documentId) + { + var linkGenerator = services.GetService(); + + if (linkGenerator is null) + { + return null; + } + + var values = new RouteValueDictionary + { + ["documentId"] = documentId, + }; + + try + { + var httpContext = services.GetService()?.HttpContext; + + return httpContext is null + ? linkGenerator.GetPathByName(DownloadAIDocument.DefaultRouteName, values) + : linkGenerator.GetPathByName(httpContext, DownloadAIDocument.DefaultRouteName, values); + } + catch (Exception) + { + return null; + } + } + + /// + /// Finds the table the caller named, accepting the worksheet name and the source file name as well as + /// the SQL table name. + /// + /// The loaded tables. + /// The name the caller supplied. + /// The table, or when nothing matches. + private static TabularTableInfo FindTable(IReadOnlyList tables, string name) + { + var trimmed = name.Trim(); + + return tables.FirstOrDefault(table => string.Equals(table.TableName, trimmed, StringComparison.OrdinalIgnoreCase)) + ?? tables.FirstOrDefault(table => string.Equals(table.WorksheetName, trimmed, StringComparison.OrdinalIgnoreCase)) + ?? tables.FirstOrDefault(table => string.Equals(table.SourceFileName, trimmed, StringComparison.OrdinalIgnoreCase)); + } + + private static string BuildSubtitle(TabularTableInfo table) + { + if (string.IsNullOrWhiteSpace(table.SourceFileName)) + { + return null; + } + + return string.IsNullOrWhiteSpace(table.WorksheetName) + ? table.SourceFileName + : $"{table.SourceFileName} · worksheet \"{table.WorksheetName}\""; + } + + /// + /// Names the preview after what it is a preview of, so a reader who downloads it can tell the files + /// apart. + /// + /// The shaped grid. + /// The file name. + private static string BuildFileName(TabularPreviewGrid grid) + { + var baseName = string.IsNullOrWhiteSpace(grid.Title) ? "tabular" : grid.Title; + var invalidCharacters = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(baseName.Length); + + foreach (var character in baseName) + { + builder.Append(invalidCharacters.Contains(character) || character == ' ' ? '_' : character); + } + + baseName = builder.ToString().Trim('_'); + + if (string.IsNullOrEmpty(baseName)) + { + baseName = "tabular"; + } + + if (baseName.Length > 100) + { + baseName = baseName[..100]; + } + + return baseName + "_preview" + PreviewExtension; + } + + private static string BuildCacheKey( + TabularToolContext context, + string tableName, + string sql, + string format, + int mutationVersion) + { + return string.Join( + "|", + context.ExportReferenceType ?? string.Empty, + context.ExportReferenceId ?? string.Empty, + tableName?.Trim() ?? string.Empty, + sql?.Trim() ?? string.Empty, + format?.Trim() ?? string.Empty, + mutationVersion.ToString(CultureInfo.InvariantCulture)); + } + + /// + /// Remembers a preview for the rest of the turn, so a model that calls twice for the same data is given + /// the same marker rather than a second copy of the same picture. + /// + private static void CacheResponse(string key, string response) + { + var invocationContext = AIInvocationScope.Current; + + if (invocationContext is null || string.IsNullOrEmpty(response)) + { + return; + } + + if (!invocationContext.Items.TryGetValue(InvocationResultCacheKey, out var cacheObject) || + cacheObject is not Dictionary cache) + { + cache = new Dictionary(StringComparer.Ordinal); + invocationContext.Items[InvocationResultCacheKey] = cache; + } + + cache[key] = response; + } + + private static string TryGetCachedResponse(string key) + { + var invocationContext = AIInvocationScope.Current; + + if (invocationContext is null || + !invocationContext.Items.TryGetValue(InvocationResultCacheKey, out var cacheObject) || + cacheObject is not Dictionary cache) + { + return null; + } + + return cache.TryGetValue(key, out var response) ? response : null; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Ingestion/MediaTypeHelper.cs b/src/Primitives/CrestApps.Core.AI.Ingestion/MediaTypeHelper.cs index 5d397bd9..5ade9ea4 100644 --- a/src/Primitives/CrestApps.Core.AI.Ingestion/MediaTypeHelper.cs +++ b/src/Primitives/CrestApps.Core.AI.Ingestion/MediaTypeHelper.cs @@ -39,6 +39,7 @@ public static string InferMediaType(string extension, string fallbackContentType ".html" or ".htm" => "text/html", ".json" => "application/json", ".webp" => "image/webp", + ".svg" => "image/svg+xml", ".xml" => "application/xml", ".csv" => "text/csv", ".yaml" or ".yml" => "text/yaml", diff --git a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/agent-availability.md b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/agent-availability.md index 87ac510b..fa78a976 100644 --- a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/agent-availability.md +++ b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/agent-availability.md @@ -20,4 +20,5 @@ The following specialized agents are available as tools. Each agent has its own - For simple requests you can handle directly, respond without invoking any agents. - When invoking an agent, provide a clear, specific prompt describing what you need it to do. - You may invoke multiple agents sequentially if the task requires different specializations. +- An agent's reply may contain bracketed markers such as `[doc:1]`, `[fig:1]` or `[chart:{...}]`. These are placeholders the host replaces with a real download, image or chart. Copy every one of them into your own answer exactly as it appears, in the same order, on its own line where the agent put it. Never renumber them, never turn them into links or file names, and never replace one with a description such as "the file is ready" or "the image is shown above" — a marker you leave out or reword is content the reader never receives. {% endif %} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Chat.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Chat.cshtml index bb080a9e..6315c496 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Chat.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Chat.cshtml @@ -24,6 +24,77 @@