From 5364aa649e963a469b57a8623ea001a25891b174 Mon Sep 17 00:00:00 2001 From: Volodymyr Kravchuk Date: Mon, 4 May 2026 17:35:56 +0300 Subject: [PATCH] feat(apollo-vertex): add insights table tool --- .../app/api/insights/[...path]/route.ts | 86 +++++++++++++ apps/apollo-vertex/locales/en.json | 1 + apps/apollo-vertex/registry.json | 20 +++ .../tools/data-fabric/resolver-result.ts | 1 + .../ai-chat/tools/insights-table/index.ts | 4 + .../insights-table/insights-table-tool.tsx | 119 ++++++++++++++++++ .../registry/ai-chat/tools/insights/index.ts | 12 ++ .../registry/ai-chat/tools/insights/shared.ts | 72 +++++++++++ .../templates/ai-chat/AiChatAgentHubMode.tsx | 57 ++++++++- .../templates/ai-chat/AiChatInsightsGate.tsx | 45 +++++++ .../templates/ai-chat/use-insights-schema.ts | 53 ++++++++ 11 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 apps/apollo-vertex/app/api/insights/[...path]/route.ts create mode 100644 apps/apollo-vertex/registry/ai-chat/tools/insights-table/index.ts create mode 100644 apps/apollo-vertex/registry/ai-chat/tools/insights-table/insights-table-tool.tsx create mode 100644 apps/apollo-vertex/registry/ai-chat/tools/insights/index.ts create mode 100644 apps/apollo-vertex/registry/ai-chat/tools/insights/shared.ts create mode 100644 apps/apollo-vertex/templates/ai-chat/AiChatInsightsGate.tsx create mode 100644 apps/apollo-vertex/templates/ai-chat/use-insights-schema.ts diff --git a/apps/apollo-vertex/app/api/insights/[...path]/route.ts b/apps/apollo-vertex/app/api/insights/[...path]/route.ts new file mode 100644 index 000000000..3de3e265e --- /dev/null +++ b/apps/apollo-vertex/app/api/insights/[...path]/route.ts @@ -0,0 +1,86 @@ +import { type NextRequest, NextResponse } from "next/server"; + +const FORWARDED_HEADERS = ["authorization", "x-uipath-internal-tenantid"]; + +function getAuthHeader(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (!authHeader) { + return null; + } + return authHeader; +} + +function buildTargetUrl(path: string[], search: string) { + return `https://alpha.uipath.com/${path.join("/")}${search}`; +} + +function forwardHeaders(request: NextRequest, extra?: HeadersInit): Headers { + const headers = new Headers(extra); + for (const name of FORWARDED_HEADERS) { + const value = request.headers.get(name); + if (value) { + headers.set(name, value); + } + } + return headers; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const authHeader = getAuthHeader(request); + if (!authHeader) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { path } = await params; + const targetUrl = buildTargetUrl(path, request.nextUrl.search); + + const response = await fetch(targetUrl, { + method: "GET", + headers: forwardHeaders(request), + signal: request.signal, + }); + + const text = await response.text(); + return new NextResponse(text, { + status: response.status, + headers: { + "Content-Type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const authHeader = getAuthHeader(request); + if (!authHeader) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { path } = await params; + const targetUrl = buildTargetUrl(path, request.nextUrl.search); + const body = await request.text(); + + const response = await fetch(targetUrl, { + method: "POST", + headers: forwardHeaders(request, { + "Content-Type": request.headers.get("content-type") ?? "application/json", + }), + body, + signal: request.signal, + }); + + const text = await response.text(); + return new NextResponse(text, { + status: response.status, + headers: { + "Content-Type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} diff --git a/apps/apollo-vertex/locales/en.json b/apps/apollo-vertex/locales/en.json index f311150b7..19ba47e4f 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -60,6 +60,7 @@ "how_can_i_help_you": "How can I help you?", "import": "Import", "info": "Info", + "insights_table_no_valid_fields": "None of the requested field IDs exist in the \"{{source}}\" Insights schema: {{fields}}.", "japanese": "Japanese", "korean": "Korean", "language": "Language", diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index cc9862754..8dbd1b727 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -556,6 +556,26 @@ "path": "registry/ai-chat/tools/data-fabric-kpi/index.ts", "type": "registry:lib", "target": "components/ui/ai-chat/tools/data-fabric-kpi/index.ts" + }, + { + "path": "registry/ai-chat/tools/insights/shared.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/tools/insights/shared.ts" + }, + { + "path": "registry/ai-chat/tools/insights/index.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/tools/insights/index.ts" + }, + { + "path": "registry/ai-chat/tools/insights-table/insights-table-tool.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/tools/insights-table/insights-table-tool.tsx" + }, + { + "path": "registry/ai-chat/tools/insights-table/index.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/tools/insights-table/index.ts" } ] }, diff --git a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/resolver-result.ts b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/resolver-result.ts index d99512a49..4f52ca502 100644 --- a/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/resolver-result.ts +++ b/apps/apollo-vertex/registry/ai-chat/tools/data-fabric/resolver-result.ts @@ -51,6 +51,7 @@ export type ToolResolutionFailure = | { reason: "unknown_entity"; entity: string } | { reason: "table_no_valid_fields"; entity: string; fields: string } | { reason: "multi_line_too_few_metrics" } + | { reason: "insights_table_no_valid_fields"; source: string; fields: string } | ResolverFailure; export type ResolverResult = diff --git a/apps/apollo-vertex/registry/ai-chat/tools/insights-table/index.ts b/apps/apollo-vertex/registry/ai-chat/tools/insights-table/index.ts new file mode 100644 index 000000000..1326fff84 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/tools/insights-table/index.ts @@ -0,0 +1,4 @@ +export { + createInsightsTableTool, + insightsTableClient, +} from "./insights-table-tool"; diff --git a/apps/apollo-vertex/registry/ai-chat/tools/insights-table/insights-table-tool.tsx b/apps/apollo-vertex/registry/ai-chat/tools/insights-table/insights-table-tool.tsx new file mode 100644 index 000000000..e1501d81c --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/tools/insights-table/insights-table-tool.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { toolDefinition } from "@tanstack/ai"; +import { standaloneAdapter } from "@uipath/apollo-dashboarding"; +import { DateTime } from "luxon"; +import { z } from "zod"; +import { TableChartCard } from "../../charts/table-chart-card"; +import { ToolResolutionError } from "../../charts/tool-resolution-error"; +import { filterSchema, resolveFilters } from "../data-fabric/shared"; +import { + buildInsightsTableDataModel, + collectInsightsFieldIds, + collectInsightsTableIds, + generateInsightsSchemaDocs, + type InsightsToolContext, +} from "../insights/shared"; + +const insightsTableInput = z.object({ + dimensions: z + .array(z.string()) + .min(1) + .describe( + "Qualified field IDs (Table.Field) to display as table columns. Use IDs exactly as listed in the Schema Reference.", + ), + filters: z + .array(filterSchema) + .optional() + .describe("Optional filters to narrow down results."), +}); + +const insightsTableDef = toolDefinition({ + name: "insights_table", + description: + "List records from the Insights tables (PROCESSRUNS, ELEMENTRUNS, INCIDENTS) for the vertical-solution source. Use this tool whenever the user's request mentions process runs, element runs, incidents, or the table names PROCESSRUNS / ELEMENTRUNS / INCIDENTS — regardless of how the user phrases the verb. 'List', 'show', 'how many', 'distribution of', 'over time', 'trend of', 'by status' all map to THIS tool when the underlying data is Insights. The data source decides, not the verb. Field names must be qualified Table.Field IDs copied EXACTLY from the schema (e.g. PROCESSRUNS.STATUS, ELEMENTRUNS.ELEMENTNAME). Supports optional filters (list, search, range including datetime).", + inputSchema: insightsTableInput, + outputSchema: insightsTableInput, + metadata: { skipFollowUp: true }, +}); + +export const insightsTableClient = insightsTableDef.client((input) => input); + +type InsightsTableInput = z.infer; + +export function createInsightsTableTool(context: InsightsToolContext) { + const today = DateTime.now().toISODate(); + const fieldIds = collectInsightsFieldIds(context.schema); + const tableIds = collectInsightsTableIds(context.schema); + const dataModel = buildInsightsTableDataModel( + context.sourceType, + context.schema, + ); + + const toolPrompt = `You have an "insights_table" tool. It is the ONLY tool that operates on the Insights "${context.sourceType}" source. + +PRECONDITION — call this tool whenever the user's request references identifiers from the Schema Reference below (the tables ${tableIds.join(", ")}, any Table.Field under them, or any natural-language phrasing of those table names). Use it regardless of the action verb — "list", "show", "how many", "distribution of", "over time", "trend of", "by status" all map to this tool when the data is Insights. Other registered chart tools operate on a different source and must NOT be called for these tables. + +## Schema Reference (source: ${context.sourceType}) +${generateInsightsSchemaDocs(context.schema)} + +## How to call +- Pass field IDs the user wants as columns in "dimensions". Field IDs are qualified "Table.Field" — use them EXACTLY as listed in the Schema Reference (case-sensitive, including the table prefix). Unqualified or paraphrased names are rejected. +- If the user does not specify fields, pick a reasonable default set (3-8 fields) from the Schema Reference. + +## Filters +You can optionally pass filters to narrow results. Filter "field" must also be a qualified "Table.Field" ID from the Schema Reference. Available filter types: +- **list**: match specific values. Use valueType matching the field type (string/number/boolean). Set invert=true to exclude. +- **search**: text pattern matching on string fields. searchFilterType: "default" (contains), "startsWith", "endsWith". +- **range** (numeric): use valueType="number" with min/max numbers. +- **range** (datetime): use valueType="datetime" with min/max as ISO 8601 strings (e.g. "2026-01-01" or "2026-01-01T00:00:00Z"). Today is ${today} — use it to resolve relative phrases like "last 30 days" or "this year" into absolute ISO dates before passing them. +Only add filters when the user asks to filter, search, or narrow results.`; + + function renderTable(output: InsightsTableInput, id: string) { + const { dimensions, filters } = output; + + const validFieldIds = new Set(fieldIds); + const validDimensions = dimensions.filter((d) => validFieldIds.has(d)); + + if (validDimensions.length === 0) { + return ( + + ); + } + + const normalizedFilters = resolveFilters(filters, { + mode: "single", + validFields: fieldIds, + }); + + const configuration = { + id, + name: context.sourceType, + type: "table" as const, + dimensions: validDimensions, + filters: normalizedFilters, + }; + + const adapter = standaloneAdapter({ + baseUrl: context.insightsBaseUrl, + accessToken: context.accessToken, + sourceType: context.sourceType, + }); + + return ( + + ); + } + + return { toolPrompt, renderTable }; +} diff --git a/apps/apollo-vertex/registry/ai-chat/tools/insights/index.ts b/apps/apollo-vertex/registry/ai-chat/tools/insights/index.ts new file mode 100644 index 000000000..726241ac8 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/tools/insights/index.ts @@ -0,0 +1,12 @@ +export { createInsightsTableTool } from "../insights-table"; +export { + buildInsightsTableDataModel, + collectInsightsFieldIds, + generateInsightsSchemaDocs, + mapInsightsFieldKind, + type InsightsField, + type InsightsFieldType, + type InsightsSchema, + type InsightsTable, + type InsightsToolContext, +} from "./shared"; diff --git a/apps/apollo-vertex/registry/ai-chat/tools/insights/shared.ts b/apps/apollo-vertex/registry/ai-chat/tools/insights/shared.ts new file mode 100644 index 000000000..c5bbd23ef --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/tools/insights/shared.ts @@ -0,0 +1,72 @@ +import type { + ColumnDataType, + InfoResponse, + TableDataModel, +} from "@uipath/apollo-dashboarding"; + +export type InsightsSchema = InfoResponse; +export type InsightsTable = InfoResponse["data"][number]; +export type InsightsField = InsightsTable["fields"][number]; +export type InsightsFieldType = "numeric" | "string" | "boolean" | "datetime"; + +export interface InsightsToolContext { + sourceType: string; + accessToken: string; + insightsBaseUrl: string; + schema: InsightsSchema; +} + +export function mapInsightsFieldKind( + columnDataType: ColumnDataType, +): InsightsFieldType { + switch (columnDataType) { + case "NUMBER": + case "FLOAT": + return "numeric"; + case "TIMESTAMP_NTZ": + case "DATE": + return "datetime"; + case "BOOLEAN": + return "boolean"; + case "TEXT": + case "LEGACYINGESTION": + case "UNKNOWN": + return "string"; + } +} + +export function buildInsightsTableDataModel( + sourceType: string, + schema: InsightsSchema, +): TableDataModel { + const fields = schema.data.flatMap((table) => + table.fields.map((field) => ({ + id: field.id, + display: field.column, + type: mapInsightsFieldKind(field.columnDataType), + })), + ); + return { id: sourceType, fields }; +} + +export function collectInsightsFieldIds(schema: InsightsSchema): string[] { + return schema.data.flatMap((table) => table.fields.map((field) => field.id)); +} + +export function collectInsightsTableIds(schema: InsightsSchema): string[] { + return schema.data.map((table) => table.id); +} + +export function generateInsightsSchemaDocs(schema: InsightsSchema): string { + return schema.data + .map((table) => { + const fields = table.fields + .map( + (field) => + `${field.id} (${mapInsightsFieldKind(field.columnDataType)})`, + ) + .join(", "); + return `${table.id}: ${fields}`; + }) + .join("\n\n"); +} diff --git a/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx b/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx index 926ba8d4b..d7e1d9f40 100644 --- a/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx +++ b/apps/apollo-vertex/templates/ai-chat/AiChatAgentHubMode.tsx @@ -36,10 +36,18 @@ import { createDataFabricTableTool, dataFabricTableClient, } from "@/registry/ai-chat/tools/data-fabric-table"; +import type { InsightsSchema } from "@/registry/ai-chat/tools/insights/shared"; +import { + createInsightsTableTool, + insightsTableClient, +} from "@/registry/ai-chat/tools/insights-table"; import { DataFabricGate } from "./AiChatDataFabricGate"; +import { InsightsGate } from "./AiChatInsightsGate"; import type { OrgTenantInfo } from "./AiChatLoginGate"; import { createUiPathSdk } from "./ai-chat-example-utils"; +const INSIGHTS_SOURCE_TYPE = "ao"; + interface AgentHubChatProps { accessToken: string; orgTenant: OrgTenantInfo; @@ -49,12 +57,16 @@ interface AgentHubChatInnerProps { accessToken: string; orgTenant: OrgTenantInfo; entities: Record; + insightsSchema: InsightsSchema; + insightsBaseUrl: string; } function AgentHubChatInner({ accessToken, orgTenant, entities, + insightsSchema, + insightsBaseUrl, }: AgentHubChatInnerProps) { const { t } = useTranslation(); const dataFabricBaseUrl = `/api/datafabric/${orgTenant.orgName}/${orgTenant.tenantName}/datafabric_/api`; @@ -89,6 +101,13 @@ function AgentHubChatInner({ dataFabricBaseUrl, }); + const insightsTableTool = createInsightsTableTool({ + sourceType: INSIGHTS_SOURCE_TYPE, + accessToken, + insightsBaseUrl, + schema: insightsSchema, + }); + const tools = clientTools( presentChoicesClient, dataFabricTableClient, @@ -96,16 +115,22 @@ function AgentHubChatInner({ dataFabricLineClient, dataFabricMultiLineClient, dataFabricKpiClient, + insightsTableClient, ); const chartToolSteering = [ - "When the user asks about Data Fabric data, pick the chart tool that best fits the request:", + "When the user asks for data, pick the tool that matches the source:", + `- "insights_*" tools — when the user asks about Insights / "${INSIGHTS_SOURCE_TYPE}" / vertical-solution data, names a Table.Field listed in the Insights Schema Reference, or names a table from the Insights Schema Reference (e.g. process runs, element runs, incidents).`, + '- "data_fabric_*" tools — when the user asks about Data Fabric entities listed in the Entity Reference.', + "Within the Data Fabric source, pick the most specific chart tool:", '- "data_fabric_table" — list/show records, view fields side by side.', '- "data_fabric_kpi" — single-number / scalar questions ("how many orders", "total revenue", "average invoice amount", "max order total"). No dimension or breakdown.', '- "data_fabric_line" — single-metric trend / time-series questions ("orders over time", "revenue by month", "growth across quarters").', '- "data_fabric_multi_line" — compare EXACTLY TWO metrics on a shared time axis ("orders count and revenue over time", "min vs max price by month"). The chart only supports two Y axes; for 3+ metrics render multiple charts.', '- "data_fabric_distribution" — histogram-style requests ("distribution of X", "histogram of X", numeric value-range binning).', + 'For the Insights source, only "insights_table" is currently available — use it for ANY Insights data request, including time-series ("last 30 days", "over time") and aggregate ("count by status") phrasings. A time filter on a datetime field is enough to express "last 30 days" / "since X".', "If two tools could both answer, prefer the more specific one: multi-line beats line when the user names 2+ metrics; line beats distribution for explicit time-series phrasing; kpi beats line/distribution when the user wants a single value with no breakdown.", + 'CRITICAL: when calling insights_table, dimension and filter "field" values MUST be qualified Table.Field IDs copied EXACTLY from the Insights Schema Reference (case-sensitive, including the table prefix and the trailing column part — e.g. "ELEMENTRUNS.ELEMENTNAME", not "ElementName" or "ELEMENTRUNS_ELEMENTNAME"). Unqualified or paraphrased names are silently dropped.', ].join("\n"); const systemPrompt = [ @@ -117,6 +142,7 @@ function AgentHubChatInner({ lineTool.toolPrompt, multiLineTool.toolPrompt, kpiTool.toolPrompt, + insightsTableTool.toolPrompt, ].join("\n\n"); const connection = createAgentHubConnection({ @@ -207,6 +233,14 @@ function AgentHubChatInner({ ); } + if (part.name === "insights_table") { + return ( + + {insightsTableTool.renderTable(part.output, part.id)} + + ); + } + if (part.name === "presentChoices") { return (
@@ -231,14 +265,27 @@ export function AgentHubChat({ accessToken, orgTenant }: AgentHubChatProps) { const sdk = createUiPathSdk(accessToken, orgTenant); const entitiesService = new Entities(sdk); + const insightsBaseUrl = `/api/insights/${orgTenant.orgName}/${orgTenant.tenantName}/insightsrtm_/api/v1`; + return ( {({ entities }) => ( - + tenantId={orgTenant.tenantId} + sourceType={INSIGHTS_SOURCE_TYPE} + > + {({ schema }) => ( + + )} + )} ); diff --git a/apps/apollo-vertex/templates/ai-chat/AiChatInsightsGate.tsx b/apps/apollo-vertex/templates/ai-chat/AiChatInsightsGate.tsx new file mode 100644 index 000000000..4cdb154ac --- /dev/null +++ b/apps/apollo-vertex/templates/ai-chat/AiChatInsightsGate.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { InsightsSchema } from "@/registry/ai-chat/tools/insights/shared"; +import { useInsightsSchema } from "./use-insights-schema"; + +interface InsightsGateProps { + baseUrl: string; + accessToken: string; + tenantId: string; + sourceType: string; + children: (props: { schema: InsightsSchema }) => ReactNode; +} + +export function InsightsGate({ + baseUrl, + accessToken, + tenantId, + sourceType, + children, +}: InsightsGateProps) { + const { + data: schema, + isLoading, + isError, + } = useInsightsSchema({ baseUrl, accessToken, tenantId, sourceType }); + + if (isLoading) { + return ( +
+ Loading Insights schema... +
+ ); + } + + if (isError || !schema) { + return ( +
+ Failed to load Insights schema for "{sourceType}". +
+ ); + } + + return <>{children({ schema })}; +} diff --git a/apps/apollo-vertex/templates/ai-chat/use-insights-schema.ts b/apps/apollo-vertex/templates/ai-chat/use-insights-schema.ts new file mode 100644 index 000000000..e5da477d1 --- /dev/null +++ b/apps/apollo-vertex/templates/ai-chat/use-insights-schema.ts @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; +import type { InfoResponse } from "@uipath/apollo-dashboarding"; + +interface UseInsightsSchemaOptions { + baseUrl: string; + accessToken: string; + tenantId: string; + sourceType: string; + enabled?: boolean; +} + +async function fetchSchema({ + baseUrl, + accessToken, + tenantId, + sourceType, +}: Omit): Promise { + const response = await fetch( + `${baseUrl}/standalone-query/${encodeURIComponent(sourceType)}/info`, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "X-UiPath-Internal-TenantId": tenantId, + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to load Insights schema for source "${sourceType}" (HTTP ${response.status})`, + ); + } + + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- shape validated upstream by the standalone-query/info contract + return (await response.json()) as InfoResponse; +} + +export function useInsightsSchema({ + baseUrl, + accessToken, + tenantId, + sourceType, + enabled = true, +}: UseInsightsSchemaOptions) { + return useQuery({ + queryKey: ["insights-schema", tenantId, sourceType], + queryFn: () => + fetchSchema({ baseUrl, accessToken, tenantId, sourceType }), + enabled, + staleTime: 5 * 60 * 1000, + }); +}