diff --git a/dataweaver/apps/web/src/app/(atlas)/page.tsx b/dataweaver/apps/web/src/app/(atlas)/page.tsx
index 7eebd378..c03b56a4 100644
--- a/dataweaver/apps/web/src/app/(atlas)/page.tsx
+++ b/dataweaver/apps/web/src/app/(atlas)/page.tsx
@@ -1,3 +1,10 @@
import { PageHome } from '~/components/scopes/page_home/page_home';
+import { EXAMPLE_PROMPTS } from '~/configs/example_prompts';
+import { shuffleArray } from '~/functions/shuffle_array';
-export default PageHome;
+const Page = () => {
+ const prompts = shuffleArray(EXAMPLE_PROMPTS).slice(0, 4);
+ return ;
+};
+
+export default Page;
diff --git a/dataweaver/apps/web/src/app/api/query/route.ts b/dataweaver/apps/web/src/app/api/query/route.ts
index 229398e2..98cdf0dd 100644
--- a/dataweaver/apps/web/src/app/api/query/route.ts
+++ b/dataweaver/apps/web/src/app/api/query/route.ts
@@ -4,7 +4,10 @@
import { nanoid } from 'nanoid';
import type { NextRequest } from 'next/server';
+import { EXAMPLE_PROMPTS } from '~/configs/example_prompts';
+import { deduplicateStrings } from '~/functions/deduplicate_strings';
import { extractJson } from '~/functions/extract_json';
+import { shuffleArray } from '~/functions/shuffle_array';
import { fetchGeminiTools, runToolLoop } from '~/server/steps/data_discovery';
import { fetchTimeSeries } from '~/server/steps/observations';
import { parseQuery } from '~/server/steps/parse_query';
@@ -51,6 +54,7 @@ export async function POST(request: NextRequest) {
const selectedEntityDcids = Array.isArray(body?.selectedEntityDcids)
? body.selectedEntityDcids
: [];
+ const followUpContext = body?.followUpContext ?? undefined;
if (typeof query !== 'string' || !query.trim()) {
return new Response(JSON.stringify({ error: 'Missing or invalid query' }), {
@@ -81,10 +85,15 @@ export async function POST(request: NextRequest) {
const safetyResult = await checkPromptSafety(query);
if (!safetyResult.allowed) {
emit({
- type: STREAM_EVENT.error,
- message:
- safetyResult.reason || 'Query was blocked by safety filter.',
+ type: STREAM_EVENT.followUp,
+ data: {
+ summary: "I'm not able to answer that question.",
+ question:
+ 'Try one of these suggestions, or type a new question about data.',
+ options: shuffleArray(EXAMPLE_PROMPTS).slice(0, 4),
+ },
});
+ emit({ type: STREAM_EVENT.complete, message: STATUS.complete });
controller.close();
return;
}
@@ -95,15 +104,16 @@ export async function POST(request: NextRequest) {
query,
atlasContext,
ancestorChainLength: ancestorChain.length,
+ followUpContext,
});
// Resolve places
let places = parsed.places;
if (parsed.isFollowUp && places.length === 0) {
- places =
- selectedEntityDcids.length > 0 ? selectedEntityDcids : [query];
+ places = selectedEntityDcids.length > 0 ? selectedEntityDcids : [];
}
- if (places.length === 0) places = [query];
+ const noExplicitPlace = places.length === 0;
+ if (noExplicitPlace) places = ['Earth'];
parsed.places = places;
emit({ type: STREAM_EVENT.parsedQuery, data: parsed });
@@ -112,6 +122,14 @@ export async function POST(request: NextRequest) {
return;
}
+ // ─── Early exit: parse_query returned a followUp ─────────────────────
+ if (parsed.followUp) {
+ emit({ type: STREAM_EVENT.followUp, data: parsed.followUp });
+ emit({ type: STREAM_EVENT.complete, message: STATUS.complete });
+ controller.close();
+ return;
+ }
+
// ─── Data Discovery ──────────────────────────────────────────────────
// Fetch tools
@@ -121,6 +139,14 @@ export async function POST(request: NextRequest) {
});
const geminiTools = await fetchGeminiTools(signal);
+ // Accumulator: collect disambiguation/follow-up signals across places.
+ // After the loop we synthesize at most one follow-up for the whole round.
+ const disambiguationEntries: Array<{
+ place: string;
+ followUp: FollowUp;
+ }> = [];
+ let emittedResultCount = 0;
+
// Process each place
for (let i = 0; i < places.length; i++) {
if (signal.aborted) break;
@@ -152,6 +178,8 @@ export async function POST(request: NextRequest) {
parsed,
atlasContext,
ancestorChain,
+ followUpContext,
+ noExplicitPlace,
geminiTools,
signal,
onToolCall: (e) => {
@@ -195,7 +223,18 @@ export async function POST(request: NextRequest) {
continue;
}
const variables = parsedResponse.variables || [];
- if (variables.length === 0 && !parsedResponse.followUp) {
+
+ // If the model returned a follow-up signal (disambiguation / fallback),
+ // stash it for post-loop synthesis instead of emitting per-place.
+ if (parsedResponse.followUp && variables.length === 0) {
+ disambiguationEntries.push({
+ place,
+ followUp: parsedResponse.followUp,
+ });
+ continue;
+ }
+
+ if (variables.length === 0) {
emit({
type: STREAM_EVENT.placeSkipped,
place,
@@ -204,7 +243,7 @@ export async function POST(request: NextRequest) {
continue;
}
const entityDcid = parsedResponse.placeDcid || resolvedPlaceDcid;
- if (!entityDcid && !parsedResponse.followUp) {
+ if (!entityDcid) {
emit({
type: STREAM_EVENT.placeSkipped,
place,
@@ -219,16 +258,12 @@ export async function POST(request: NextRequest) {
message: STATUS.loadingTimeSeries(placeLabel, variables.length),
});
- const timeSeries = entityDcid
- ? await Promise.all(
- variables.map((v) =>
- fetchTimeSeries(v.dcid, entityDcid, signal),
- ),
- )
- : [];
- const entities = entityDcid
- ? [{ dcid: entityDcid, name: parsedResponse.placeName || place }]
- : [];
+ const timeSeries = await Promise.all(
+ variables.map((v) => fetchTimeSeries(v.dcid, entityDcid, signal)),
+ );
+ const entities = [
+ { dcid: entityDcid, name: parsedResponse.placeName || place },
+ ];
const discoveryResult: QueryResult = {
id: nanoid(),
title:
@@ -246,7 +281,6 @@ export async function POST(request: NextRequest) {
coverage: parsedResponse.coverage,
insights: parsedResponse.insights,
relatedQueries: parsedResponse.relatedQueries,
- followUp: parsedResponse.followUp,
};
const { tableHtml, notesHtml } = renderResultHtml(discoveryResult);
@@ -258,6 +292,52 @@ export async function POST(request: NextRequest) {
result: discoveryResult,
place,
});
+ emittedResultCount++;
+ }
+
+ // ─── Post-loop: synthesize a single follow-up if needed ──────────
+ const firstDisambiguation = disambiguationEntries[0];
+ if (firstDisambiguation) {
+ // Merge all disambiguation signals into one follow-up.
+ // Use the first entry as the base and combine options.
+ const base = firstDisambiguation.followUp;
+ const mergedFollowUp: FollowUp = {
+ summary: base.summary,
+ question: base.question,
+ options: base.options,
+ };
+
+ // If multiple places triggered disambiguation, merge summaries.
+ if (disambiguationEntries.length > 1) {
+ mergedFollowUp.summary = deduplicateStrings(
+ disambiguationEntries
+ .map((e) => e.followUp.summary)
+ .filter(Boolean),
+ ).join(' ');
+ mergedFollowUp.options = deduplicateStrings(
+ disambiguationEntries.flatMap((e) => e.followUp.options),
+ ).slice(0, 4);
+ }
+
+ // Strip options when no explicit place was provided —
+ // the question should only ask the user to specify a place.
+ if (noExplicitPlace) {
+ mergedFollowUp.options = [];
+ }
+
+ emit({ type: STREAM_EVENT.followUp, data: mergedFollowUp });
+ } else if (emittedResultCount === 0) {
+ // Invariant: never emit nothing. If no results and no disambiguation,
+ // send a generic fallback follow-up.
+ emit({
+ type: STREAM_EVENT.followUp,
+ data: {
+ summary: '',
+ question:
+ 'I wasn\u2019t able to find relevant data for your query. Could you try rephrasing or being more specific about a place or topic?',
+ options: [],
+ },
+ });
}
emit({ type: STREAM_EVENT.complete, message: STATUS.complete });
diff --git a/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx
index 20d2f5e4..03937b6e 100644
--- a/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx
+++ b/dataweaver/apps/web/src/components/elements/card/chart/chart.tsx
@@ -42,7 +42,7 @@ export interface CardChartProps extends CardState {
// dataset. Let's make it more generic once we have real data to work with
data?: ChartDatum[];
facets?: FacetInfo[];
- relatedQuery?: string;
+ relatedQueries?: string[];
}
export const CardChart = ({
@@ -53,7 +53,7 @@ export const CardChart = ({
description,
data,
facets,
- relatedQuery,
+ relatedQueries,
}: CardChartProps) => {
const editor = useEditor();
@@ -146,18 +146,21 @@ export const CardChart = ({
)}
- {relatedQuery && !isLoading && (
+ {relatedQueries && relatedQueries.length > 0 && !isLoading && (
-
+ {relatedQueries.map((query) => (
+
+ ))}
)}
diff --git a/dataweaver/apps/web/src/components/elements/card/footer.module.scss b/dataweaver/apps/web/src/components/elements/card/footer.module.scss
index f49860e9..d06d0c3c 100644
--- a/dataweaver/apps/web/src/components/elements/card/footer.module.scss
+++ b/dataweaver/apps/web/src/components/elements/card/footer.module.scss
@@ -1,4 +1,8 @@
.container {
+ display: flex;
flex-shrink: 0;
+ flex-flow: column;
+ gap: 8px;
+ align-items: flex-start;
padding: 0 28px 18px;
}
diff --git a/dataweaver/apps/web/src/components/elements/card/text.tsx b/dataweaver/apps/web/src/components/elements/card/text.tsx
index ee0812ca..ad1bc182 100644
--- a/dataweaver/apps/web/src/components/elements/card/text.tsx
+++ b/dataweaver/apps/web/src/components/elements/card/text.tsx
@@ -41,7 +41,7 @@ export interface CardTextProps extends CardState {
/** HTML string rendered via `html-react-parser` (sanitized/filtered). */
body?: string;
- relatedQuery?: string;
+ relatedQueries?: string[];
}
export const CardText = ({
@@ -50,7 +50,7 @@ export const CardText = ({
selection,
title,
body,
- relatedQuery,
+ relatedQueries,
}: CardTextProps) => {
const editor = useEditor();
@@ -106,18 +106,21 @@ export const CardText = ({
)}
- {relatedQuery && !isLoading && (
+ {relatedQueries && relatedQueries.length > 0 && !isLoading && (
-
+ {relatedQueries.map((query) => (
+
+ ))}
)}
diff --git a/dataweaver/apps/web/src/components/scopes/atlas/helpers.ts b/dataweaver/apps/web/src/components/scopes/atlas/helpers.ts
index 4d9605af..f9e8fbf4 100644
--- a/dataweaver/apps/web/src/components/scopes/atlas/helpers.ts
+++ b/dataweaver/apps/web/src/components/scopes/atlas/helpers.ts
@@ -5,7 +5,7 @@ import type { CardTextProps } from '~/components/elements/card/text';
import { CARD_VARIANT_MAX } from './config';
interface BaseContent extends Partial> {
- relatedQuery?: string;
+ relatedQueries?: string[];
}
interface TextContent
@@ -67,7 +67,7 @@ export const contentToShape = (
// worst-case space up front means initial placement never overlaps neighbor
...CARD_VARIANT_MAX[content.variant],
isLoading: content.isLoading ?? false,
- relatedQuery: content.relatedQuery,
+ relatedQueries: content.relatedQueries,
};
if (content.variant === 'chart') {
diff --git a/dataweaver/apps/web/src/components/scopes/atlas/query_provider.tsx b/dataweaver/apps/web/src/components/scopes/atlas/query_provider.tsx
index 3906a00a..dba046a8 100644
--- a/dataweaver/apps/web/src/components/scopes/atlas/query_provider.tsx
+++ b/dataweaver/apps/web/src/components/scopes/atlas/query_provider.tsx
@@ -9,7 +9,8 @@ import {
useRef,
} from 'react';
import { toast } from '~/components/foundations/toaster/store';
-import type { CardEntry, StreamEvent } from '~/server/types';
+
+import type { CardEntry, FollowUpContext, StreamEvent } from '~/server/types';
import { STATUS, STREAM_EVENT } from '~/server/types';
import { useAtlasStore } from '~/store';
import { useAtlas } from './atlas_provider';
@@ -62,6 +63,7 @@ export const QueryProvider = ({ children }: QueryProviderProps) => {
nodeSetParsedQuery,
querySetStatus,
nodeAddResult,
+ nodeSetFollowUp,
cardRegisterBatch,
queryComplete,
queryFail,
@@ -85,12 +87,6 @@ export const QueryProvider = ({ children }: QueryProviderProps) => {
// Write the query result data to the history node.
nodeAddResult(active.nodeId, entityDcid, result);
- if (result.followUp) {
- // If the api has returned a follow-up question, we skip card registration here,
- // as the FollowUp component will be rendered instead of new cards
- return;
- }
-
// Batch-register result cards.
const resultEntries: CardEntry[] = [
{
@@ -151,6 +147,11 @@ export const QueryProvider = ({ children }: QueryProviderProps) => {
querySetStatus(event.reason);
break;
}
+
+ case STREAM_EVENT.followUp: {
+ nodeSetFollowUp(active.nodeId, event.data);
+ break;
+ }
}
}, []);
@@ -161,6 +162,7 @@ export const QueryProvider = ({ children }: QueryProviderProps) => {
() => ({
runPrompt: (prompt: string) => {
const {
+ nodes,
queryStart,
querySetProcessing,
querySetStatus,
@@ -181,11 +183,40 @@ export const QueryProvider = ({ children }: QueryProviderProps) => {
places: node.parsedQuery?.places ?? [],
}));
+ // If the parent node had a followUp, build structured context
+ // instead of concatenating into the query string.
+ let followUpContext: FollowUpContext | undefined;
+ if (parentNodeId) {
+ const parentNode = nodes[parentNodeId];
+ const parentFollowUp = parentNode?.followUp;
+
+ if (parentFollowUp && parentNode) {
+ if (parentNode.followUpContext) {
+ // Extend existing chain
+ followUpContext = {
+ originalQuery: parentNode.followUpContext.originalQuery,
+ followUps: [
+ ...parentNode.followUpContext.followUps,
+ { question: parentFollowUp.question, answer: prompt },
+ ],
+ };
+ } else {
+ // Start a new chain
+ followUpContext = {
+ originalQuery: parentNode.query,
+ followUps: [
+ { question: parentFollowUp.question, answer: prompt },
+ ],
+ };
+ }
+ }
+ }
+
// Derive selected entity dcids from selected cards via store
const selectedEntityDcids = getSelectedEntityDcids(selectedShapeIds);
// Create history node in store
- const nodeId = queryStart(prompt, null, parentNodeId);
+ const nodeId = queryStart(prompt, null, parentNodeId, followUpContext);
// Store active query state for the stream handler
activeQueryRef.current = {
@@ -205,6 +236,7 @@ export const QueryProvider = ({ children }: QueryProviderProps) => {
atlasContext,
ancestorChain,
selectedEntityDcids,
+ followUpContext,
});
},
queryCancel: () => {
diff --git a/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx b/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx
index 19994104..1854ff27 100644
--- a/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx
+++ b/dataweaver/apps/web/src/components/scopes/atlas/shapes/card.tsx
@@ -54,7 +54,7 @@ export class ShapeCardUtil extends ShapeUtil {
}),
).optional(),
isLoading: T.boolean.optional(),
- relatedQuery: T.string.optional(),
+ relatedQueries: T.arrayOf(T.string).optional(),
};
override getDefaultProps = (): ShapeCardProps => {
@@ -79,7 +79,7 @@ export class ShapeCardUtil extends ShapeUtil {
// Each card variant owns its own actions, content and footer; the shape just
// hands it the state and content it needs to render itself.
#renderCard = (shape: ShapeCard) => {
- const { variant, title, description, body, data, relatedQuery } =
+ const { variant, title, description, body, data, relatedQueries } =
shape.props;
const isLoading = shape.props.isLoading ?? false;
@@ -93,7 +93,7 @@ export class ShapeCardUtil extends ShapeUtil {
selection={selection}
title={title}
body={body}
- relatedQuery={relatedQuery}
+ relatedQueries={relatedQueries}
/>
);
}
@@ -107,7 +107,7 @@ export class ShapeCardUtil extends ShapeUtil {
title={title}
description={description}
data={data}
- relatedQuery={relatedQuery}
+ relatedQueries={relatedQueries}
/>
);
}
diff --git a/dataweaver/apps/web/src/components/scopes/atlas/sync_store.ts b/dataweaver/apps/web/src/components/scopes/atlas/sync_store.ts
index 95413967..00647fbe 100644
--- a/dataweaver/apps/web/src/components/scopes/atlas/sync_store.ts
+++ b/dataweaver/apps/web/src/components/scopes/atlas/sync_store.ts
@@ -30,7 +30,7 @@ export const deriveNotesContent = (result: QueryResult): AtlasContent => ({
title: `${result.title} • Notes`,
body: result.notesHtml ?? '',
isLoading: false,
- relatedQuery: result.relatedQueries?.[0],
+ relatedQueries: result.relatedQueries,
});
/** Derive AtlasContent for a chart card from a QueryResult (first variable's facets). */
diff --git a/dataweaver/apps/web/src/components/scopes/page_home/follow_up.tsx b/dataweaver/apps/web/src/components/scopes/page_home/follow_up.tsx
index 2b121c2d..38c4d8b2 100644
--- a/dataweaver/apps/web/src/components/scopes/page_home/follow_up.tsx
+++ b/dataweaver/apps/web/src/components/scopes/page_home/follow_up.tsx
@@ -2,8 +2,11 @@
import { EASE_LINEAR } from '@package/tokens/ts';
import { m } from 'motion/react';
+import { useMemo } from 'react';
import { Button } from '~/components/elements/button';
import { IconClose } from '~/components/primitives/icons/close';
+import { EXAMPLE_PROMPTS } from '~/configs/example_prompts';
+import { shuffleArray } from '~/functions/shuffle_array';
import type { FollowUp as FollowUpData } from '~/server/types';
import s from './follow_up.module.scss';
@@ -20,6 +23,12 @@ export const FollowUp = ({
onSelect,
onClose,
}: FollowUpProps) => {
+ const options = useMemo(() => {
+ return followUp?.options?.length
+ ? followUp.options
+ : shuffleArray(EXAMPLE_PROMPTS).slice(0, 4);
+ }, [followUp?.options]);
+
return (
{prompt}
-
{followUp.summary}
{followUp.question}
- {followUp?.options?.length > 0 && (
+ {options.length > 0 && (
- {followUp.options.map((option) => (
+ {options.map((option) => (
-
- {EXAMPLE_PROMPTS.map((example, index) => (
+ {prompts.map((example, index) => (
-