From ecd7aa072f3410ccad76efa1a6f9bb05d0e2bc8f Mon Sep 17 00:00:00 2001 From: intraQ <251956840+intraq-dev-ai@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:41:36 +1000 Subject: [PATCH 1/2] Prepare public repository positioning --- COMMERCIAL.md | 3 + LICENSE.md | 73 +++- README.md | 156 ++++++--- .../components/DashboardAnalyzerPanel.vue | 328 ++++++++++++++---- examples/README.md | 26 ++ examples/ecommerce/README.md | 36 ++ examples/energy-retail/README.md | 36 ++ examples/hospitality/README.md | 41 +++ examples/saas/README.md | 35 ++ 9 files changed, 612 insertions(+), 122 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/ecommerce/README.md create mode 100644 examples/energy-retail/README.md create mode 100644 examples/hospitality/README.md create mode 100644 examples/saas/README.md diff --git a/COMMERCIAL.md b/COMMERCIAL.md index 092a5b8..ea94897 100644 --- a/COMMERCIAL.md +++ b/COMMERCIAL.md @@ -9,6 +9,9 @@ implementation/operations, or use in a competing commercial analytics, BI, dashboard, SQL-assistant, or AI-reporting service requires an IntraQ commercial agreement. +For commercial licensing, contact the maintainers through the commercial channel +published for the project or through `hello@intraq.dev`. + ## Included - Dashboards, Dashboard Builder, Analyzer, SQL Editor, data sources, data diff --git a/LICENSE.md b/LICENSE.md index eba82fa..b6d65bb 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -13,10 +13,41 @@ fork, modify, and self-host IntraQ for permitted uses while reserving commercial managed-service, resale, white-label, OEM, and competing-service rights for IntraQ. +## Acceptance + +By using, copying, modifying, distributing, making available, or running this +software, you agree to the terms of this license. + +If you do not agree to these terms, you may not use, copy, modify, distribute, +make available, or run this software. + +## Definitions + +"Licensor" means the copyright holder or entity offering this software under +this license. + +"Software" means the IntraQ source code and related files made available in this +repository, except third-party components or files that expressly state a +different license. + +"You" means the individual or legal entity exercising permissions under this +license. + +"Your company" means any legal entity, sole proprietorship, or organization that +you work for, plus any entity that controls, is controlled by, or is under common +control with that entity. + +"Competing service" means a commercial product or service whose primary purpose +is to provide analytics, business intelligence, dashboards, AI reporting, SQL +assistant, data assistant, embedded analytics, or managed analytics +functionality to third parties. + ## Grant -Subject to this license, you may use, copy, modify, fork, and run this software -for: +Subject to this license, the Licensor grants you a non-exclusive, royalty-free, +worldwide, non-sublicensable, non-transferable license to use, copy, modify, +fork, run, distribute, make available, and prepare derivative works of the +Software only for: - internal business use; - personal use; @@ -46,6 +77,14 @@ Without a separate written commercial agreement with IntraQ, you may not: - use IntraQ names, logos, or trademarks except to identify the original project and preserve notices. +## Notices + +You must ensure that anyone who receives a copy of any part of the Software from +you also receives this license. + +You may not alter, remove, or obscure any copyright, trademark, license, or +attribution notices in the Software. + ## Contributions And Modifications You may create and use modifications under this license. If you distribute @@ -55,12 +94,42 @@ has been modified. Unless a separate written contributor agreement says otherwise, contributions submitted to this repository are provided under this same license. +## Patents + +The Licensor grants you a license under any patent claims the Licensor can +license, or later becomes able to license, to make, have made, use, sell, offer +for sale, import, and have imported the Software, in each case only as permitted +by this license. + +This patent license does not cover patent claims that you or your company cause +to be infringed by modifications, additions, or combinations you make to the +Software. + +If you or your company make a written claim that the Software infringes or +contributes to infringement of any patent, your patent license under this +license ends immediately. + ## Third-Party Software This repository may depend on third-party software with separate licenses. Those licenses apply to the relevant third-party packages and are not changed by this license. +## Termination + +If you use the Software in violation of this license, that use is not licensed +and your license automatically terminates. + +If the Licensor notifies you of a violation and you stop all violation within 30 +days after receiving notice, your license is reinstated retroactively. If you +violate this license again after reinstatement, your license terminates +automatically and permanently. + +## No Other Rights + +This license does not grant any rights other than those expressly stated in this +license. All rights not expressly granted are reserved by the Licensor. + ## No Warranty The software is provided "as is", without warranty of any kind, express or diff --git a/README.md b/README.md index 25b8b2e..9562581 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,99 @@ # intraQ -[Website](https://intraq.dev) +[![CI](https://github.com/intraq-dev-ai/intraq/actions/workflows/ci.yml/badge.svg)](https://github.com/intraq-dev-ai/intraq/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/license-IntraQ%20Sustainable%20Use-blue)](LICENSE.md) +[![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)](docker-compose.yml) +[![Node](https://img.shields.io/badge/node-24.x-339933?logo=node.js&logoColor=white)](.nvmrc) -intraQ is a source-available AI reporting and dashboard platform for -self-hosted operational analytics. +[Website](https://intraq.dev) · [Docs](docs/DEMO_GUIDE.md) · [Quickstart](QUICKSTART.md) · [Configuration](docs/CONFIGURATION.md) · [Contributing](CONTRIBUTING.md) -It includes local dashboards, Analyzer, SQL models, MCP, data-source management, -and dashboard-builder workflows. +**intraQ is a source-available operational BI platform for SQL-backed data. Ask questions in plain English, generate trusted SQL, and build live dashboards without Power BI, spreadsheet exports, or manual reporting queues.** -AI is grounded in local metadata, data dictionary entries, SQL models, -relationships, dashboard context, and safe result summaries. This source tree -intentionally excludes the paid AI Studio, proprietary domain intelligence, -control plane, paid release tooling, private operational docs, generated -artifacts, credentials, and private operational material. +It combines AI analytics, natural language SQL, text-to-SQL workflows, a dashboard builder, embedded analytics foundations, MCP tools, and a semantic layer built from local metadata, data dictionary entries, SQL models, relationships, dashboard context, and safe result summaries. ![intraQ dashboard builder with AI sidebar](docs/assets/demo/00-readme-hero-ai-sidebar.png) -## License +## Why teams use intraQ + +- **AI business intelligence** — ask operational questions in plain English. +- **Trusted SQL generation** — inspect SQL and evidence before publishing results. +- **Dashboard builder** — turn answers into reusable dashboards. +- **Self-hosted BI** — run with Docker Compose against your own database. +- **Semantic layer** — define tables, fields, joins, metrics, and business meaning. +- **Embedded analytics foundation** — use intraQ as a reporting layer for product and internal workflows. +- **Provider-flexible AI** — configure Codex OAuth, OpenAI, or Gemini from the admin UI. + +## Demo flow + +```text +Ask a business question + ↓ +AI plans against metadata and SQL models + ↓ +Trusted SQL is generated and executed + ↓ +The answer returns with evidence + ↓ +Save the result as a live dashboard component +``` -intraQ is source-available under the IntraQ Sustainable Use License. +Use the seeded demo to try questions such as: + +- `How is revenue trending by day?` +- `Which channel has the highest revenue?` +- `Compare revenue and gross margin by category.` +- `Which location has the highest average order value?` +- `Create a dashboard chart for revenue by channel.` + +See the full [Demo guide](docs/DEMO_GUIDE.md). + +## Architecture + +```text +User question + → AI Analyzer + → Metadata + semantic model + saved SQL models + → SQL planning and validation + → Connected operational database + → Result summary + evidence + → Dashboard Builder + → Saved live dashboard +``` + +The public source includes local dashboards, Analyzer, SQL models, MCP, data-source management, and dashboard-builder workflows. + +It intentionally excludes paid AI Studio, proprietary domain intelligence, control plane, paid release tooling, private operational docs, generated artifacts, credentials, and private operational material. + +## Use cases and knowledge bases -You may use, fork, modify, and run intraQ for internal business, personal, -educational, evaluation, and non-commercial purposes. +intraQ is built for operational reporting where users need more than static charts: -Paid hosting, managed service use, white-label resale, OEM redistribution, paid -third-party support/operations, or use in a competing commercial analytics, BI, -dashboard, SQL-assistant, or AI-reporting service requires a commercial -agreement with IntraQ. +- **Hospitality analytics** — revenue health, covers, product mix, wastage signals, outlet performance, PMS and POS reporting. +- **Energy retail reporting** — accounts, billing cycles, arrears, credit exposure, payments, exceptions, and customer risk. +- **SaaS embedded analytics** — customer-facing dashboards over product data. +- **Ecommerce analytics** — revenue, orders, products, channels, margins, and customer behavior. -See [LICENSE.md](LICENSE.md) and [COMMERCIAL.md](COMMERCIAL.md). +Explore practical question sets in [`examples/`](examples/README.md). -## Public Source Scope +## Comparisons -This repository is the generalized public source package. It excludes -non-public material, private operational docs, paid AI Studio, proprietary -domain packs, feedback learning loops, eval pipelines, multi-tenant governance, -billing, and managed-service code. +intraQ is not trying to replace every enterprise reporting suite. It is focused on operational BI workflows where teams want AI-assisted SQL, local control, and dashboards from governed data models. -See [docs/PUBLIC_SOURCE_SCOPE.md](docs/PUBLIC_SOURCE_SCOPE.md), [CONTRIBUTING.md](CONTRIBUTING.md), -and [SUPPORT.md](SUPPORT.md). +### intraQ vs Power BI -## Requirements +Power BI is a broad reporting suite. intraQ focuses on natural language SQL, evidence-backed AI answers, and turning operational questions into live dashboard components from SQL-backed models. -- Node.js 24 LTS. This repo pins `24.16.0` in `.nvmrc` and `.node-version`. -- npm 11.12+. -- PostgreSQL 14+. +### intraQ vs Metabase + +Metabase is strong for self-service querying and dashboards. intraQ adds an AI Analyzer and dashboard-builder workflow designed around plain-English questions, trusted SQL generation, and evidence review. + +### intraQ vs Looker + +Looker centers on governed semantic modeling at enterprise scale. intraQ is lighter to run locally and focuses on operational teams that want AI-assisted reporting over existing SQL data. + +### intraQ vs Sigma Computing + +Sigma provides spreadsheet-style cloud analytics. intraQ focuses on self-hosted or controlled deployments, natural language SQL, and operational dashboards grounded in local metadata and models. ## Quickstart @@ -57,6 +106,12 @@ docker compose up --build Then open `http://localhost:4100`. +Seeded local login: + +| Email | Password | +|---|---| +| `admin@local.intraq.test` | `intraq-demo` | + For local development without Docker: ```bash @@ -80,11 +135,9 @@ npm run db:seed npm run dev ``` -Seeded local login: +More setup detail is in [QUICKSTART.md](QUICKSTART.md). Environment variables are documented in [docs/CONFIGURATION.md](docs/CONFIGURATION.md). -| Email | Password | -|---|---| -| `admin@local.intraq.test` | `intraq-demo` | +AI provider setup for Codex OAuth, OpenAI, and Gemini is documented in [docs/AI_PROVIDER_SETUP.md](docs/AI_PROVIDER_SETUP.md). For Codex OAuth browser login, set `OPENAI_OAUTH_CLIENT_ID` in the API environment; otherwise the admin page cannot start the Codex login flow. ## Development @@ -93,24 +146,29 @@ npm test npm run build ``` -Copy `.env.example` to `.env` for local development. Do not commit local env -files, database passwords, provider keys, client data, or private operational -material. - -More setup detail is in [QUICKSTART.md](QUICKSTART.md). Environment variables -are documented in [docs/CONFIGURATION.md](docs/CONFIGURATION.md). - -AI provider setup for Codex OAuth, OpenAI, and Gemini is documented in -[docs/AI_PROVIDER_SETUP.md](docs/AI_PROVIDER_SETUP.md). -For Codex OAuth browser login, set `OPENAI_OAUTH_CLIENT_ID` in the API -environment; otherwise the admin page cannot start the Codex login flow. - -The seeded product demo and suggested walkthrough videos are documented in -[docs/DEMO_GUIDE.md](docs/DEMO_GUIDE.md). +Copy `.env.example` to `.env` for local development. Do not commit local env files, database passwords, provider keys, client data, or private operational material. Focused workflow docs: -- [MCP tools](docs/MCP.md) - [AI Analyzer](docs/AI_ANALYZER.md) - [Dashboard Builder](docs/DASHBOARD_BUILDER.md) - [SQL Editor](docs/SQL_EDITOR.md) +- [MCP tools](docs/MCP.md) +- [AI provider setup](docs/AI_PROVIDER_SETUP.md) +- [Publication checklist](docs/PUBLICATION_CHECKLIST.md) + +## License and public source scope + +intraQ is source-available under the IntraQ Sustainable Use License. + +You may use, fork, modify, and run intraQ for internal business, personal, educational, evaluation, and non-commercial purposes. + +Paid hosting, managed service use, white-label resale, OEM redistribution, paid third-party support/operations, or use in a competing commercial analytics, BI, dashboard, SQL-assistant, or AI-reporting service requires a commercial agreement with IntraQ. + +See [LICENSE.md](LICENSE.md), [COMMERCIAL.md](COMMERCIAL.md), [docs/PUBLIC_SOURCE_SCOPE.md](docs/PUBLIC_SOURCE_SCOPE.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SUPPORT.md](SUPPORT.md). + +## Suggested GitHub topics + +Recommended repository topics: + +`ai-analytics`, `ai-bi`, `llm`, `semantic-model`, `business-intelligence`, `analytics-platform`, `ai-dashboard`, `text-to-sql`, `dashboard-builder`, `self-hosted`, `postgres`, `sql`, `embedded-analytics`, `reporting`, `natural-language-sql`, `operational-analytics` diff --git a/apps/web/src/modules/dashboard-builder/components/DashboardAnalyzerPanel.vue b/apps/web/src/modules/dashboard-builder/components/DashboardAnalyzerPanel.vue index 3ccf1a0..af95bcc 100644 --- a/apps/web/src/modules/dashboard-builder/components/DashboardAnalyzerPanel.vue +++ b/apps/web/src/modules/dashboard-builder/components/DashboardAnalyzerPanel.vue @@ -2,40 +2,34 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { appendMessage, - createAnalyzerPlan, - orchestrateAnalyzer, - resolveAnalyzerFollowup + askAnalyzer } from '../../analyzer/api'; import { sanitizeAnalyzerAnswerText } from '../../analyzer/answer-sanitizer'; -import { completeAnalyzerPlan } from '../../analyzer/analyzer-runner'; import { localAnalyzerFailureMessage, persistedOrLocalAnalyzerFailureMessage } from '../../analyzer/failure-message'; -import AnalyzerResultBlock from '../../analyzer/AnalyzerResultBlock.vue'; -import { readLatestPlanTitle } from '../../analyzer/intent'; import { readError } from '../../analyzer/page-helpers'; import { renderAiMessageMarkdown } from '../../shared/ai-message-markdown'; +import { dashboardDataCachePolicyFromSettings } from '../dashboard-data-cache-policy'; +import { loadVisualizationData } from '../visualization/data'; +import { visualizationSpecFromElement } from '../visualization/spec'; import DashboardAnalyzerScopeControl from './DashboardAnalyzerScopeControl.vue'; import { dashboardAnalyzerComponents, dashboardAnalyzerContextSummary, dashboardAnalyzerDataSources, - dashboardAnalyzerPlanContext, - dashboardAnalyzerPlanModelContext, dashboardAnalyzerQuestionPlaceholder, dashboardAnalyzerQuickQuestions, dashboardAnalyzerScopeMetadata, preferredDashboardDataSourceId, type DashboardAnalyzerScope } from './dashboard-analyzer-scope'; -import { createDashboardAnalyzerTableDataLoader, fetchDashboardAnalyzerTableData } from './dashboardAnalyzerData'; import { useDashboardAnalyzerConversation } from './use-dashboard-analyzer-conversation'; import type { AnalyzerAnswer, + AnalyzerColumn, AnalyzerExecution, - AnalyzerOrchestration, - AnalyzerPlan, DataSourceSummary } from '../../analyzer/types'; -import type { Dashboard } from '../types'; +import type { Dashboard, DashboardElement, VisualizationData } from '../types'; const props = defineProps<{ dashboard: Dashboard; @@ -51,13 +45,13 @@ const question = ref(''); const status = ref('Analyzer ready'); const isAsking = ref(false); const latestAnswer = ref(null); -const latestPlan = ref(null); -const latestExecution = ref(null); -const latestOrchestration = ref(null); const activeRequestController = ref(null); const questionInput = ref(null); const thread = ref(null); const IDLE_WORKING_STATUSES = new Set(['Analyzer ready', 'New dashboard analyzer conversation ready']); +const DASHBOARD_QA_ROW_LIMIT = 5; +const DASHBOARD_QA_ELEMENT_LIMIT = 8; +const DATA_ELEMENT_TYPES = new Set(['area', 'bar', 'card', 'chart', 'column', 'line', 'matrix', 'pie', 'stacked', 'table']); const availableDataSources = computed(() => dashboardAnalyzerDataSources(props.dashboard, props.dataSources)); const selectedDataSource = computed(() => @@ -70,7 +64,6 @@ const analyzerComponents = computed(() => { const selectedComponent = computed(() => analyzerComponents.value.find(component => component.id === selectedComponentId.value) ?? null ); -const latestPlanTitle = computed(() => latestPlan.value ? readLatestPlanTitle(latestPlan.value) : 'Analyzer Result'); const suggestedFollowUps = computed(() => latestAnswer.value?.suggestedFollowUps ?? []); const quickQuestions = computed(() => dashboardAnalyzerQuickQuestions(questionScope.value)); const workingStatus = computed(() => { @@ -92,11 +85,6 @@ const { }); const isQuestionDisabled = computed(() => isAsking.value || isConversationLoading.value); -const loadMoreDashboardAnalyzerTableData = createDashboardAnalyzerTableDataLoader({ - activeController: () => activeRequestController.value, - dashboard: () => props.dashboard, - latestPlan: () => latestPlan.value -}); watch(availableDataSources, sources => { if (selectedDataSourceId.value && sources.some(source => source.id === selectedDataSourceId.value)) return; selectedDataSourceId.value = preferredDashboardDataSourceId(props.dashboard, sources); @@ -114,7 +102,7 @@ watch(analyzerComponents, components => { onBeforeUnmount(() => activeRequestController.value?.abort()); watch( - [() => messages.value.length, () => Boolean(latestExecution.value), isAsking], + [() => messages.value.length, isAsking], () => void nextTick(() => thread.value?.scrollTo({ top: thread.value.scrollHeight, behavior: 'smooth' })) ); async function submitQuestion(): Promise { @@ -155,48 +143,42 @@ async function submitQuestion(): Promise { messages.value = [...messages.value, userMessage]; question.value = ''; - status.value = 'Resolving dashboard context'; - latestOrchestration.value = await orchestrateAnalyzer({ - dataSourceId: selectedDataSourceId.value, - question: prompt, - conversationId - }, { signal: controller.signal }); - const followup = latestOrchestration.value.followup ?? - await resolveAnalyzerFollowup({ question: prompt, conversationId }, { signal: controller.signal }); - - status.value = 'Planning analyzer result'; - const questionForPlan = followup.questionForPlan || prompt; - const dashboardContext = dashboardAnalyzerPlanContext({ - component: selectedComponent.value, - dataSourceId: selectedDataSourceId.value, + status.value = 'Reading visible dashboard data'; + const execution = await dashboardQuestionEvidenceExecution({ dashboard: props.dashboard, - scope: questionScope.value - }); - latestPlan.value = await createAnalyzerPlan({ dataSourceId: selectedDataSourceId.value, - question: questionForPlan, - conversationId, - dashboardContext, - ...dashboardAnalyzerPlanModelContext(questionScope.value, selectedComponent.value) - }, { signal: controller.signal }); - const completion = await completeAnalyzerPlan({ + scope: questionScope.value, + selectedComponentId: selectedComponentId.value, + signal: controller.signal + }); + if (execution.rowCount === 0) { + throw new Error('This dashboard does not have enough visible data to answer that question.'); + } + + status.value = 'Answering from this dashboard'; + latestAnswer.value = await askAnalyzer({ conversationId, dataSourceId: selectedDataSourceId.value, - latestPlanTitle: latestPlanTitle.value, - onStatus: nextStatus => { status.value = nextStatus; }, - orchestration: latestOrchestration.value, - plan: latestPlan.value, - prompt, - signal: controller.signal, - tableDataLoader: input => fetchDashboardAnalyzerTableData({ - ...input, - dashboard: props.dashboard - }) - }); - latestAnswer.value = completion.answer; - latestExecution.value = completion.execution; - messages.value = [...messages.value, completion.assistantMessage]; - status.value = completion.needsClarification ? 'Analyzer needs clarification' : 'Analyzer ready'; + execution, + plan: dashboardQuestionAnswerPlan(prompt, execution), + question: dashboardQuestionPrompt(prompt, props.dashboard.name, questionScope.value) + }, { signal: controller.signal }); + + status.value = 'Saving dashboard answer'; + const assistantMessage = await appendMessage(conversationId, { + role: 'assistant', + content: latestAnswer.value.answer, + metadata: { + dashboardAnswerOnly: true, + dashboardId: props.dashboard.id, + dashboardName: props.dashboard.name, + evidenceComponentCount: dashboardEvidenceComponentCount(execution), + suggestedFollowUps: latestAnswer.value.suggestedFollowUps, + knowledgeReferences: latestAnswer.value.knowledgeReferences + } + }, { signal: controller.signal }); + messages.value = [...messages.value, assistantMessage]; + status.value = 'Analyzer ready'; } catch (caught) { if (controller.signal.aborted || isAbortError(caught)) { status.value = 'Analyzer stopped. Ask another question when ready.'; @@ -231,9 +213,6 @@ function stopAnalyzer(): void { function resetAnalyzerState(): void { latestAnswer.value = null; - latestPlan.value = null; - latestExecution.value = null; - latestOrchestration.value = null; } function resetPanel(): void { @@ -275,6 +254,221 @@ function closePanel(): void { function isAbortError(value: unknown): boolean { return value instanceof DOMException && value.name === 'AbortError'; } + +async function dashboardQuestionEvidenceExecution(input: { + dashboard: Dashboard; + dataSourceId: string; + scope: DashboardAnalyzerScope; + selectedComponentId: string; + signal: AbortSignal; +}): Promise { + const elements = dashboardQuestionEvidenceElements(input); + const values = await Promise.all(elements.map(element => dashboardQuestionEvidenceRows({ + dashboard: input.dashboard, + element, + signal: input.signal + }))); + const rows = values.flat(); + const columns = analyzerColumnsFromRows(rows); + return { + columns, + dataSourceId: input.dataSourceId, + fetchedRows: rows.length, + message: rows.length + ? 'Dashboard Q&A evidence from visible dashboard components.' + : 'No dashboard evidence was available.', + rowCount: rows.length, + rows, + tableName: 'dashboard_visible_data', + title: 'Dashboard evidence', + totalRows: rows.length + }; +} + +function dashboardQuestionEvidenceElements(input: { + dashboard: Dashboard; + dataSourceId: string; + scope: DashboardAnalyzerScope; + selectedComponentId: string; +}): DashboardElement[] { + const selectedIds = input.scope === 'component' && input.selectedComponentId + ? new Set([input.selectedComponentId]) + : null; + return input.dashboard.elements + .filter(element => + element.isVisible !== false + && DATA_ELEMENT_TYPES.has(element.type.trim().toLowerCase()) + && (!selectedIds || selectedIds.has(element.id)) + && elementDataSourceId(element) === input.dataSourceId + ) + .sort((left, right) => elementEvidencePriority(left) - elementEvidencePriority(right) || left.order - right.order) + .slice(0, DASHBOARD_QA_ELEMENT_LIMIT); +} + +async function dashboardQuestionEvidenceRows(input: { + dashboard: Dashboard; + element: DashboardElement; + signal: AbortSignal; +}): Promise>> { + try { + const data = await loadVisualizationData( + input.element, + visualizationSpecFromElement(input.element), + input.dashboard.filters, + { + cachePolicy: dashboardDataCachePolicyFromSettings(input.dashboard.settings), + peerElements: input.dashboard.elements, + rowLimit: DASHBOARD_QA_ROW_LIMIT, + signal: input.signal + } + ); + return visualizationEvidenceRows(input.element, data); + } catch (caught) { + if (isAbortError(caught)) throw caught; + return []; + } +} + +function visualizationEvidenceRows( + element: DashboardElement, + data: VisualizationData +): Array> { + const preferred = elementEvidenceFields(element); + const rawRows = (data.rawData ?? []) + .slice(0, DASHBOARD_QA_ROW_LIMIT) + .map(row => ({ + component: element.name, + componentType: element.type, + ...scalarFields(row, preferred) + })) + .filter(row => Object.keys(row).length > 2); + if (rawRows.length > 0) return rawRows; + + return data.labels.slice(0, DASHBOARD_QA_ROW_LIMIT).map((label, index) => ({ + component: element.name, + componentType: element.type, + label: String(label), + ...Object.fromEntries(data.datasets.flatMap(dataset => { + const value = dataset.data[index]; + return typeof value === 'number' && Number.isFinite(value) + ? [[dataset.label || 'value', value]] + : []; + })) + })).filter(row => Object.keys(row).length > 3); +} + +function dashboardQuestionPrompt(question: string, dashboardName: string, scope: DashboardAnalyzerScope): string { + return [ + `Dashboard: ${dashboardName}.`, + `Scope: ${scope}.`, + 'Answer the user using only the supplied dashboard_visible_data execution rows.', + 'Do not create tables, SQL, charts, dashboard actions, or queues.', + 'If the dashboard evidence does not contain enough information, say that this dashboard does not show enough data to answer.', + `User question: ${question}` + ].join('\n'); +} + +function dashboardQuestionAnswerPlan(question: string, execution: AnalyzerExecution) { + return { + message: 'Answer using only data already visible on this dashboard.', + actions: [{ + action: 'answer_conversation', + params: { + question, + reason: 'Dashboard Ask AI is limited to plain answers over visible dashboard evidence.' + } + }], + intentDetails: { + question, + knowledgeReferences: [], + selectedModel: null, + selectedModels: [], + sql: '', + insightGuidance: [ + `Use only ${execution.rowCount} dashboard evidence row${execution.rowCount === 1 ? '' : 's'}.`, + 'Do not introduce data that is not already represented by dashboard components.' + ] + } + }; +} + +function dashboardEvidenceComponentCount(execution: AnalyzerExecution): number { + const components = new Set((execution.rows ?? []).flatMap(row => readString(row.component) ?? [])); + return components.size; +} + +function analyzerColumnsFromRows(rows: Array>): AnalyzerColumn[] { + const fields = [...new Set(rows.flatMap(row => Object.keys(row)))]; + return fields.map(field => ({ + field, + label: labelFor(field), + type: rows.some(row => typeof row[field] === 'number') ? 'number' : 'string' + })); +} + +function scalarFields( + row: Record, + preferredFields: string[] +): Record { + const fields = preferredFields.length > 0 ? preferredFields : Object.keys(row).slice(0, 12); + return Object.fromEntries(fields.flatMap(field => { + const value = row[field]; + if (value === null || typeof value === 'boolean') return [[field, value]]; + if (typeof value === 'number' && Number.isFinite(value)) return [[field, value]]; + if (typeof value === 'string' && value.trim()) return [[field, value.trim().slice(0, 240)]]; + return []; + })); +} + +function elementEvidenceFields(element: DashboardElement): string[] { + const config = element.config ?? {}; + return unique([ + readString(config.valueField), + readString(config.field), + readString(config.xField), + ...readStringArray(config.ySeries ?? config.yFields), + ...readStringArray(config.columns), + ...readStringArray(config.rowFields), + ...readStringArray(config.columnFields), + ...readStringArray(config.valueFields) + ].filter((value): value is string => Boolean(value))); +} + +function elementDataSourceId(element: DashboardElement): string | undefined { + const visualization = readRecord(element.config?.visualization); + const dataRef = readRecord(visualization?.dataRef); + return readString(element.dataSourceId) + ?? readString(element.config?.dataSourceId) + ?? readString(dataRef?.sourceId); +} + +function elementEvidencePriority(element: DashboardElement): number { + if (element.type === 'card') return 0; + if (element.type === 'table' || element.type === 'matrix') return 2; + return 1; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.flatMap(item => typeof item === 'string' && item.trim() ? [item.trim()] : []) : []; +} + +function readRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function labelFor(field: string): string { + return field.split('_').map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(' '); +} + +function unique(values: string[]): string[] { + return [...new Set(values)]; +}