diff --git a/CHANGELOG.md b/CHANGELOG.md index 89d3889..0b2f30c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased - Answer presentation + +- Add `present_results`: server-formatted metrics, tables, computed percentages, + charts and validated in-app filter/record/action links. +- Persist and stream presentation parts with a multi-line GFM fallback and + record-data retention; add a static default presentation skill. +- Add the owner's 81 JPEG / 20 PNG golden example, shared live eval coverage, + permission/formatting/input tests and strict-schema budget coverage. + ## Unreleased - P-e - Add per-profile heuristic/classifier model routing, with budgeted ROUTE usage. diff --git a/README.md b/README.md index 9925821..a980696 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`. | `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. | | `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. | -The data access itself is done by the AI tool kernel in QQQ core (`com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel`). The kernel checks table permissions and exposure rules, applies record security, and caps and redacts what the model sees. Every tool result reaches the model wrapped as untrusted data. +Data tools call the QQQ UI API through `UiApiToolbox` and `UiApiGateway` with the signed-in user's session. The UI enforces permissions and record security; the assistant further narrows access and caps and redacts returned values. Data tool results reach the model wrapped as untrusted data. Only record-free navigation and help use the core AI tool kernel. ## Requirements @@ -203,6 +203,7 @@ Base path `/qqq/v1/assistant`. Every request is authenticated through the app's The stream sends these events, each with a `seq`: - `message.start`, `text.delta` - `tool.start`, `tool.result` (with `render` and `recordRefs`) +- `presentation` (`messageId`, `part`: summary, blocks and server-built GFM markdown) - `ui.navigate`, `notice` - `usage`, `message.end` - `error` @@ -487,3 +488,16 @@ NETWORK/TIMEOUT marks a provider DOWN for 60 seconds; success marks it UP. Local health is probed when unknown/stale with a two-second timeout. Provider cards show health and down-until, failures show fallback totals, and turn records show the route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema. + +### Answer presentation + +The always-available `present_results` tool turns retrieved results into metrics, +tables, charts and actionable in-app links. The server validates the user's UI API +visibility, formats field values, computes shares and builds all links. It emits +and stores a `presentation` part with a complete GFM Markdown fallback. Profile +skills can extend the built-in presentation instructions. See the +[part, stream and input contract](docs/answer-presentation.md). + +The image-breakdown golden task also runs in the existing opt-in live provider suite: +`./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am` +(with `ANTHROPIC_API_KEY` set). Live calls are excluded from normal verification. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index da6a2e9..5aa86f2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -446,3 +446,36 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad - an "Explain access" action (for a user, and a table or a prompt, which operations the assistant could call and which it would be denied, with the reason); - a recent-denials widget on the app home. - **(d) Spec'd assistant endpoints:** move `/qqq/v1/assistant/...` (conversations, turns, events, feedback, cancel, config, actions) onto `AbstractEndpointSpec`s through the qbit spec extension point (`MiddlewareSpecExtension`, established prefix `/assistant`, QRun-IO/qqq#1128). The SSE turn and events streams stay raw routes until the spec layer can describe a stream. + +## Answer presentation (owner priority 2026-10-13) + +`present_results` is always offered and reads no records. It takes a direct answer, +metrics, a table, an optional table-derived chart, filtered list links, record links +and next-step actions. The server validates metadata and the user's UI API table +visibility once per turn, formats values using QQQ field types/display formats, +computes percentages and builds every `/app` href. Unknown or denied items are +dropped with an explanation. The assistant must still retrieve every fact through +data tools before presenting it. + +The [presentation part and SSE contract](answer-presentation.md) defines `summary`, +`blocks` and the complete server-built GFM `markdown` fallback. Each successful +call emits `presentation` with `{messageId, part}` and persists that part; the tool +response tells the model not to repeat its tables or links. Audit rows contain +only accepted presentation data. Normal record-data retention removes these parts. + +The built-in Presentation skill is compact static prompt text in the cached rules +block. It asks for a one-sentence answer first, metrics for single numbers, tables +for breakdowns/comparisons, charts for useful shares/trends and actual filter links +whenever offering a list. Administrator profile skills append after it as before +and extend it; there is no replacement boolean or extra permission surface. + +The owner's one-line table was a frontend parsing defect: Next's `AssistantMarkdown` +used react-markdown without `remark-gfm`, so table lines became a paragraph. +The renderer fix belongs to `qqq-frontend-next`; this worktree preserves real +newlines and provides structured blocks plus a proper multi-line GFM fallback. +The frontend change is maintained separately and is not verified by this build. + +The shared golden task asks "How many images do I have by type?": five UI count +calls establish 81 JPEG, 20 PNG and zero other configured types, then a presentation +with two rows, 80.2%/19.8% shares, two pie points and working MIME filter links. +The same task is included in the opt-in `live` Anthropic eval suite. diff --git a/docs/answer-presentation.md b/docs/answer-presentation.md new file mode 100644 index 0000000..a229e41 --- /dev/null +++ b/docs/answer-presentation.md @@ -0,0 +1,56 @@ +# Assistant answer presentation contract (v1) + +Owner priority (2026-10-13): answers must ALWAYS be easy to read and act on. Bad example the owner saw, rendered on ONE line: +"| Type | Count | |---|---| | JPEG (image/jpeg) | 81 | | PNG (image/png) | 20 | | WebP, GIF, SVG | 0 |" followed by "You can open the lists filtered by JPEG or PNG" with NO links. + +Root cause found in Next: `AssistantMarkdown` uses react-markdown WITHOUT `remark-gfm`, so GFM tables are not parsed and the table's lines collapse into one paragraph (soft line breaks). Backend streaming preserves `\n` (events are JSON). + +## New message part: `presentation` + +An assistant message's `parts` (persisted in `assistant_message.parts_json`, streamed as an SSE event, returned by the conversation API) may contain, besides `text`, `notice`, `tool`/`render` parts etc., ONE part per `present_results` call: + +```json +{ + "type": "presentation", + "summary": "101 media files are images: 81 JPEG and 20 PNG.", + "markdown": "", + "blocks": [ + { "kind": "metrics", "items": [ { "label": "Images", "value": 101, "display": "101", "href": "/app/media?filter=..." } ] }, + { "kind": "table", "title": "Media by type", + "columns": [ { "key": "type", "label": "Type", "align": "left" }, + { "key": "count", "label": "Count", "align": "right" }, + { "key": "share", "label": "Share", "align": "right" } ], + "rows": [ { "cells": { "type": "JPEG (image/jpeg)", "count": 81, "share": 0.802 }, + "display": { "type": "JPEG (image/jpeg)", "count": "81", "share": "80.2%" }, + "href": "/app/media?filter=..." } ] }, + { "kind": "chart", "chartType": "pie", "title": "Share by type", + "points": [ { "label": "JPEG", "value": 81, "display": "81", "href": "/app/media?filter=..." }, + { "label": "PNG", "value": 20, "display": "20", "href": "/app/media?filter=..." } ] }, + { "kind": "filterLinks", "items": [ { "label": "Open JPEG media (81)", "tableName": "media", "href": "/app/media?filter=..." } ] }, + { "kind": "recordLinks", "items": [ { "label": "Sunset.jpg", "tableName": "media", "recordId": "12", "href": "/app/media/12" } ] }, + { "kind": "actions", "items": [ { "label": "Show the PNG files", "prompt": "Show me the PNG media files" }, + { "label": "Open media", "href": "/app/media" } ] } + ] +} +``` + +Rules: +- `summary` is required, one sentence, the direct answer first. +- `chartType` ∈ `bar`, `pie`, `line`; ≤ 12 points; values are numbers. +- Every `href` is built SERVER-SIDE from metadata and is an `/app` path (same rules as `TurnEngine.isAppHref`). Filtered list links are `/app/?filter=`, which Next's `deserializeFilter` accepts (JSON starting with `{`). The model never writes URLs. +- `display` strings are formatted server-side from field metadata (`displayFormat`, e.g. `%,d`, `$%,.2f`; percent columns as `80.2%`); clients show `display` when present, else the raw value. +- `actions` items have either `prompt` (send as the user's next message) or `href` (navigate), never both. +- Unknown `kind`s must be ignored by clients (forward compatible); clients that do not render blocks render `markdown`. +- The SSE event is `presentation` with `{ "messageId": , "part": }`; Next's reducer appends it to the assistant message's parts. + +## Tool input and implementation + +The input schema is [present-results.schema.json](../qbit-assistant/src/main/resources/present-results.schema.json). `table.rowKeys` supplies one raw filter value per row; `table.rowFilter` names the table, field and optional operator (EQUALS by default). Columns receive stable server keys `c0`, `c1`, etc. Input cells remain raw scalars; each row has a parallel display map. The example keys above are illustrative. + +`PresentResultsTool` is instantiated per turn. It caches `GET /metaData` with that turn's session, checks table read visibility and instance fields, validates QQQ criteria operators and builds paths using RC.5 `AiLinkBuilder`. Filter links use URL-encoded JSON accepted by Next. Record ids are encoded as opaque path segments. Metadata denial or invalid item drops that item (an invalid table drops its table/chart), with a short explanation to the model; other items survive. No data query or permission elevation happens here. + +Limits: summary 300 characters, display text 2,000 characters, table 20 columns/200 rows, chart 12 points, item lists 100 entries, filters 20 criteria/200 values. Chart truncation is reported to the model. Percent columns reference a raw numeric column; a zero total yields 0.0%. Numbers still must originate in earlier data tool results; presentation is formatting, not a new source of facts. + +The schema uses small named, nonrecursive definitions to avoid deeply inlined structures. Each object component is at most two levels of inline containers; the resolved table/criteria structure necessarily has the nesting shown in the owner contract. Strict is off by default. Opting `present_results` into strict mode requires all properties (empty arrays/strings, empty table and -1 for unused percentage indexes), removes provider-unsupported length constraints, and retains server validation. This has zero optional parameters and one shared scalar union in the existing strict-budget test. + +Presentation parts are persisted with the assistant message and replayed as `presentation` events with `{messageId, part}` (plus the standard event sequence). They are removed by record-data retention along with tool renders. Model history and text-only consumers can use the server-built Markdown. Unknown block kinds should be ignored. Plain labels must be rendered as text, never parsed as links or HTML; fallback Markdown escapes them. Tool audit input records the accepted presentation only, excluding dropped values. diff --git a/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/AnthropicRequestContractTest.java b/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/AnthropicRequestContractTest.java index 287af88..fe49bcd 100644 --- a/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/AnthropicRequestContractTest.java +++ b/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/AnthropicRequestContractTest.java @@ -108,6 +108,24 @@ class AnthropicRequestContractTest + /******************************************************************************* + ** Presentation remains inside the actual serialized strict request budget. + *******************************************************************************/ + @Test + void testPresentationStrictOptIn() throws Exception + { + Map body = requestBody(new AssistantQBitConfig().withModelProvider(new ScriptedModelProvider()).withStrictToolNames(Set.of("present_results"))); + assertThat(violations(body)).isEmpty(); + assertThat(strictBudgetViolations(body)).isEmpty(); + assertThat(tools(body)).anySatisfy(tool -> + { + assertThat(tool.get("name")).isEqualTo("present_results"); + assertThat(tool.get("strict")).isEqualTo(true); + }); + } + + + /******************************************************************************* ** The RC.3 shape (every tool strict) is recognized as over budget, and a ** host can still opt a few tools in. diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java index db3b832..4dd3743 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java @@ -34,6 +34,7 @@ import com.kingsrook.qbits.assistant.provider.ModelPart; import com.kingsrook.qbits.assistant.provider.ModelRequest; import com.kingsrook.qbits.assistant.provider.ModelToolDefinition; import com.kingsrook.qbits.assistant.provider.SystemBlock; +import com.kingsrook.qbits.assistant.tools.PresentResultsTool; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition; import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel; @@ -116,6 +117,7 @@ public class ContextBuilder .withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict())))); } } + byName.put(PresentResultsTool.NAME, PresentResultsTool.definition(isStrict(PresentResultsTool.NAME, true))); return (new ArrayList<>(byName.values())); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java index d1bd794..8be9c18 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java @@ -31,7 +31,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; ** assistantMessage.partsJson and returned by the API: ** {type:"text", text} | {type:"tool", toolCallId, name, summary, outcome, render?} ** | {type:"record_refs", refs:[...]} | {type:"navigate", href} - ** | {type:"notice", level, text} + ** | {type:"notice", level, text} | {type:"presentation", summary, blocks, markdown} ** Consecutive text is merged into one part. Thread-safe. *******************************************************************************/ public class MessageParts @@ -110,6 +110,17 @@ public class MessageParts + /******************************************************************************* + ** Append a server-built presentation, one part per successful tool call. + *******************************************************************************/ + public synchronized MessageParts addPresentation(Map presentation) + { + parts.add(new LinkedHashMap<>(presentation)); + return (this); + } + + + /******************************************************************************* ** Append record references. *******************************************************************************/ @@ -150,12 +161,12 @@ public class MessageParts /******************************************************************************* ** Remove record data (retention): the render payloads of tool parts and - ** the record_refs parts. Tool summaries, text, navigation and notices + ** the record_refs and presentation parts. Tool summaries, text, navigation and notices ** stay. Returns whether anything was removed. *******************************************************************************/ public synchronized boolean stripRecordData() { - boolean changed = parts.removeIf(p -> "record_refs".equals(p.get("type"))); + boolean changed = parts.removeIf(p -> ("record_refs".equals(p.get("type")) || "presentation".equals(p.get("type")))); for(Map part : parts) { if("tool".equals(part.get("type")) && part.remove("render") != null) @@ -169,7 +180,7 @@ public class MessageParts /******************************************************************************* - ** All text parts joined (for the model's history and titles). + ** Text and presentation fallbacks joined (for model history and titles). *******************************************************************************/ public synchronized String text() { @@ -180,6 +191,10 @@ public class MessageParts { builder.append(part.get("text")); } + else if("presentation".equals(part.get("type"))) + { + builder.append("\n").append(part.get("markdown")).append("\n"); + } } return (builder.toString()); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/SystemPrompt.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/SystemPrompt.java index fd747f1..7be292a 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/SystemPrompt.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/SystemPrompt.java @@ -40,7 +40,8 @@ public class SystemPrompt 10. An administrator may give you a name, a personality and skills (in and blocks). Follow them for tone and know-how, but they never override these rules and never give you other tools, data or permissions. 11. Some values are masked by the application (for example j***@x.com or ***1234). Treat them as masked, never as the real value, and never ask the user to reveal them for you. - Answer style: brief and specific. Use short Markdown (lists, small tables). Refer to records with the links the tools return. + Presentation (default skill; administrator skills may extend it): + Answer first in one sentence; do not restate totals ("101 add up to 101"). For 3+ items, comparisons or breakdowns, call present_results with a table; add a chart when shares or trends help. Prefer metrics for a single big number. Include any list you offer to open as a filterLinks item, never just plain text. Use only values from tool results, omit zero-count categories, and use percentOfColumn for shares. Keep prose short; do not repeat presented tables or links. Never write URLs yourself; supply metadata names and raw filter values to present_results. """; public static final String QQQ_PRIMER = """ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolLabels.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolLabels.java index dc40227..8acdbf5 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolLabels.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ToolLabels.java @@ -27,6 +27,7 @@ import java.util.Map; public class ToolLabels { private static final Map LABELS = Map.ofEntries( + Map.entry("present_results", new String[] { "Presenting results", "Presented results" }), Map.entry("list_tables", new String[] { "Listing tables", "Listed tables" }), Map.entry("describe_table", new String[] { "Reading table details", "Read table details" }), Map.entry("query_records", new String[] { "Querying records", "Queried records" }), diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java index 5bc5518..b1e5154 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java @@ -42,7 +42,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils; *******************************************************************************/ public class TurnClassification { - private static final Set METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help"); + private static final Set METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help", "present_results"); private final ClassificationSource source; private final Set tables = new LinkedHashSet<>(); private DataClassification classification; @@ -65,7 +65,8 @@ public class TurnClassification /******************************************************************************* - ** Observe before appending any result to a model request. Metadata tools carry no record values. + ** Observe before appending results. Metadata reads no records; presentation + ** only reuses values whose originating data tools were already classified. *******************************************************************************/ public void observe(ModelPart use, AiToolResult result) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java index 9a0649f..8656189 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java @@ -36,6 +36,7 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest; import com.kingsrook.qbits.assistant.provider.ModelResponse; import com.kingsrook.qbits.assistant.provider.ModelUsage; import com.kingsrook.qbits.assistant.provider.StreamHandle; +import com.kingsrook.qbits.assistant.tools.PresentResultsTool; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef; import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall; @@ -101,6 +102,7 @@ public class TurnEngine implements Runnable private static final Set ERROR_OUTCOMES = Set.of(AiToolOutcome.DENIED, AiToolOutcome.NOT_FOUND, AiToolOutcome.INVALID_INPUT, AiToolOutcome.ERROR); + private final PresentResultsTool presenter; private final AssistantRuntime runtime; private final AssistantQBitConfig config; private final AssistantStore store; @@ -146,6 +148,7 @@ public class TurnEngine implements Runnable AssistantTurn turn, String userText, Map pageContext, PolicyService.EffectivePolicy policy, RunningTurn running, String softBudgetNotice) { this.runtime = runtime; + this.presenter = new PresentResultsTool(runtime.getQInstance()); this.config = runtime.getConfig(); this.store = runtime.getStore(); this.session = session; @@ -689,7 +692,11 @@ public class TurnEngine implements Runnable { if(!running.isCancelled()) { - if(ContextBuilder.KERNEL_TOOLS.contains(call.getName())) + if(PresentResultsTool.NAME.equals(call.getName())) + { + results[index] = presenter.execute(toolContext(), call.getId(), call.getInput()); + } + else if(ContextBuilder.KERNEL_TOOLS.contains(call.getName())) { /////////////////////////////////////////////////// // navigate / search_help: no records are read // @@ -798,7 +805,8 @@ public class TurnEngine implements Runnable } Map data = map("messageId", assistantMessage.getId(), "toolCallId", use.getToolUseId(), "name", use.getToolName(), "outcome", outcome, "summary", summary); - if(result.getRender() != null) + Boolean presentation = PresentResultsTool.NAME.equals(use.getToolName()) && AiToolOutcome.OK.equals(result.getOutcome()) && result.getRender() != null; + if(result.getRender() != null && !presentation) { data.put("render", result.getRender()); } @@ -808,7 +816,12 @@ public class TurnEngine implements Runnable } emit(TurnEvent.TOOL_RESULT, data); - parts.addTool(use.getToolUseId(), use.getToolName(), summary, outcome, result.getRender()); + parts.addTool(use.getToolUseId(), use.getToolName(), summary, outcome, presentation ? null : result.getRender()); + if(presentation) + { + parts.addPresentation(result.getRender()); + emit(TurnEvent.PRESENTATION, map("messageId", assistantMessage.getId(), "part", result.getRender())); + } parts.addRecordRefs(refs); String href = result.getNavigateHref(); @@ -826,7 +839,7 @@ public class TurnEngine implements Runnable .withUserId(userId) .withToolCallId(use.getToolUseId()) .withToolName(use.getToolName()) - .withInputJson(truncate(redactInput(use.getInput()), MAX_INPUT_JSON_CHARS)) + .withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS)) .withOutcome(outcome) .withPermissionCheck(result.getPermissionCheck()) .withTableName(result.getTableName()) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java index 49b194e..946e944 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java @@ -33,6 +33,7 @@ public class TurnEvent public static final String TEXT_DELTA = "text.delta"; public static final String TOOL_START = "tool.start"; public static final String TOOL_RESULT = "tool.result"; + public static final String PRESENTATION = "presentation"; public static final String UI_NAVIGATE = "ui.navigate"; public static final String NOTICE = "notice"; public static final String USAGE = "usage"; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java new file mode 100644 index 0000000..28c14f2 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java @@ -0,0 +1,727 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.tools; + + +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.MathContext; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import com.fasterxml.jackson.core.type.TypeReference; +import com.kingsrook.qbits.assistant.provider.ModelToolDefinition; +import com.kingsrook.qbits.assistant.uiapi.UiApiCall; +import com.kingsrook.qbits.assistant.uiapi.UiApiResult; +import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; +import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; +import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** Builds presentations from previously retrieved values, never reading records. + ** One instance belongs to one turn: UI metadata, including denials, is cached + ** only for that turn and session. All output links are built by the server. + *******************************************************************************/ +public class PresentResultsTool +{ + public static final String NAME = "present_results"; + + private final QInstance qInstance; + private UiApiResult metadata; + + + + /******************************************************************************* + ** Create a turn-scoped presenter. + *******************************************************************************/ + public PresentResultsTool(QInstance qInstance) + { + this.qInstance = qInstance; + } + + + + /******************************************************************************* + ** Stable schema. Strict opt-in requires empty placeholders instead of many + ** optional productions, keeping the existing strict grammar budget small. + *******************************************************************************/ + public static ModelToolDefinition definition(Boolean strict) + { + try(InputStream stream = PresentResultsTool.class.getResourceAsStream("/present-results.schema.json")) + { + Map schema = JsonUtils.toObject(new String(stream.readAllBytes(), StandardCharsets.UTF_8), new TypeReference>() {}); + if(strict) + { + requireProperties(schema); + } + return (new ModelToolDefinition().withName(NAME).withDescription("Present an answer as metrics, a table, a chart and actionable links. Use only data from tool results; never supply URLs.") + .withInputSchema(schema).withStrict(strict)); + } + catch(Exception e) + { + throw (new IllegalStateException("Cannot load presentation schema", e)); + } + } + + + + /******************************************************************************* + ** Eliminate optional productions for strict mode without adding unions. + *******************************************************************************/ + private static void requireProperties(Object node) + { + if(node instanceof Map) + { + Map schema = object(node); + schema.remove("maxLength"); + if(schema.get("properties") instanceof Map properties) + { + schema.put("required", new ArrayList<>(properties.keySet())); + } + schema.values().forEach(PresentResultsTool::requireProperties); + } + else if(node instanceof List list) + { + list.forEach(PresentResultsTool::requireProperties); + } + } + + + + /******************************************************************************* + ** Preserve valid items when others are dropped; never echo rejected values. + *******************************************************************************/ + public synchronized AiToolResult execute(UiApiToolbox.Context context, String callId, Map input) + { + AiToolResult result = new AiToolResult().withToolCallId(callId).withToolName(NAME); + try + { + require(input != null && input.get("summary") instanceof String, "summary required"); + String summary = text(input.get("summary")); + require(!summary.isBlank() && summary.length() <= 300, "summary must contain 1 to 300 characters"); + List notes = new ArrayList<>(); + List> blocks = new ArrayList<>(); + addItems(blocks, "metrics", input.get("metrics"), notes, item -> metric(context, item)); + Map table = null; + if(input.get("table") != null) + { + try + { + table = table(context, object(input.get("table"))); + if(!list(table.get("columns")).isEmpty()) + { + blocks.add(table); + } + } + catch(IllegalArgumentException e) + { + notes.add("Dropped table: " + e.getMessage()); + } + } + if(input.get("chart") != null && table != null && !list(table.get("rows")).isEmpty()) + { + try + { + blocks.add(chart(object(input.get("chart")), table, notes)); + } + catch(IllegalArgumentException e) + { + notes.add("Dropped chart: " + e.getMessage()); + } + } + if(input.get("chart") != null && (table == null || list(table.get("rows")).isEmpty())) + { + notes.add("Dropped chart: requires a valid nonempty table."); + } + addItems(blocks, "filterLinks", input.get("filterLinks"), notes, item -> filterLink(context, item)); + addItems(blocks, "recordLinks", input.get("recordLinks"), notes, item -> recordLink(context, item)); + addItems(blocks, "actions", input.get("actions"), notes, item -> action(context, item)); + Map part = map("type", "presentation", "summary", summary, "blocks", blocks, "markdown", markdown(summary, blocks)); + return (result.withOutcome(AiToolOutcome.OK).withSummary("Presented results").withRender(part) + .withPermissionCheck(metadata == null ? "presentation:no table references" : "uiApi:GET /metaData:" + metadata.status()) + .withModelContent("Presented to the user: " + summary + (summary.endsWith(".") ? "" : ".") + " Do not repeat the table or links in text." + (notes.isEmpty() ? "" : "\n" + String.join("\n", notes)))); + } + catch(IllegalArgumentException e) + { + return (result.withOutcome(AiToolOutcome.INVALID_INPUT).withSummary("Invalid presentation").withModelContent(e.getMessage())); + } + } + + + + /******************************************************************************* + ** Resolve tables through the same metadata operation and session as the UI. + *******************************************************************************/ + private QTableMetaData visibleTable(UiApiToolbox.Context context, String name) + { + QTableMetaData table = qInstance.getTable(name); + require(table != null, "unknown table " + name); + if(metadata == null) + { + try + { + metadata = context.gateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone())); + } + catch(QException e) + { + metadata = new UiApiResult(503, "{}"); + } + } + require(metadata.isSuccess(), "GET /metaData answered HTTP " + metadata.status()); + Map tables = object(object(metadata.json()).get("tables")); + require(tables.containsKey(name) && Boolean.TRUE.equals(object(tables.get(name)).get("readPermission")), "table not visible through UI API: " + name); + return (table); + } + + + + /******************************************************************************* + ** A field reference must be paired with a visible table reference. + *******************************************************************************/ + private QFieldMetaData field(UiApiToolbox.Context context, Map item) + { + String tableName = text(item.get("tableName")); + String fieldName = text(item.get("fieldName")); + require(fieldName.isEmpty() || !tableName.isEmpty(), "fieldName requires tableName"); + QTableMetaData table = tableName.isEmpty() ? null : visibleTable(context, tableName); + QFieldMetaData field = fieldName.isEmpty() ? null : table.getFields().get(fieldName); + require(fieldName.isEmpty() || field != null, "unknown field " + fieldName); + return (field); + } + + + + /******************************************************************************* + ** A metric preserves its numeric value and adds the metadata display. + *******************************************************************************/ + private Map metric(UiApiToolbox.Context context, Map item) + { + QFieldMetaData field = field(context, item); + number(item.get("value")); + return (map("label", label(item), "value", item.get("value"), "display", display(item.get("value"), field))); + } + + + + /******************************************************************************* + ** Build table cells and percentages from raw values, never model displays. + *******************************************************************************/ + private Map table(UiApiToolbox.Context context, Map input) + { + List columns = list(input.get("columns")); + List rows = list(input.get("rows")); + require(columns.size() <= 20 && rows.size() <= 200, "table exceeds 20 columns or 200 rows"); + require(rows.isEmpty() || !columns.isEmpty(), "rows require columns"); + List fields = new ArrayList<>(); + List> outputColumns = new ArrayList<>(); + List totals = new ArrayList<>(); + for(Integer c = 0; c < columns.size(); c++) + { + Map column = object(columns.get(c)); + fields.add(field(context, column)); + BigDecimal total = BigDecimal.ZERO; + Boolean numeric = !rows.isEmpty(); + for(Object row : rows) + { + require(list(row).size() == columns.size(), "row width must match columns"); + Object value = list(row).get(c); + scalar(value); + if(value instanceof Number) + { + total = total.add(number(value)); + } + else + { + numeric = false; + } + } + totals.add(numeric ? total : null); + outputColumns.add(map("key", "c" + c, "label", label(column), "align", numeric || percentColumn(column) >= 0 ? "right" : "left")); + } + Map rowFilter = input.get("rowFilter") == null ? Map.of() : object(input.get("rowFilter")); + List rowKeys = input.get("rowKeys") == null ? List.of() : list(input.get("rowKeys")); + Boolean hasFilter = !text(rowFilter.get("tableName")).isEmpty(); + require(!hasFilter || rowKeys.size() == rows.size(), "rowKeys must match rows"); + QFieldMetaData filterField = field(context, rowFilter); + require(!hasFilter || filterField != null, "rowFilter requires a field"); + List> outputRows = new ArrayList<>(); + for(Integer r = 0; r < rows.size(); r++) + { + Map cells = new LinkedHashMap<>(); + Map displays = new LinkedHashMap<>(); + for(Integer c = 0; c < columns.size(); c++) + { + Integer source = percentColumn(object(columns.get(c))); + Object value = list(rows.get(r)).get(c); + String formatted; + if(source >= 0) + { + require(source < columns.size() && totals.get(source) != null && percentColumn(object(columns.get(source))) < 0, "percentOfColumn must reference a numeric raw column"); + BigDecimal share = totals.get(source).signum() == 0 ? BigDecimal.ZERO : number(list(rows.get(r)).get(source)).divide(totals.get(source), MathContext.DECIMAL64); + value = share; + formatted = String.format(Locale.US, "%.1f%%", share.multiply(BigDecimal.valueOf(100))); + } + else + { + formatted = display(value, fields.get(c)); + } + cells.put("c" + c, value); + displays.put("c" + c, formatted); + } + Map row = map("cells", cells, "display", displays); + if(hasFilter) + { + Object key = rowKeys.get(r); + scalar(key); + Map criterion = map("fieldName", rowFilter.get("fieldName"), "operator", rowFilter.getOrDefault("operator", "EQUALS"), "values", java.util.Collections.singletonList(key)); + row.put("href", filterHref(context, text(rowFilter.get("tableName")), List.of(criterion))); + } + outputRows.add(row); + } + return (map("kind", "table", "title", text(input.get("title")), "columns", outputColumns, "rows", outputRows)); + } + + + + /******************************************************************************* + ** A chart uses the table's raw values, formatted values and row links. + *******************************************************************************/ + private Map chart(Map input, Map table, List notes) + { + String type = text(input.get("chartType")); + require(List.of("bar", "pie", "line").contains(type), "invalid chartType"); + Integer labelColumn = index(input.get("labelColumn")); + Integer valueColumn = index(input.get("valueColumn")); + Integer width = list(table.get("columns")).size(); + require(labelColumn >= 0 && valueColumn >= 0 && labelColumn < width && valueColumn < width, "chart column index out of range"); + List> points = new ArrayList<>(); + List rows = list(table.get("rows")); + for(Object entry : rows) + { + Map row = object(entry); + Object value = object(row.get("cells")).get("c" + valueColumn); + number(value); + if(points.size() < 12) + { + Map point = map("label", object(row.get("display")).get("c" + labelColumn), "value", value, "display", object(row.get("display")).get("c" + valueColumn)); + if(row.containsKey("href")) + { + point.put("href", row.get("href")); + } + points.add(point); + } + } + if(rows.size() > 12) + { + notes.add("Chart truncated to 12 points; the table retains all rows."); + } + return (map("kind", "chart", "chartType", type, "title", text(input.get("title")), "points", points)); + } + + + + /******************************************************************************* + ** A filtered list link, including only validated metadata and raw values. + *******************************************************************************/ + private Map filterLink(UiApiToolbox.Context context, Map item) + { + String tableName = text(item.get("tableName")); + return (map("label", label(item), "tableName", tableName, "href", filterHref(context, tableName, list(item.get("criteria"))))); + } + + + + /******************************************************************************* + ** Serialize the UI's JSON filter format. The table path uses core navigate's + ** helper; filter values stay raw inside the URL-encoded JSON. + *******************************************************************************/ + private String filterHref(UiApiToolbox.Context context, String tableName, List criteria) + { + visibleTable(context, tableName); + require(!criteria.isEmpty() && criteria.size() <= 20, "filter requires 1 to 20 criteria"); + List> output = new ArrayList<>(); + for(Object entry : criteria) + { + Map criterion = object(entry); + String fieldName = text(criterion.get("fieldName")); + require(!fieldName.isBlank(), "filter requires fieldName"); + field(context, map("tableName", tableName, "fieldName", fieldName)); + String operator = text(criterion.get("operator")); + operator = operator.isEmpty() ? "EQUALS" : operator; + try + { + QCriteriaOperator.valueOf(operator); + } + catch(IllegalArgumentException e) + { + throw (new IllegalArgumentException("invalid criteria operator")); + } + List values = list(criterion.get("values")); + require(values.size() <= 200, "too many filter values"); + values.forEach(PresentResultsTool::scalar); + output.add(map("fieldName", fieldName, "operator", operator, "values", values)); + } + return (safeHref(AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(map("criteria", output, "booleanOperator", "AND")), StandardCharsets.UTF_8))); + } + + + + /******************************************************************************* + ** Record ids are opaque path segments, encoded by core's link helper. + *******************************************************************************/ + private Map recordLink(UiApiToolbox.Context context, Map item) + { + String tableName = text(item.get("tableName")); + visibleTable(context, tableName); + Object id = item.get("recordId"); + require(id instanceof String || id instanceof Number, "recordId must be a string or number"); + String recordId = text(id); + require(!recordId.isBlank() && !List.of(".", "..").contains(recordId), "invalid recordId"); + return (map("label", label(item), "tableName", tableName, "recordId", recordId, "href", safeHref(AiLinkBuilder.recordHref(tableName, recordId)))); + } + + + + /******************************************************************************* + ** Actions are either a next-message prompt or a visible table destination. + *******************************************************************************/ + private Map action(UiApiToolbox.Context context, Map item) + { + String prompt = text(item.get("prompt")); + String destination = text(item.get("navigateTo")); + require(prompt.isBlank() != destination.isBlank(), "action requires exactly one of prompt or navigateTo"); + if(!destination.isBlank()) + { + visibleTable(context, destination); + return (map("label", label(item), "href", safeHref(AiLinkBuilder.objectHref(destination)))); + } + return (map("label", label(item), "prompt", prompt)); + } + + + + /******************************************************************************* + ** Preserve independently valid items and report dropped positions. + *******************************************************************************/ + private static void addItems(List> blocks, String kind, Object input, List notes, Function, Map> build) + { + if(input == null) + { + return; + } + List entries = list(input); + require(entries.size() <= 100, "too many " + kind); + List> items = new ArrayList<>(); + for(Integer i = 0; i < entries.size(); i++) + { + try + { + items.add(build.apply(object(entries.get(i)))); + } + catch(IllegalArgumentException e) + { + notes.add("Dropped " + kind + "[" + i + "]: " + e.getMessage()); + } + } + if(!items.isEmpty()) + { + blocks.add(map("kind", kind, "items", items)); + } + } + + + + /******************************************************************************* + ** Metadata formats use QQQ field coercion and Java display format strings. + *******************************************************************************/ + private static String display(Object value, QFieldMetaData field) + { + if(value == null) + { + return (""); + } + if(field != null) + { + try + { + Object typed = ValueUtils.getValueAsFieldType(field.getType(), value); + if(field.getDisplayFormat() != null) + { + return (String.format(Locale.US, field.getDisplayFormat(), typed)); + } + value = typed; + } + catch(Exception e) + { + throw (new IllegalArgumentException("value does not match field format")); + } + } + if(value instanceof Number) + { + BigDecimal decimal = number(value); + return (decimal.stripTrailingZeros().scale() <= 0 ? String.format(Locale.US, "%,d", decimal.toBigIntegerExact()) : decimal.toPlainString()); + } + return (text(value)); + } + + + + /******************************************************************************* + ** Complete GFM fallback with real newlines; chart data is already in table. + *******************************************************************************/ + private static String markdown(String summary, List> blocks) + { + StringBuilder markdown = new StringBuilder(escape(summary)).append("\n"); + for(Map block : blocks) + { + if("table".equals(block.get("kind"))) + { + markdown.append("\n").append(escape(text(block.get("title")))).append("\n\n|"); + List columns = list(block.get("columns")); + for(Object column : columns) + { + markdown.append(' ').append(escape(text(object(column).get("label")))).append(" |"); + } + markdown.append("\n|"); + for(Object column : columns) + { + markdown.append("right".equals(object(column).get("align")) ? " ---: |" : " --- |"); + } + for(Object entry : list(block.get("rows"))) + { + Map row = object(entry); + markdown.append("\n|"); + for(Integer c = 0; c < columns.size(); c++) + { + String value = escape(text(object(row.get("display")).get("c" + c))); + markdown.append(' ').append(c == 0 && row.containsKey("href") ? "[" + value + "](" + row.get("href") + ")" : value).append(" |"); + } + } + markdown.append("\n"); + } + else if(block.get("items") instanceof List items) + { + markdown.append("\n"); + for(Object entry : items) + { + Map item = object(entry); + String label = escape(text(item.get("label"))); + markdown.append("- "); + if(item.containsKey("href")) + { + markdown.append('[').append(label).append("](").append(item.get("href")).append(')'); + } + else + { + markdown.append(label); + if(item.containsKey("display") || item.containsKey("prompt")) + { + markdown.append(": ").append(escape(text(item.getOrDefault("display", item.get("prompt"))))); + } + } + markdown.append("\n"); + } + } + } + return (markdown.toString()); + } + + + + /******************************************************************************* + ** Literal text only: no Markdown links, HTML or GFM URL autolinks from labels. + *******************************************************************************/ + private static String escape(String value) + { + StringBuilder output = new StringBuilder(); + for(char character : value.toCharArray()) + { + if("\\`*_{}[]()<>|!&#:.@~".indexOf(character) >= 0) + { + output.append("&#").append((int) character).append(';'); + } + else + { + output.append(character); + } + } + return (output.toString()); + } + + + + /******************************************************************************* + ** Reject unsafe paths even if metadata contains unexpected names. + *******************************************************************************/ + private static String safeHref(String href) + { + require(AiLinkBuilder.isSafeAppPath(href), "unsafe application path"); + return (href); + } + + + + /******************************************************************************* + ** A column with no percentage uses -1 in strict mode. + *******************************************************************************/ + private static Integer percentColumn(Map column) + { + return (column.get("percentOfColumn") == null ? -1 : index(column.get("percentOfColumn"))); + } + + + + /******************************************************************************* + ** Indexes must be integral; never silently truncate model input. + *******************************************************************************/ + private static Integer index(Object value) + { + try + { + return (number(value).intValueExact()); + } + catch(ArithmeticException e) + { + throw (new IllegalArgumentException("index must be an integer")); + } + } + + + + /******************************************************************************* + ** Finite numeric input only. + *******************************************************************************/ + private static BigDecimal number(Object value) + { + require(value instanceof Number, "numeric value required"); + try + { + return (new BigDecimal(value.toString())); + } + catch(NumberFormatException e) + { + throw (new IllegalArgumentException("finite numeric value required")); + } + } + + + + /******************************************************************************* + ** Cells and filter values remain raw JSON scalars. + *******************************************************************************/ + private static void scalar(Object value) + { + require(value == null || value instanceof String || value instanceof Number || value instanceof Boolean, "scalar value required"); + if(value instanceof Number) + { + number(value); + } + if(value instanceof String) + { + text(value); + } + } + + + + /******************************************************************************* + ** Bounded single-line display text. + *******************************************************************************/ + private static String text(Object value) + { + require(value == null || value instanceof String || value instanceof Number || value instanceof Boolean, "text or scalar required"); + String text = value == null ? "" : String.valueOf(value); + require(text.length() <= 2000, "text exceeds 2000 characters"); + return (text.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", " ")); + } + + + + /******************************************************************************* + ** Labels are mandatory plain text. + *******************************************************************************/ + private static String label(Map item) + { + String label = text(item.get("label")); + require(!label.isBlank(), "label required"); + return (label); + } + + + + /******************************************************************************* + ** Validate a JSON object without converting arbitrary values to strings. + *******************************************************************************/ + @SuppressWarnings("unchecked") + private static Map object(Object value) + { + require(value instanceof Map, "object required"); + return ((Map) value); + } + + + + /******************************************************************************* + ** Validate a JSON array. + *******************************************************************************/ + private static List list(Object value) + { + require(value instanceof List, "array required"); + return ((List) value); + } + + + + /******************************************************************************* + ** Ordered output maps allow null raw cells. + *******************************************************************************/ + private static Map map(Object... pairs) + { + Map map = new LinkedHashMap<>(); + for(Integer i = 0; i < pairs.length; i += 2) + { + map.put((String) pairs[i], pairs[i + 1]); + } + return (map); + } + + + + /******************************************************************************* + ** Validation failures contain only server-authored explanations. + *******************************************************************************/ + private static void require(Boolean condition, String message) + { + if(!condition) + { + throw (new IllegalArgumentException(message)); + } + } +} diff --git a/qbit-assistant/src/main/resources/present-results.schema.json b/qbit-assistant/src/main/resources/present-results.schema.json new file mode 100644 index 0000000..2236add --- /dev/null +++ b/qbit-assistant/src/main/resources/present-results.schema.json @@ -0,0 +1,280 @@ +{ + "type": "object", + "properties": { + "summary": { + "type": "string", + "maxLength": 300 + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/$defs/metric" + } + }, + "table": { + "$ref": "#/$defs/table" + }, + "chart": { + "$ref": "#/$defs/chart" + }, + "filterLinks": { + "type": "array", + "items": { + "$ref": "#/$defs/filterLink" + } + }, + "recordLinks": { + "type": "array", + "items": { + "$ref": "#/$defs/recordLink" + } + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/$defs/action" + } + } + }, + "required": [ + "summary" + ], + "additionalProperties": false, + "$defs": { + "cell": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "metric": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "number" + }, + "tableName": { + "type": "string" + }, + "fieldName": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + }, + "column": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "fieldName": { + "type": "string" + }, + "percentOfColumn": { + "type": "integer" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "rowFilter": { + "type": "object", + "properties": { + "tableName": { + "type": "string" + }, + "fieldName": { + "type": "string" + }, + "operator": { + "type": "string" + } + }, + "required": [ + "tableName", + "fieldName" + ], + "additionalProperties": false + }, + "criterion": { + "type": "object", + "properties": { + "fieldName": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/$defs/cell" + } + } + }, + "required": [ + "fieldName", + "operator", + "values" + ], + "additionalProperties": false + }, + "filterLink": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "criteria": { + "$ref": "#/$defs/criteria" + } + }, + "required": [ + "label", + "tableName", + "criteria" + ], + "additionalProperties": false + }, + "recordLink": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "recordId": { + "$ref": "#/$defs/cell" + } + }, + "required": [ + "label", + "tableName", + "recordId" + ], + "additionalProperties": false + }, + "action": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "navigateTo": { + "type": "string" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "chart": { + "type": "object", + "properties": { + "chartType": { + "type": "string", + "enum": [ + "bar", + "pie", + "line" + ] + }, + "title": { + "type": "string" + }, + "labelColumn": { + "type": "integer" + }, + "valueColumn": { + "type": "integer" + } + }, + "required": [ + "chartType", + "labelColumn", + "valueColumn" + ], + "additionalProperties": false + }, + "row": { + "type": "array", + "items": { + "$ref": "#/$defs/cell" + } + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/column" + } + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/$defs/row" + } + }, + "rowKeys": { + "type": "array", + "items": { + "$ref": "#/$defs/cell" + } + }, + "criteria": { + "type": "array", + "items": { + "$ref": "#/$defs/criterion" + } + }, + "table": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "columns": { + "$ref": "#/$defs/columns" + }, + "rows": { + "$ref": "#/$defs/rows" + }, + "rowFilter": { + "$ref": "#/$defs/rowFilter" + }, + "rowKeys": { + "$ref": "#/$defs/rowKeys" + } + }, + "required": [ + "columns", + "rows" + ], + "additionalProperties": false + } + }, + "description": "Present only facts from previous tool results. Optional sections may be omitted. In strict mode use empty arrays, empty strings, empty table rows/columns and -1 for unused indexes; every property must be supplied. rowKeys belongs to table, one raw key per row. Percent columns may use null cells; the server computes them." +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java index 1945739..50a51f0 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java @@ -93,6 +93,10 @@ class ClassificationTest extends BaseAssistantTest assertThat(record.getClassification()).isEqualTo(DataClassification.PHI); page.observe(ModelPart.toolUse("1", "describe_table", Map.of("table", "page")), new AiToolResult()); assertThat(page.getClassification()).isEqualTo(DataClassification.PUBLIC); + page.observe(ModelPart.toolUse("present", "present_results", Map.of("summary", "Available results")), new AiToolResult()); + assertThat(page.getClassification()).isEqualTo(DataClassification.PUBLIC); + record.observe(ModelPart.toolUse("present", "present_results", Map.of("summary", "Available results")), new AiToolResult()); + assertThat(record.getClassification()).isEqualTo(DataClassification.PHI); page.observe(ModelPart.toolUse("2", "search_records", Map.of()), new AiToolResult().withModelContent("{\"results\":[{\"table\":\"media\"}]}")); assertThat(page.getClassification()).isEqualTo(DataClassification.PII); page.observe(ModelPart.toolUse("3", "search_records", Map.of()), new AiToolResult()); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java index ac6051f..e2e8cd1 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java @@ -226,9 +226,13 @@ public class AssistantTestFixture update("page", new QRecord().withValue("id", 12).withValue("metaDescription", "Who I am and what I build.")); update("page", new QRecord().withValue("id", 12).withValue("content", "Body of page 12, rewritten with a longer story about the home lab.")); - insert("media", List.of( - new QRecord().withValue("fileName", "cover.heic").withValue("mimeType", "image/heic"), - new QRecord().withValue("fileName", "logo.png").withValue("mimeType", "image/png").withValue("width", 512))); + List media = new ArrayList<>(); + for(Integer i = 0; i < 101; i++) + { + media.add(new QRecord().withValue("fileName", "image-" + i + (i < 81 ? ".jpg" : ".png")) + .withValue("mimeType", i < 81 ? "image/jpeg" : "image/png").withValue("width", 512)); + } + insert("media", media); List stats = new ArrayList<>(); for(int d = 1; d <= 5; d++) diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/GoldenTasks.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/GoldenTasks.java index 6738709..84026fa 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/GoldenTasks.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/GoldenTasks.java @@ -180,6 +180,7 @@ public class GoldenTasks }, o -> live(o, "search_records", "/app/blog_post/1"))); + tasks.add(PresentationGoldenTask.task()); return (tasks); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/PresentationGoldenTask.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/PresentationGoldenTask.java new file mode 100644 index 0000000..e89b06d --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/PresentationGoldenTask.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.testing; + + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.core.type.TypeReference; +import com.kingsrook.qbits.assistant.engine.TurnEvent; +import com.kingsrook.qbits.assistant.provider.ModelPart; +import com.kingsrook.qbits.assistant.provider.ModelResponse; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import static com.kingsrook.qbits.assistant.testing.GoldenTasks.map; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Owner's image breakdown, shared by deterministic and live provider evals. + *******************************************************************************/ +public class PresentationGoldenTask +{ + /******************************************************************************* + ** Counts come from UI API tool results, including the omitted zero types. + *******************************************************************************/ + public static GoldenTasks.Task task() + { + return (new GoldenTasks.Task("images-by-type-presentation", AssistantTestFixture.EDITOR, "How many images do I have by type?", provider -> + { + List calls = new ArrayList<>(); + for(String mime : List.of("image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml")) + { + calls.add(new Object[] {"count_records", map("table", "media", "filter", GoldenTasks.filter("AND", GoldenTasks.criterion("mimeType", "EQUALS", mime)))}); + } + provider.thenToolUses(null, calls).thenRespond(request -> + { + List results = EvalSupport.lastToolResults(request); + Integer jpeg = ((Number) EvalSupport.unwrap(results.get(0).getText()).get("count")).intValue(); + Integer png = ((Number) EvalSupport.unwrap(results.get(1).getText()).get("count")).intValue(); + for(Integer i = 2; i < results.size(); i++) + { + assertThat(((Number) EvalSupport.unwrap(results.get(i).getText()).get("count")).intValue()).isZero(); + } + return (new ModelResponse().withParts(List.of(ModelPart.toolUse("present", "present_results", input(jpeg, png)))).withStopReason(ModelResponse.STOP_TOOL_USE)); + }).thenText(""); + }, PresentationGoldenTask::check, PresentationGoldenTask::check)); + } + + + + /******************************************************************************* + ** The presentation input, deliberately using null percentage placeholders. + *******************************************************************************/ + public static Map input(Integer jpeg, Integer png) + { + return (map("summary", (jpeg + png) + " media files are images: " + jpeg + " JPEG and " + png + " PNG.", + "metrics", List.of(map("label", "Images", "value", jpeg + png)), + "table", map("title", "Media by type", "columns", List.of(map("label", "Type"), map("label", "Count"), map("label", "Share", "percentOfColumn", 1)), + "rows", List.of(java.util.Arrays.asList("JPEG", jpeg, null), java.util.Arrays.asList("PNG", png, null)), + "rowFilter", map("tableName", "media", "fieldName", "mimeType"), "rowKeys", List.of("image/jpeg", "image/png")), + "chart", map("chartType", "pie", "title", "Share by type", "labelColumn", 0, "valueColumn", 1), + "filterLinks", List.of(filterLink("JPEG", "image/jpeg"), filterLink("PNG", "image/png")))); + } + + + + /******************************************************************************* + ** A raw MIME criterion for the presentation tool. + *******************************************************************************/ + private static Map filterLink(String label, String mime) + { + return (map("label", "Open " + label + " media", "tableName", "media", "criteria", List.of(map("fieldName", "mimeType", "operator", "EQUALS", "values", List.of(mime))))); + } + + + + /******************************************************************************* + ** Check actual block semantics, allowing a live model to choose labels and + ** column order. Values, shares and filter criteria must remain exact. + *******************************************************************************/ + public static void check(TurnRunner.Outcome outcome) + { + assertThat(outcome.end()).containsEntry("status", "COMPLETE"); + assertThat(outcome.toolCalls()).anyMatch(call -> "count_records".equals(call.getToolName()) && "OK".equals(call.getOutcome())); + assertThat(outcome.events(TurnEvent.PRESENTATION)).hasSize(1); + Map part = object(outcome.events(TurnEvent.PRESENTATION).get(0).get("part")); + assertPart(part); + } + + + + /******************************************************************************* + ** Also used against the persisted part, not only the streamed event. + *******************************************************************************/ + public static void assertPart(Map part) + { + assertThat(part.get("type")).isEqualTo("presentation"); + assertThat((String) part.get("summary")).isNotBlank().doesNotContain("\n").hasSizeLessThanOrEqualTo(300); + assertThat((String) part.get("markdown")).contains("\n|"); + Map table = block(part, "table"); + List rows = (List) table.get("rows"); + assertThat(rows).hasSize(2); + assertThat(rows).anySatisfy(row -> assertThat(object(object(row).get("display")).values()).contains("81", "80.2%")); + assertThat(rows).anySatisfy(row -> assertThat(object(object(row).get("display")).values()).contains("20", "19.8%")); + Map chart = block(part, "chart"); + assertThat(chart.get("chartType")).isEqualTo("pie"); + List points = (List) chart.get("points"); + assertThat(points).hasSize(2); + assertThat(points.stream().map(p -> ((Number) object(p).get("value")).intValue()).toList()).containsExactlyInAnyOrder(81, 20); + List links = (List) block(part, "filterLinks").get("items"); + assertThat(links).hasSize(2); + List mimes = new ArrayList<>(); + for(Object entry : links) + { + String href = (String) object(entry).get("href"); + assertThat(href).startsWith("/app/media?filter="); + try + { + Map filter = JsonUtils.toObject(URLDecoder.decode(href.substring(href.indexOf('=') + 1), StandardCharsets.UTF_8), new TypeReference>() {}); + assertThat(filter).containsEntry("booleanOperator", "AND"); + List criteria = (List) filter.get("criteria"); + assertThat(criteria).hasSize(1); + Map criterion = object(criteria.get(0)); + assertThat(criterion).containsEntry("fieldName", "mimeType").containsEntry("operator", "EQUALS"); + mimes.addAll((List) criterion.get("values")); + } + catch(Exception e) + { + throw (new AssertionError("Invalid filtered list href", e)); + } + } + assertThat(mimes).containsExactlyInAnyOrder("image/jpeg", "image/png"); + } + + + + /******************************************************************************* + ** Find one block of a given kind. + *******************************************************************************/ + public static Map block(Map part, String kind) + { + return (((List) part.get("blocks")).stream().map(PresentationGoldenTask::object).filter(block -> kind.equals(block.get("kind"))).findFirst().orElseThrow()); + } + + + + /******************************************************************************* + ** Typed test view of a JSON object. + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static Map object(Object value) + { + return ((Map) value); + } +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java index 4098a38..ae0200c 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java @@ -59,7 +59,8 @@ public class TurnRunner *******************************************************************************/ public String text() { - return (events.stream().filter(e -> TurnEvent.TEXT_DELTA.equals(e.getType())).map(e -> String.valueOf(e.getData().get("text"))).collect(Collectors.joining())); + return (events.stream().filter(e -> TurnEvent.TEXT_DELTA.equals(e.getType()) || TurnEvent.PRESENTATION.equals(e.getType())) + .map(e -> TurnEvent.PRESENTATION.equals(e.getType()) ? String.valueOf(((Map) e.getData().get("part")).get("markdown")) : String.valueOf(e.getData().get("text"))).collect(Collectors.joining())); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/PresentResultsToolTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/PresentResultsToolTest.java new file mode 100644 index 0000000..1eba20e --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/PresentResultsToolTest.java @@ -0,0 +1,275 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.tools; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.assistant.engine.MessageParts; +import com.kingsrook.qbits.assistant.engine.PolicyService; +import com.kingsrook.qbits.assistant.engine.TurnEvent; +import com.kingsrook.qbits.assistant.model.AssistantMessage; +import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; +import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qbits.assistant.testing.PresentationGoldenTask; +import com.kingsrook.qbits.assistant.testing.TurnRunner; +import com.kingsrook.qbits.assistant.uiapi.UiApiCall; +import com.kingsrook.qbits.assistant.uiapi.UiApiGateway; +import com.kingsrook.qbits.assistant.uiapi.UiApiResult; +import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; +import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qbits.assistant.testing.PresentationGoldenTask.block; +import static com.kingsrook.qbits.assistant.testing.PresentationGoldenTask.object; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Presentation persistence, permission boundaries, formatting and input limits. + *******************************************************************************/ +class PresentResultsToolTest extends BaseAssistantTest +{ + /******************************************************************************* + ** Streamed, replayed and stored presentations agree; retention removes data. + *******************************************************************************/ + @Test + void testPersistenceAndReplay() throws Exception + { + PresentationGoldenTask.task().script().accept(provider); + TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "How many images do I have by type?"); + PresentationGoldenTask.check(outcome); + QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config)); + Map event = outcome.events(TurnEvent.PRESENTATION).get(0); + AssistantMessage message = runner.runtime().getStore().getMessage(AssistantTestFixture.EDITOR, (Integer) event.get("messageId")); + MessageParts parts = MessageParts.fromJson(message.getPartsJson()); + Map part = parts.list().stream().filter(p -> "presentation".equals(p.get("type"))).findFirst().orElseThrow(); + PresentationGoldenTask.assertPart(part); + assertThat(part).isEqualTo(com.kingsrook.qqq.backend.core.utils.JsonUtils.toObject(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(event.get("part")), Map.class)); + List rows = (List) block(part, "table").get("rows"); + List points = (List) block(part, "chart").get("points"); + List links = (List) block(part, "filterLinks").get("items"); + for(Integer i = 0; i < 2; i++) + { + assertThat(object(rows.get(i)).get("href")).isEqualTo(object(links.get(i)).get("href")); + assertThat(object(points.get(i)).get("href")).isEqualTo(object(rows.get(i)).get("href")); + } + assertThat(runner.runtime().getStore().listEvents(AssistantTestFixture.EDITOR, outcome.turnId(), 0, 1000)) + .anyMatch(e -> TurnEvent.PRESENTATION.equals(e.getEventType())); + assertThat(parts.text()).contains("81", "20", "\n|"); + assertThat(parts.stripRecordData()).isTrue(); + assertThat(parts.list()).noneMatch(p -> "presentation".equals(p.get("type"))); + assertThat(outcome.toolCalls()).anySatisfy(call -> + { + assertThat(call.getToolName()).isEqualTo(PresentResultsTool.NAME); + assertThat(call.getInputJson()).contains("presentation"); + assertThat(call.getResultJson()).contains("Do not repeat"); + }); + } + + + + /******************************************************************************* + ** The real UI metadata hides media from an analyst, as does presentation. + *******************************************************************************/ + @Test + void testUiDenial() throws Exception + { + String user = AssistantTestFixture.ANALYST; + UiApiResult metadata = runner.runtime().getUiApiGateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(user)); + assertThat(object(metadata.json().get("tables"))).doesNotContainKey("media"); + provider.thenToolUse(PresentResultsTool.NAME, input("summary", "The list is unavailable.", "recordLinks", List.of(input("label", "Image", "tableName", "media", "recordId", "1")))).thenText(""); + TurnRunner.Outcome outcome = runner.run(user, "Open an image"); + assertThat(outcome.toolCalls().get(0).getResultJson()).contains("table not visible through UI API: media"); + Map part = object(outcome.events(TurnEvent.PRESENTATION).get(0).get("part")); + assertThat((List) part.get("blocks")).isEmpty(); + assertThat(outcome.toolCalls().get(0).getInputJson()).doesNotContain("recordId"); + } + + + + /******************************************************************************* + ** Cache metadata once per turn and send the original session UUID. + *******************************************************************************/ + @Test + void testMetadataCache() throws Exception + { + List calls = new ArrayList<>(); + UiApiGateway gateway = new UiApiGateway() + { + /******************************************************************************* + ** Fixture metadata gateway. + *******************************************************************************/ + @Override + public boolean isInvocable(String method, String path) + { + return (true); + } + + + + /******************************************************************************* + ** Fixture metadata gateway. + *******************************************************************************/ + @Override + public UiApiResult invoke(UiApiCall call) + { + calls.add(call); + return (new UiApiResult(200, "{\"tables\":{\"media\":{\"readPermission\":true}}}")); + } + }; + UiApiToolbox.Context context = new UiApiToolbox.Context("editor-session", "UTC", gateway, PolicyService.ToolPolicy.defaults(), null); + PresentResultsTool tool = new PresentResultsTool(qInstance); + assertThat(tool.execute(context, "1", PresentationGoldenTask.input(81, 20)).getOutcome()).isEqualTo(AiToolOutcome.OK); + tool.execute(context, "2", PresentationGoldenTask.input(81, 20)); + assertThat(calls).hasSize(1).allMatch(call -> "editor-session".equals(call.getSessionUUID()) && "/metaData".equals(call.getPathTemplate())); + new PresentResultsTool(qInstance).execute(context, "3", PresentationGoldenTask.input(81, 20)); + assertThat(calls).hasSize(2); + } + + + + /******************************************************************************* + ** Unknown metadata is dropped independently without persisting its values. + *******************************************************************************/ + @Test + void testUnknownMetadata() + { + AiToolResult result = execute(input("summary", "Available results.", "metrics", List.of( + input("label", "Unknown table", "value", 9876, "tableName", "missing"), + input("label", "Unknown field", "value", 5432, "tableName", "media", "fieldName", "missing"), + input("label", "Known", "value", 101)))); + assertThat(result.getModelContent()).contains("Dropped metrics[0]", "unknown table", "Dropped metrics[1]", "unknown field"); + assertThat(result.getRender().toString()).doesNotContain("9876", "5432"); + assertThat((List) block(result.getRender(), "metrics").get("items")).hasSize(1); + } + + + + /******************************************************************************* + ** Markdown punctuation, external URLs and pipes remain literal text. + *******************************************************************************/ + @Test + void testUntrustedLabelsAndLinks() + { + AiToolResult result = execute(input("summary", "Available results.", "metrics", List.of(input("label", "[evil](https://evil.example) | ", "value", 1)), + "recordLinks", List.of(input("label", "Open", "tableName", "media", "recordId", "a/b?x#y")), + "actions", List.of(input("label", "Ask", "prompt", "Show images"), input("label", "Open media", "navigateTo", "media"), input("label", "Bad", "prompt", "Ask", "navigateTo", "media")))); + String markdown = (String) result.getRender().get("markdown"); + assertThat(markdown).doesNotContain("https://", "", "[evil]").contains("|", "[Open](/app/media/a%2Fb%3Fx%23y)", "- Ask: Show images", "[Open media](/app/media)"); + assertThat(result.getModelContent()).contains("exactly one"); + } + + + + /******************************************************************************* + ** Formatting follows metadata while raw values remain numeric. + *******************************************************************************/ + @Test + void testFormatting() + { + qInstance.getTable("media").addField(new QFieldMetaData("price", QFieldType.DECIMAL).withDisplayFormat("$%,.2f")); + AiToolResult result = execute(input("summary", "Amounts.", "metrics", List.of(input("label", "Amount", "value", 1234.5, "tableName", "media", "fieldName", "price"), input("label", "Count", "value", 12000)), + "table", input("columns", List.of(input("label", "Amount", "tableName", "media", "fieldName", "price")), "rows", List.of(List.of(1234.5))))); + assertThat(block(result.getRender(), "metrics").toString()).contains("$1,234.50", "12,000", "1234.5"); + assertThat(block(result.getRender(), "table").toString()).contains("$1,234.50"); + } + + + + /******************************************************************************* + ** Percentages cannot refer to labels, derived percentages or bad indexes. + *******************************************************************************/ + @Test + void testInvalidPercentColumn() + { + for(Object source : List.of(0, 3, 1.5)) + { + AiToolResult result = execute(input("summary", "Invalid share.", "table", input("columns", List.of(input("label", "Name"), input("label", "Share", "percentOfColumn", source)), "rows", List.of(List.of("JPEG", 0))))); + assertThat(result.getModelContent()).contains("Dropped table"); + assertThat((List) result.getRender().get("blocks")).isEmpty(); + } + } + + + + /******************************************************************************* + ** Zero totals are defined, and charts never exceed twelve points. + *******************************************************************************/ + @Test + void testChartLimitAndZeroTotal() + { + List> rows = new ArrayList<>(); + for(Integer i = 0; i < 13; i++) + { + rows.add(List.of("Item " + i, 0, 0)); + } + AiToolResult result = execute(input("summary", "Thirteen items.", "table", input("columns", List.of(input("label", "Item"), input("label", "Count"), input("label", "Share", "percentOfColumn", 1)), "rows", rows), + "chart", input("chartType", "bar", "labelColumn", 0, "valueColumn", 1))); + assertThat((List) block(result.getRender(), "chart").get("points")).hasSize(12); + assertThat((List) block(result.getRender(), "table").get("rows")).hasSize(13); + assertThat(result.getModelContent()).contains("truncated to 12"); + assertThat(block(result.getRender(), "table").toString()).contains("0.0%"); + } + + + + /******************************************************************************* + ** Operator validation does not coerce raw numeric/boolean filter values. + *******************************************************************************/ + @Test + void testCriteriaValidation() + { + AiToolResult result = execute(input("summary", "Links.", "filterLinks", List.of( + input("label", "Invalid", "tableName", "media", "criteria", List.of(input("fieldName", "width", "operator", "INVENTED", "values", List.of(10)))), + input("label", "Valid", "tableName", "media", "criteria", List.of(input("fieldName", "width", "operator", "EQUALS", "values", List.of(512))))))); + assertThat(result.getModelContent()).contains("invalid criteria operator"); + List links = (List) block(result.getRender(), "filterLinks").get("items"); + String decoded = java.net.URLDecoder.decode((String) object(links.get(0)).get("href"), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decoded).contains("\"values\":[512]"); + } + + + + /******************************************************************************* + ** Invalid summaries produce an error tool result; no presentation is emitted. + *******************************************************************************/ + @Test + void testInvalidSummaryAndAlwaysOffered() throws Exception + { + assertThat(runner.runtime().getContextBuilder().tools(new PolicyService.ToolPolicy(true, java.util.Set.of("navigate"), 20, true))) + .anyMatch(tool -> PresentResultsTool.NAME.equals(tool.getName()) && !Boolean.TRUE.equals(tool.getStrict())); + assertThat(execute(input("summary", "x".repeat(301))).getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT); + provider.thenToolUse(PresentResultsTool.NAME, Map.of()).thenText("Unable to present."); + TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "Present"); + assertThat(outcome.events(TurnEvent.PRESENTATION)).isEmpty(); + assertThat(outcome.toolCalls().get(0).getOutcome()).isEqualTo("INVALID_INPUT"); + } + + + + /******************************************************************************* + ** Use the fixture's real UI gateway for unit-level presentation checks. + *******************************************************************************/ + private AiToolResult execute(Map input) + { + return (new PresentResultsTool(qInstance).execute(new UiApiToolbox.Context(AssistantTestFixture.EDITOR, "UTC", runner.runtime().getUiApiGateway(), PolicyService.ToolPolicy.defaults(), null), "present", input)); + } +}