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) => (