feat: answer presentation: present_results, presentation parts, default presentation skill #32

Merged
KofTwentyTwo merged 2 commits from feature/answer-presentation into develop 2026-10-14 00:59:58 +00:00
20 changed files with 1643 additions and 16 deletions

No files matched your search

+9
View File
@@ -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.
+15 -1
View File
@@ -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.
+33
View File
@@ -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.
+56
View File
@@ -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": "<server-built GFM fallback of the whole presentation, for clients that do not render blocks (CLI/TUI)>",
"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/<tableName>?filter=<URL-encoded JSON {"criteria":[{"fieldName":..,"operator":..,"values":[..]}],"booleanOperator":"AND"}>`, 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": <id>, "part": <the part above> }`; 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.
@@ -108,6 +108,24 @@ class AnthropicRequestContractTest
/*******************************************************************************
** Presentation remains inside the actual serialized strict request budget.
*******************************************************************************/
@Test
void testPresentationStrictOptIn() throws Exception
{
Map<String, Object> 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.
@@ -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()));
}
@@ -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<String, Object> 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<String, Object> 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());
}
@@ -40,7 +40,8 @@ public class SystemPrompt
10. An administrator may give you a name, a personality and skills (in <assistant_profile> and <skill> 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 = """
@@ -27,6 +27,7 @@ import java.util.Map;
public class ToolLabels
{
private static final Map<String, String[]> 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" }),
@@ -42,7 +42,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
*******************************************************************************/
public class TurnClassification
{
private static final Set<String> METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help");
private static final Set<String> METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help", "present_results");
private final ClassificationSource source;
private final Set<String> 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)
{
@@ -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<AiToolOutcome> 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<String, Object> 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<String, Object> 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())
@@ -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";
@@ -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<String, Object> schema = JsonUtils.toObject(new String(stream.readAllBytes(), StandardCharsets.UTF_8), new TypeReference<Map<String, Object>>() {});
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<String, Object> 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<String, Object> 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<String> notes = new ArrayList<>();
List<Map<String, Object>> blocks = new ArrayList<>();
addItems(blocks, "metrics", input.get("metrics"), notes, item -> metric(context, item));
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> metric(UiApiToolbox.Context context, Map<String, Object> 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<String, Object> table(UiApiToolbox.Context context, Map<String, Object> 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<QFieldMetaData> fields = new ArrayList<>();
List<Map<String, Object>> outputColumns = new ArrayList<>();
List<BigDecimal> totals = new ArrayList<>();
for(Integer c = 0; c < columns.size(); c++)
{
Map<String, Object> 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<String, Object> 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<Map<String, Object>> outputRows = new ArrayList<>();
for(Integer r = 0; r < rows.size(); r++)
{
Map<String, Object> cells = new LinkedHashMap<>();
Map<String, Object> 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<String, Object> row = map("cells", cells, "display", displays);
if(hasFilter)
{
Object key = rowKeys.get(r);
scalar(key);
Map<String, Object> 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<String, Object> chart(Map<String, Object> input, Map<String, Object> table, List<String> 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<Map<String, Object>> points = new ArrayList<>();
List<?> rows = list(table.get("rows"));
for(Object entry : rows)
{
Map<String, Object> row = object(entry);
Object value = object(row.get("cells")).get("c" + valueColumn);
number(value);
if(points.size() < 12)
{
Map<String, Object> 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<String, Object> filterLink(UiApiToolbox.Context context, Map<String, Object> 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<Map<String, Object>> output = new ArrayList<>();
for(Object entry : criteria)
{
Map<String, Object> 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<String, Object> recordLink(UiApiToolbox.Context context, Map<String, Object> 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<String, Object> action(UiApiToolbox.Context context, Map<String, Object> 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<Map<String, Object>> blocks, String kind, Object input, List<String> notes, Function<Map<String, Object>, Map<String, Object>> build)
{
if(input == null)
{
return;
}
List<?> entries = list(input);
require(entries.size() <= 100, "too many " + kind);
List<Map<String, Object>> 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<Map<String, Object>> blocks)
{
StringBuilder markdown = new StringBuilder(escape(summary)).append("\n");
for(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> object(Object value)
{
require(value instanceof Map<?, ?>, "object required");
return ((Map<String, Object>) 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<String, Object> map(Object... pairs)
{
Map<String, Object> 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));
}
}
}
@@ -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."
}
@@ -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("<qqq_data>{\"results\":[{\"table\":\"media\"}]}</qqq_data>"));
assertThat(page.getClassification()).isEqualTo(DataClassification.PII);
page.observe(ModelPart.toolUse("3", "search_records", Map.of()), new AiToolResult());
@@ -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<QRecord> 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<QRecord> stats = new ArrayList<>();
for(int d = 1; d <= 5; d++)
@@ -180,6 +180,7 @@ public class GoldenTasks
},
o -> live(o, "search_records", "/app/blog_post/1")));
tasks.add(PresentationGoldenTask.task());
return (tasks);
}
@@ -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<Object[]> 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<ModelPart> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Object> mimes = new ArrayList<>();
for(Object entry : links)
{
String href = (String) object(entry).get("href");
assertThat(href).startsWith("/app/media?filter=");
try
{
Map<String, Object> filter = JsonUtils.toObject(URLDecoder.decode(href.substring(href.indexOf('=') + 1), StandardCharsets.UTF_8), new TypeReference<Map<String, Object>>() {});
assertThat(filter).containsEntry("booleanOperator", "AND");
List<?> criteria = (List<?>) filter.get("criteria");
assertThat(criteria).hasSize(1);
Map<String, Object> 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<String, Object> block(Map<String, Object> 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<String, Object> object(Object value)
{
return ((Map<String, Object>) value);
}
}
@@ -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()));
}
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<UiApiCall> 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) | <img>", "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://", "<img>", "[evil]").contains("&#124;", "[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<List<Object>> 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<String, Object> input)
{
return (new PresentResultsTool(qInstance).execute(new UiApiToolbox.Context(AssistantTestFixture.EDITOR, "UTC", runner.runtime().getUiApiGateway(), PolicyService.ToolPolicy.defaults(), null), "present", input));
}
}