Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion dataweaver/apps/web/src/app/(atlas)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <PageHome examplePrompts={prompts} />;
};

export default Page;
118 changes: 99 additions & 19 deletions dataweaver/apps/web/src/app/api/query/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' }), {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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'];
Comment thread
sagrimson marked this conversation as resolved.
parsed.places = places;

emit({ type: STREAM_EVENT.parsedQuery, data: parsed });
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -152,6 +178,8 @@ export async function POST(request: NextRequest) {
parsed,
atlasContext,
ancestorChain,
followUpContext,
noExplicitPlace,
geminiTools,
signal,
onToolCall: (e) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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);
Expand All @@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry that this will result in a long list of potentially repetitive or disjointed text, as well as duplicated options. Perhaps this should be run through a follow-up synthesis pass through the LLM to make it a single, coherent summary. However, I would put that aside for now until we have a chance to experiement.

Separately (and more actionable right away), right now we are not dedupping, which would help (but not totally remove the issue, because the two summaries might have similar, but not identical follow-up options).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added deduping, but I think you're right this could warrant another llm pass

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 });
Expand Down
29 changes: 16 additions & 13 deletions dataweaver/apps/web/src/components/elements/card/chart/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -53,7 +53,7 @@ export const CardChart = ({
description,
data,
facets,
relatedQuery,
relatedQueries,
}: CardChartProps) => {
const editor = useEditor();

Expand Down Expand Up @@ -146,18 +146,21 @@ export const CardChart = ({
)}
</div>

{relatedQuery && !isLoading && (
{relatedQueries && relatedQueries.length > 0 && !isLoading && (
<Card.Footer>
<Button
size="small"
variant="flat"
tone="accent-subtle"
icon={IconPencil}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => runPrompt(relatedQuery)}
>
{relatedQuery}
</Button>
{relatedQueries.map((query) => (
<Button
key={query}
size="small"
variant="flat"
tone="accent-subtle"
icon={IconPencil}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => runPrompt(query)}
>
{query}
</Button>
))}
</Card.Footer>
)}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.container {
display: flex;
flex-shrink: 0;
flex-flow: column;
gap: 8px;
align-items: flex-start;
padding: 0 28px 18px;
}
29 changes: 16 additions & 13 deletions dataweaver/apps/web/src/components/elements/card/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -50,7 +50,7 @@ export const CardText = ({
selection,
title,
body,
relatedQuery,
relatedQueries,
}: CardTextProps) => {
const editor = useEditor();

Expand Down Expand Up @@ -106,18 +106,21 @@ export const CardText = ({
)}
</div>

{relatedQuery && !isLoading && (
{relatedQueries && relatedQueries.length > 0 && !isLoading && (
<Card.Footer>
<Button
size="small"
variant="flat"
tone="accent-subtle"
icon={IconPencil}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => runPrompt(relatedQuery)}
>
{relatedQuery}
</Button>
{relatedQueries.map((query) => (
<Button
key={query}
size="small"
variant="flat"
tone="accent-subtle"
icon={IconPencil}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => runPrompt(query)}
>
{query}
</Button>
))}
</Card.Footer>
)}
</Card.Base>
Expand Down
4 changes: 2 additions & 2 deletions dataweaver/apps/web/src/components/scopes/atlas/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { CardTextProps } from '~/components/elements/card/text';
import { CARD_VARIANT_MAX } from './config';

interface BaseContent extends Partial<Pick<CardState, 'isLoading'>> {
relatedQuery?: string;
relatedQueries?: string[];
}

interface TextContent
Expand Down Expand Up @@ -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') {
Expand Down
Loading