@lightdash/cli 2.265.0 → 2.267.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo.build +1 -1
- package/dist/vendor/template/.claude/skills/sdk-features/SKILL.md +8 -8
- package/dist/vendor/template/references/ai-analysis.md +73 -148
- package/dist/vendor/template/skill.md +1 -1
- package/dist/vendor/template/src/components/insights/InsightsSummary.tsx +96 -0
- package/dist/vendor/template/src/components/insights/InvestigateMenuItem.tsx +51 -0
- package/dist/vendor/template/src/components/insights/InvestigationCard.tsx +93 -0
- package/dist/vendor/template/src/components/insights/Markdown.tsx +111 -0
- package/dist/vendor/template/src/components/insights/index.ts +10 -0
- package/dist/vendor/template/src/components/insights/markers.tsx +111 -0
- package/package.json +3 -3
|
@@ -117,14 +117,14 @@ order, `sortBy` describes result ordering, `totalColumnCount` exposes truncation
|
|
|
117
117
|
- `external-fetch`: `client.externalFetch(alias, opts)` calls an external
|
|
118
118
|
connection linked to this app. The connection must already be linked by the
|
|
119
119
|
host; you cannot add one from app code.
|
|
120
|
-
- `ai-insights`:
|
|
121
|
-
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
`
|
|
127
|
-
|
|
120
|
+
- `ai-insights`: wire the shipped components in `src/components/insights/`
|
|
121
|
+
(`InsightsSummary`, `InsightMarker` / `insightCellProps`,
|
|
122
|
+
`InvestigateMenuItem`, `InvestigationCard`) rather than writing your own:
|
|
123
|
+
summary block at the top, markers on every query-bound chart (as both `dot`
|
|
124
|
+
and `activeDot`), Investigate in every point menu, the card under the chart.
|
|
125
|
+
A short ask ("add AI analysis") gets the whole set. They sit on
|
|
126
|
+
`useInsights()` / `useInsights(result)` and render nothing when the host has
|
|
127
|
+
no analysis. Full contract in `/app/references/ai-analysis.md`.
|
|
128
128
|
- `ai-prompt`: `useAiPrompt()` returns `{ available, ask, loading, text, error }`;
|
|
129
129
|
`ask({ prompt, sources: [{ result, label }], focus?: { row } })` sends an
|
|
130
130
|
author-written question over loaded `useLightdash` results and resolves to
|
|
@@ -9,8 +9,26 @@ Two operations exist, both run by Lightdash:
|
|
|
9
9
|
- **Detect** — reads the results the app already loaded and returns a headline, a summary, a list of notable data points (anomalies) with the query, field and row they refer to, and the limitations of the data (no comparison period, truncation, and so on).
|
|
10
10
|
- **Investigate** — for one anomaly, an AI agent with read-only query tools looks for possible drivers using data beyond the page and returns a Markdown explanation with evidence and a confidence line. It runs asynchronously; the app shows progress from the pushed status.
|
|
11
11
|
|
|
12
|
+
## Default build
|
|
13
|
+
|
|
14
|
+
When the user asks for AI analysis, an executive summary, anomalies, "what changed", "flag anything unusual", or insights and gives no further detail, build **all** of this with the shipped components in `src/components/insights/` (never rewrite them):
|
|
15
|
+
|
|
16
|
+
1. `<InsightsSummary />` near the top of the page.
|
|
17
|
+
2. On every query-bound chart, `InsightMarker` as both `dot` and `activeDot` (lines/areas) or `insightCellProps` on each `Cell` (bars), one per series with its `dataKey` as `fieldId`, plus the finding text in the tooltip via `insightTooltipText`.
|
|
18
|
+
3. `<InvestigateMenuItem />` in every data-point action menu, next to "Filter by …" and "View underlying data".
|
|
19
|
+
4. `<InvestigationCard />` under the chart once an investigation starts.
|
|
20
|
+
|
|
21
|
+
Narrower asks build only that part: "just an executive summary" is step 1 alone; "mark anomalies on the charts" is steps 2 to 4. All four components render nothing when the host has no analysis to offer, so nothing else needs gating.
|
|
22
|
+
|
|
12
23
|
```tsx
|
|
13
|
-
import { useInsights } from '@lightdash/query-sdk';
|
|
24
|
+
import { useInsights, useLightdash } from '@lightdash/query-sdk';
|
|
25
|
+
import {
|
|
26
|
+
InsightMarker,
|
|
27
|
+
InsightsSummary,
|
|
28
|
+
InvestigateMenuItem,
|
|
29
|
+
InvestigationCard,
|
|
30
|
+
insightTooltipText,
|
|
31
|
+
} from '@/components/insights';
|
|
14
32
|
```
|
|
15
33
|
|
|
16
34
|
## View-level: the executive summary block
|
|
@@ -31,86 +49,14 @@ const view = useInsights();
|
|
|
31
49
|
// view.analyse() ask Lightdash to (re)run the analysis
|
|
32
50
|
```
|
|
33
51
|
|
|
34
|
-
Render
|
|
35
|
-
|
|
36
|
-
```tsx
|
|
37
|
-
import { useInsights } from '@lightdash/query-sdk';
|
|
38
|
-
import { Button } from '@/components/ui/button';
|
|
39
|
-
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
40
|
-
import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
|
41
|
-
|
|
42
|
-
function ExecutiveSummary() {
|
|
43
|
-
const view = useInsights();
|
|
44
|
-
|
|
45
|
-
// The org has not enabled AI analysis: render nothing, never a stub.
|
|
46
|
-
if (view.status === 'unavailable') return null;
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
<Card>
|
|
50
|
-
<CardHeader className="flex flex-row items-center justify-between">
|
|
51
|
-
<CardTitle className="flex items-center gap-2">
|
|
52
|
-
<Sparkles className="h-4 w-4" /> Executive summary
|
|
53
|
-
</CardTitle>
|
|
54
|
-
<Button
|
|
55
|
-
variant="outline"
|
|
56
|
-
size="sm"
|
|
57
|
-
onClick={view.analyse}
|
|
58
|
-
disabled={view.status === 'analysing'}
|
|
59
|
-
>
|
|
60
|
-
{view.status === 'analysing' ? (
|
|
61
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
62
|
-
) : (
|
|
63
|
-
<RefreshCw className="h-4 w-4" />
|
|
64
|
-
)}
|
|
65
|
-
{view.status === 'ready' ? 'Refresh' : 'Analyse'}
|
|
66
|
-
</Button>
|
|
67
|
-
</CardHeader>
|
|
68
|
-
<CardContent>
|
|
69
|
-
{view.status === 'idle' && (
|
|
70
|
-
<p className="text-muted-foreground">
|
|
71
|
-
Click Analyse for a summary of what is on this page.
|
|
72
|
-
</p>
|
|
73
|
-
)}
|
|
74
|
-
{view.status === 'analysing' && (
|
|
75
|
-
<p className="text-muted-foreground">Reading the data on this page…</p>
|
|
76
|
-
)}
|
|
77
|
-
{view.status === 'error' && (
|
|
78
|
-
<p className="text-destructive">{view.error}</p>
|
|
79
|
-
)}
|
|
80
|
-
{view.status === 'ready' && (
|
|
81
|
-
<>
|
|
82
|
-
{view.stale && (
|
|
83
|
-
<p className="text-sm text-muted-foreground">
|
|
84
|
-
The view changed since this was generated. Refresh to update.
|
|
85
|
-
</p>
|
|
86
|
-
)}
|
|
87
|
-
<h3 className="text-lg font-semibold">{view.headline}</h3>
|
|
88
|
-
<p>{view.summary}</p>
|
|
89
|
-
{view.limitations.length > 0 && (
|
|
90
|
-
<ul className="text-sm text-muted-foreground">
|
|
91
|
-
{view.limitations.map((l) => (
|
|
92
|
-
<li key={l}>{l}</li>
|
|
93
|
-
))}
|
|
94
|
-
</ul>
|
|
95
|
-
)}
|
|
96
|
-
<p className="text-xs text-muted-foreground">
|
|
97
|
-
AI-generated from the data on this page
|
|
98
|
-
{view.dataAsOf ? ` · Data as of ${view.dataAsOf}` : ''}
|
|
99
|
-
</p>
|
|
100
|
-
</>
|
|
101
|
-
)}
|
|
102
|
-
</CardContent>
|
|
103
|
-
</Card>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
```
|
|
52
|
+
Render `<InsightsSummary />` near the top of the page. It handles every status (idle, analysing, ready, error, stale) and renders nothing when unavailable. Props: `title`, `idleText`, `className`. Write your own block only when the user asks for a layout the component cannot give, and then keep every status branch.
|
|
107
53
|
|
|
108
54
|
Rules for the block:
|
|
109
55
|
|
|
110
56
|
- Say "AI-generated" in the footer. Never name a model or provider.
|
|
111
57
|
- Show the limitations; they are what keeps the summary honest.
|
|
112
58
|
- Never mount it unconditionally in a modal or overlay; it is page content.
|
|
113
|
-
- `analyse()` re-runs even when a result exists (that is the
|
|
59
|
+
- `analyse()` re-runs even when a result exists (that is the Regenerate case). Do not call it in an effect on mount; the viewer clicks. Lightdash reuses a stored analysis of identical rows on open by itself.
|
|
114
60
|
|
|
115
61
|
## Per-chart: anomalies on the data points
|
|
116
62
|
|
|
@@ -121,7 +67,7 @@ const orders = useLightdash(ordersQuery);
|
|
|
121
67
|
const insights = useInsights(orders);
|
|
122
68
|
// insights.status same as the view
|
|
123
69
|
// insights.anomalies the anomalies that refer to this query
|
|
124
|
-
// insights.matches(row)
|
|
70
|
+
// insights.matches(row, fieldId?) the anomalies on this row; fieldId narrows to one metric
|
|
125
71
|
// insights.canInvestigate false when no agent is available
|
|
126
72
|
// insights.canContinue false when the org keeps viewers at the explanation
|
|
127
73
|
// insights.investigate(id) start an investigation of one anomaly
|
|
@@ -150,98 +96,77 @@ Each anomaly:
|
|
|
150
96
|
}
|
|
151
97
|
```
|
|
152
98
|
|
|
153
|
-
Mark the flagged rows
|
|
99
|
+
Mark the flagged rows with the shipped helpers. Only `high`, `medium` and `positive` earn a marker; `info` is context for the summary and is never painted. Every helper takes the series' `dataKey` as `fieldId`, so on a chart plotting revenue and orders a revenue anomaly marks only the revenue series. The marker element must be both `dot` and `activeDot`, otherwise the hover dot covers it and takes the click:
|
|
154
100
|
|
|
155
101
|
```tsx
|
|
156
|
-
import {
|
|
157
|
-
|
|
158
|
-
const MARKED = new Set(['high', 'medium', 'positive']);
|
|
159
|
-
const marker = (insights, row) =>
|
|
160
|
-
insights.matches(row).find((a) => MARKED.has(a.severity)) ?? null;
|
|
102
|
+
import { Line, LineChart, Tooltip } from 'recharts';
|
|
103
|
+
import { ChartTooltipSurface } from '@/lib/floating';
|
|
161
104
|
|
|
162
|
-
function
|
|
163
|
-
const
|
|
164
|
-
const insights = useInsights(
|
|
105
|
+
function RevenueByMonth() {
|
|
106
|
+
const revenue = useLightdash(revenueQuery);
|
|
107
|
+
const insights = useInsights(revenue);
|
|
108
|
+
const [menu, setMenu] = useState(null);
|
|
109
|
+
const openMenu = (row, e, fieldId) =>
|
|
110
|
+
setMenu({ row, fieldId, x: e.clientX, y: e.clientY });
|
|
111
|
+
const dot = (
|
|
112
|
+
<InsightMarker insights={insights} fieldId="revenue" onOpenMenu={openMenu} />
|
|
113
|
+
);
|
|
165
114
|
|
|
166
115
|
return (
|
|
167
|
-
<
|
|
168
|
-
<
|
|
169
|
-
{
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
116
|
+
<LineChart data={revenue.data}>
|
|
117
|
+
<Tooltip
|
|
118
|
+
content={({ payload, label }) => {
|
|
119
|
+
const row = payload?.[0]?.payload;
|
|
120
|
+
const finding = insightTooltipText(insights, row, 'revenue');
|
|
121
|
+
return row ? (
|
|
122
|
+
<ChartTooltipSurface>
|
|
123
|
+
<div className="font-semibold">{label}</div>
|
|
124
|
+
<div>{revenue.format(row, 'revenue')}</div>
|
|
125
|
+
{finding && <div className="text-destructive text-xs">{finding}</div>}
|
|
126
|
+
</ChartTooltipSurface>
|
|
127
|
+
) : null;
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
<Line dataKey="revenue" dot={dot} activeDot={dot} />
|
|
131
|
+
</LineChart>
|
|
181
132
|
);
|
|
182
133
|
}
|
|
183
134
|
```
|
|
184
135
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
### Markers on line and area charts must stay clickable on hover
|
|
188
|
-
|
|
189
|
-
Recharts draws `activeDot` on top of `dot` while the cursor is over a point. With the default `activeDot={{ r: 5 }}` the hover dot covers the marker and takes the click, so the action menu only opens from a one-pixel rim. Render the same marker component for both, with the same click handler, or turn the hover dot off:
|
|
136
|
+
Bar charts use `insightCellProps` on each `Cell` and open the menu from the bar's `onClick`:
|
|
190
137
|
|
|
191
138
|
```tsx
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
<circle
|
|
198
|
-
cx={cx}
|
|
199
|
-
cy={cy}
|
|
200
|
-
r={anomaly ? 5 : 3}
|
|
201
|
-
fill={anomaly ? 'var(--destructive)' : 'var(--chart-1)'}
|
|
202
|
-
style={{ cursor: 'pointer' }}
|
|
203
|
-
onClick={(e) => onOpenMenu(payload, e)}
|
|
204
|
-
/>
|
|
205
|
-
);
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
function OrdersOverTime() {
|
|
209
|
-
const orders = useLightdash(ordersQuery);
|
|
210
|
-
const insights = useInsights(orders);
|
|
211
|
-
const [menu, setMenu] = useState(null);
|
|
212
|
-
const openMenu = (row, e) => setMenu({ row, x: e.clientX, y: e.clientY });
|
|
213
|
-
const dot = <Marker insights={insights} onOpenMenu={openMenu} />;
|
|
214
|
-
|
|
215
|
-
return (
|
|
216
|
-
<LineChart data={orders.data}>
|
|
217
|
-
<Line dataKey="orders_count" dot={dot} activeDot={dot} />
|
|
218
|
-
{/* or: activeDot={false} */}
|
|
219
|
-
</LineChart>
|
|
220
|
-
);
|
|
221
|
-
}
|
|
139
|
+
<Bar dataKey="revenue" onClick={(d, _i, e) => openMenu(d.payload, e, 'revenue')}>
|
|
140
|
+
{rows.map((row) => (
|
|
141
|
+
<Cell key={row.status} {...insightCellProps(insights, row, 'revenue')} />
|
|
142
|
+
))}
|
|
143
|
+
</Bar>
|
|
222
144
|
```
|
|
223
145
|
|
|
224
|
-
|
|
146
|
+
`markerFor(insights, row, fieldId)` returns the anomaly behind a marker when you need it directly, for example to open an `InvestigationCard`.
|
|
225
147
|
|
|
226
148
|
## Investigate from the action menu
|
|
227
149
|
|
|
228
|
-
|
|
150
|
+
Add `<InvestigateMenuItem />` to the data-point action menu next to "Filter by …" and "View underlying data". It renders only when the clicked row has a flagged anomaly: **Investigate with AI** starts one when an agent is available, and **View investigation** reopens the card once one is running, done or failed, so a dismissed result is never lost:
|
|
229
151
|
|
|
230
152
|
```tsx
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
153
|
+
<DropdownMenuContent>
|
|
154
|
+
<DropdownMenuItem onSelect={() => addFilter(menu.row)}>
|
|
155
|
+
Filter by {value}
|
|
156
|
+
</DropdownMenuItem>
|
|
157
|
+
<DropdownMenuItem onSelect={() => showUnderlyingData(menu.row)}>
|
|
158
|
+
View underlying data
|
|
236
159
|
</DropdownMenuItem>
|
|
237
|
-
|
|
160
|
+
<InvestigateMenuItem
|
|
161
|
+
insights={insights}
|
|
162
|
+
row={menu.row}
|
|
163
|
+
fieldId={menu.fieldId}
|
|
164
|
+
onOpen={(anomaly) => setOpenInvestigation(anomaly.id)}
|
|
165
|
+
/>
|
|
166
|
+
</DropdownMenuContent>
|
|
238
167
|
```
|
|
239
168
|
|
|
240
|
-
Then render
|
|
241
|
-
|
|
242
|
-
- `running` — a spinner and "Investigating…". It can take a minute.
|
|
243
|
-
- `ready` — render `explanation` as Markdown (it contains a summary, a "Possible drivers" list with the evidence for each, an "Evidence" list, and a "Confidence: …" line). If `partial` is true, say the query budget ran out. When `insights.canContinue` is true, offer a **Continue in Ask AI** button that calls `insights.continueInAskAi(anomaly.id)`; Lightdash opens the thread. When it is false the organisation keeps viewers at the explanation: render no button.
|
|
244
|
-
- `error` — show `error` and offer Investigate again.
|
|
169
|
+
Then render `<InvestigationCard anomaly={anomaly} insights={insights} onDismiss={...} />` under the chart for the anomaly being investigated (find it with `insights.anomalies.find((a) => a.id === openInvestigation)`; re-read it on every render so the card follows the pushed status). The card shows the spinner while running, the explanation as Markdown when ready (a summary, a "Possible drivers" list with evidence, an "Evidence" list, a "Confidence: …" line), a note when `partial`, an "Investigate again" action on error, and **Continue in Ask AI** only when `insights.canContinue` is true. When it is false the organisation keeps viewers at the explanation and the card shows no button.
|
|
245
170
|
|
|
246
171
|
Never open a dialog for the investigation yourself; the app renders state, Lightdash owns the run.
|
|
247
172
|
|
|
@@ -292,7 +217,7 @@ function RevenueTakeaway() {
|
|
|
292
217
|
- Do not send rows or field values anywhere. The hooks expose results; Lightdash already has the data.
|
|
293
218
|
- Do not add an external connection to a model provider for summaries or explanations. Use this hook.
|
|
294
219
|
- Do not fabricate insights when the status is `idle` or `unavailable`; render the state.
|
|
295
|
-
- Do not render `explanation` as HTML.
|
|
220
|
+
- Do not render `explanation` as HTML. `InvestigationCard` renders its Markdown safely; if you must render it elsewhere, use the shipped `Markdown` component from `@/components/insights/Markdown`.
|
|
296
221
|
- Do not call `analyse()` on mount or on a timer.
|
|
297
222
|
|
|
298
223
|
## When nothing is notable
|
|
@@ -693,7 +693,7 @@ For any external HTTP API call, read `/app/references/external-apis.md` and use
|
|
|
693
693
|
|
|
694
694
|
### AI analysis — executive summary and anomalies
|
|
695
695
|
|
|
696
|
-
When the user asks for an executive summary, "what changed and why", anomaly callouts, or any AI-generated insight about the data on the page, read `/app/references/ai-analysis.md` and
|
|
696
|
+
When the user asks for AI analysis, an executive summary, "what changed and why", anomaly callouts, or any AI-generated insight about the data on the page, read `/app/references/ai-analysis.md` and build its **Default build** with the shipped components in `src/components/insights/` (`InsightsSummary`, `InsightMarker` / `insightCellProps`, `InvestigateMenuItem`, `InvestigationCard`): summary block at the top, markers on every query-bound chart, Investigate in every point menu, the investigation card under the chart. A short ask gets the whole set; only a narrower ask ("just a summary") gets a subset. Never rewrite those components. Lightdash runs the analysis over the queries the app already loaded and pushes the result in; the app renders it and never sends rows or keys. The components render nothing when the host has no analysis to offer. For an author-written question over loaded results (a takeaway under a chart, "explain this row"), use `useAiPrompt` from the same reference.
|
|
697
697
|
|
|
698
698
|
## Visual Design
|
|
699
699
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view-level analysis block. Renders nothing when the host has no
|
|
3
|
+
* analysis to offer (embeds, tiles, org not rolled out).
|
|
4
|
+
*/
|
|
5
|
+
import { useInsights } from '@lightdash/query-sdk';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
8
|
+
import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
title?: ReactNode;
|
|
13
|
+
/** Shown before the first run. */
|
|
14
|
+
idleText?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function InsightsSummary({
|
|
19
|
+
title = 'Executive summary',
|
|
20
|
+
idleText = 'Click Analyse for an AI-generated read of everything on this page.',
|
|
21
|
+
className,
|
|
22
|
+
}: Props) {
|
|
23
|
+
const view = useInsights();
|
|
24
|
+
if (view.status === 'unavailable') return null;
|
|
25
|
+
const busy = view.status === 'analysing';
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Card className={className}>
|
|
29
|
+
<CardHeader className="flex flex-row items-center justify-between gap-4 space-y-0">
|
|
30
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
31
|
+
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
|
32
|
+
{title}
|
|
33
|
+
</CardTitle>
|
|
34
|
+
<Button
|
|
35
|
+
variant="outline"
|
|
36
|
+
size="sm"
|
|
37
|
+
onClick={view.analyse}
|
|
38
|
+
disabled={busy}
|
|
39
|
+
>
|
|
40
|
+
{busy ? (
|
|
41
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
42
|
+
) : (
|
|
43
|
+
<RefreshCw className="h-4 w-4" />
|
|
44
|
+
)}
|
|
45
|
+
{view.status === 'ready' ? 'Regenerate' : 'Analyse'}
|
|
46
|
+
</Button>
|
|
47
|
+
</CardHeader>
|
|
48
|
+
<CardContent className="space-y-3">
|
|
49
|
+
{view.status === 'idle' && (
|
|
50
|
+
<p className="text-sm text-muted-foreground">{idleText}</p>
|
|
51
|
+
)}
|
|
52
|
+
{busy && (
|
|
53
|
+
<p className="text-sm text-muted-foreground">
|
|
54
|
+
Reading the data on this page…
|
|
55
|
+
</p>
|
|
56
|
+
)}
|
|
57
|
+
{view.status === 'error' && (
|
|
58
|
+
<p className="text-sm text-destructive">
|
|
59
|
+
{view.error ?? 'The analysis failed. Try again.'}
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
{view.status === 'ready' && (
|
|
63
|
+
<>
|
|
64
|
+
{view.stale && (
|
|
65
|
+
<p className="text-xs text-muted-foreground">
|
|
66
|
+
The view changed since this was generated.
|
|
67
|
+
Regenerate to update.
|
|
68
|
+
</p>
|
|
69
|
+
)}
|
|
70
|
+
{view.headline && (
|
|
71
|
+
<p className="font-semibold leading-snug">
|
|
72
|
+
{view.headline}
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
{view.summary && (
|
|
76
|
+
<p className="text-sm leading-relaxed">
|
|
77
|
+
{view.summary}
|
|
78
|
+
</p>
|
|
79
|
+
)}
|
|
80
|
+
{view.limitations.length > 0 && (
|
|
81
|
+
<ul className="list-disc space-y-1 pl-5 text-xs text-muted-foreground">
|
|
82
|
+
{view.limitations.map((limitation) => (
|
|
83
|
+
<li key={limitation}>{limitation}</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
)}
|
|
87
|
+
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
88
|
+
AI-generated from the data on this page
|
|
89
|
+
{view.dataAsOf ? ` · Data as of ${view.dataAsOf}` : ''}
|
|
90
|
+
</p>
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
</CardContent>
|
|
94
|
+
</Card>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The investigation entry for a data point's action menu. Starts an
|
|
3
|
+
* investigation for an idle anomaly, reopens the card for one that is
|
|
4
|
+
* running, done or failed, and renders nothing when the row has no flagged
|
|
5
|
+
* anomaly.
|
|
6
|
+
*/
|
|
7
|
+
import type { Insight, QueryInsights } from '@lightdash/query-sdk';
|
|
8
|
+
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
|
9
|
+
import { Sparkles } from 'lucide-react';
|
|
10
|
+
import { markerFor } from './markers';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
insights: QueryInsights;
|
|
14
|
+
row: Record<string, unknown> | null | undefined;
|
|
15
|
+
/** The clicked series' `dataKey`, from the marker's `onOpenMenu`. */
|
|
16
|
+
fieldId?: string;
|
|
17
|
+
/** Called with the anomaly whose card should be shown. */
|
|
18
|
+
onOpen: (anomaly: Insight) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function InvestigateMenuItem({
|
|
22
|
+
insights,
|
|
23
|
+
row,
|
|
24
|
+
fieldId,
|
|
25
|
+
onOpen,
|
|
26
|
+
}: Props) {
|
|
27
|
+
const anomaly = markerFor(insights, row, fieldId);
|
|
28
|
+
if (!anomaly) return null;
|
|
29
|
+
|
|
30
|
+
if (anomaly.investigation.status === 'idle') {
|
|
31
|
+
if (!insights.canInvestigate) return null;
|
|
32
|
+
return (
|
|
33
|
+
<DropdownMenuItem
|
|
34
|
+
onSelect={() => {
|
|
35
|
+
insights.investigate(anomaly.id);
|
|
36
|
+
onOpen(anomaly);
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<Sparkles className="mr-2 h-4 w-4" />
|
|
40
|
+
Investigate with AI
|
|
41
|
+
</DropdownMenuItem>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<DropdownMenuItem onSelect={() => onOpen(anomaly)}>
|
|
47
|
+
<Sparkles className="mr-2 h-4 w-4" />
|
|
48
|
+
View investigation
|
|
49
|
+
</DropdownMenuItem>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders one anomaly's investigation state under its chart. The app owns
|
|
3
|
+
* where it appears; Lightdash owns the run.
|
|
4
|
+
*/
|
|
5
|
+
import type { Insight, QueryInsights } from '@lightdash/query-sdk';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
8
|
+
import { Loader2, MessageSquare, X } from 'lucide-react';
|
|
9
|
+
import { Markdown } from './Markdown';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
anomaly: Insight;
|
|
13
|
+
insights: QueryInsights;
|
|
14
|
+
onDismiss?: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function InvestigationCard({
|
|
19
|
+
anomaly,
|
|
20
|
+
insights,
|
|
21
|
+
onDismiss,
|
|
22
|
+
className,
|
|
23
|
+
}: Props) {
|
|
24
|
+
const { investigation } = anomaly;
|
|
25
|
+
if (investigation.status === 'idle') return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Card className={className}>
|
|
29
|
+
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
|
30
|
+
<CardTitle className="text-sm font-medium leading-snug">
|
|
31
|
+
Investigation · {anomaly.text}
|
|
32
|
+
</CardTitle>
|
|
33
|
+
{onDismiss && (
|
|
34
|
+
<Button
|
|
35
|
+
variant="ghost"
|
|
36
|
+
size="icon"
|
|
37
|
+
aria-label="Dismiss"
|
|
38
|
+
onClick={onDismiss}
|
|
39
|
+
>
|
|
40
|
+
<X className="h-4 w-4" />
|
|
41
|
+
</Button>
|
|
42
|
+
)}
|
|
43
|
+
</CardHeader>
|
|
44
|
+
<CardContent className="space-y-3 text-sm">
|
|
45
|
+
{investigation.status === 'running' && (
|
|
46
|
+
<p className="flex items-center gap-2 text-muted-foreground">
|
|
47
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
48
|
+
Investigating… this can take a minute.
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
{investigation.status === 'error' && (
|
|
52
|
+
<>
|
|
53
|
+
<p className="text-destructive">
|
|
54
|
+
{investigation.error ?? 'The investigation failed.'}
|
|
55
|
+
</p>
|
|
56
|
+
{insights.canInvestigate && (
|
|
57
|
+
<Button
|
|
58
|
+
variant="outline"
|
|
59
|
+
size="sm"
|
|
60
|
+
onClick={() => insights.investigate(anomaly.id)}
|
|
61
|
+
>
|
|
62
|
+
Investigate again
|
|
63
|
+
</Button>
|
|
64
|
+
)}
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
{investigation.status === 'ready' && (
|
|
68
|
+
<>
|
|
69
|
+
<Markdown text={investigation.explanation ?? ''} />
|
|
70
|
+
{investigation.partial && (
|
|
71
|
+
<p className="text-xs text-muted-foreground">
|
|
72
|
+
The query budget ran out; this is a partial
|
|
73
|
+
answer.
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
{insights.canContinue && (
|
|
77
|
+
<Button
|
|
78
|
+
variant="outline"
|
|
79
|
+
size="sm"
|
|
80
|
+
onClick={() =>
|
|
81
|
+
insights.continueInAskAi(anomaly.id)
|
|
82
|
+
}
|
|
83
|
+
>
|
|
84
|
+
<MessageSquare className="h-4 w-4" />
|
|
85
|
+
Continue in Ask AI
|
|
86
|
+
</Button>
|
|
87
|
+
)}
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</CardContent>
|
|
91
|
+
</Card>
|
|
92
|
+
);
|
|
93
|
+
}
|