Skip to content

Commit 859da3a

Browse files
authored
Merge pull request #12 from DevelopmentFinanceObservatory/release/website
Sync dfo-website@dd1f50d60809
2 parents 87d9f4c + b6d888a commit 859da3a

35 files changed

Lines changed: 2076 additions & 261 deletions

‎db/test/live-share.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,30 @@ test('live share routes', async (t) => {
529529
assert.equal(stub.to('/render/data').length, 0);
530530
});
531531

532+
// Node's fetch decodes a gzipped export and keeps its compressed length.
533+
await t.test(
534+
'data: a gzipped export streams every row without its compressed length',
535+
async () => {
536+
const id = await liveShare();
537+
const { deps } = over({
538+
refreshFrame: () => frameAnswer('current', DATASET_FRAME),
539+
storedExport: () =>
540+
new Response('year,value\n2024,1\n', {
541+
headers: {
542+
'content-type': 'text/csv',
543+
'content-encoding': 'gzip',
544+
'content-length': '9',
545+
},
546+
}),
547+
});
548+
549+
const file = await download(deps, id, 'data', 'format=csv&bundle=false');
550+
551+
assert.equal(file.headers.get('content-length'), null);
552+
assert.equal(await file.text(), 'year,value\n2024,1\n');
553+
},
554+
);
555+
532556
await t.test('data: no file while pending, on an error, or on a refused export', async () => {
533557
const id = await liveShare();
534558
const queued = over({

‎docs/analytics.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Usage events go to our own database, errors to Sentry, and page views to Google
44

55
## Usage events
66

7-
The events DFO Overview asks for (Data access → "Events to record") land in the `event` table (`db/schema.ts`, migration `0006_events`): one row per event with a dotted `name`, the event's own `props` (JSONB), `user_id` when signed in, a random per-browser `visitor_id` (localStorage, no cookie) when the visitor has consented, and the `page`. No IPs, no prompt text.
7+
The events DFO Overview asks for (Data access → "Events to record") land in the `event` table (`db/schema.ts`, migration `0006_events`): one row per event with a dotted `name`, the event's own `props` (JSONB), `user_id` when signed in, a random per-browser `visitor_id` (localStorage, no cookie) when the visitor has consented, and the `page`. No event holds an IP address or the text of a Studio prompt. The three search events hold the first 500 characters of the query.
88

99
The browser stores `visitor_id` only after the visitor accepts "DFO usage statistics" in the Analytics category of the cookie banner (Usercentrics service `L3eMCSMT3WELG7`, read through the CMP's `window.__ucCmp` API and `UC_CONSENT` event in `src/lib/consent.ts`). Until then, including while the banner is loading or blocked, events arrive with a null visitor id. Withdrawing consent deletes the stored id.
1010

‎src/app/(site)/ai-policy/page.tsx‎

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ export const metadata: Metadata = {
88
'How the DFO’s AI-assisted search works, where its figures come from, and what happens to what you type.',
99
};
1010

11-
// ONE's own text (legal's draft, "Website policy content - DFO"). Two
12-
// bullets of section 3 — retention and legal basis — are still blanks in
13-
// that draft, so they are not on the page yet: a policy page must not
14-
// publish a placeholder.
1511
const AiPolicyPage = () => (
1612
<main>
1713
<Policy title="AI Transparency">
@@ -29,6 +25,11 @@ const AiPolicyPage = () => (
2925
elimination of errors, and we encourage users to verify any figure used for
3026
high-stakes decisions against the cited primary source.
3127
</p>
28+
<p>
29+
The same data is available to other AI assistants, such as Claude and ChatGPT,
30+
through the DFO’s connector. There the DFO supplies the figures and their sources,
31+
and the assistant you chose writes the answer under its provider’s own terms.
32+
</p>
3233

3334
<h2>2. Data provenance and source grounding</h2>
3435
<p>
@@ -53,17 +54,52 @@ const AiPolicyPage = () => (
5354
system pipeline.
5455
</li>
5556
<li>
56-
<strong>Enterprise isolation.</strong> Natural language interactions are
57-
processed through isolated enterprise-tier channels. Your query strings, search
58-
history, and session parameters are confidential and are not used to train any
59-
public AI model.
57+
<strong>How questions are processed.</strong> The text of your question is sent
58+
to Google’s Gemini models, which read it to find the data you asked for and, in
59+
the Studio, to write the answer.
60+
</li>
61+
<li>
62+
<strong>What a search records.</strong> When you search on this site, we record
63+
the first 500 characters of your question together with the data it matched, to
64+
learn what people look for and where the data falls short. The record is tied to
65+
your account if you are signed in, and to a random identifier in your browser if
66+
you accepted usage statistics in the cookie banner. These records are held in
67+
the DFO’s own database and in PostHog, an analytics service hosted in the EU.
68+
</li>
69+
<li>
70+
<strong>Studio conversations.</strong> The Studio stores each conversation so
71+
that it can continue from one message to the next. If you are signed in, the
72+
conversation is also saved to your account until you delete it or delete your
73+
account. A technical record of each conversation, including its text, is kept in
74+
Langfuse, a monitoring service hosted in the EU, so that we can find and fix
75+
faults. Usage statistics record that a prompt was sent and how long it was, and
76+
leave out its text.
77+
</li>
78+
<li>
79+
<strong>Connected assistants.</strong> When you use the DFO from another
80+
assistant, the DFO receives the question and the data requests that assistant
81+
sends. It needs no account and is not told who you are. Charts and tables the
82+
assistant creates through the DFO are stored so that they can be shown, and
83+
anyone who has a chart’s link can open it.
6084
</li>
6185
<li>
62-
<strong>Data minimization.</strong> Query text is processed only to generate a
63-
response and is not linked to a persistent user profile beyond what’s needed to
64-
maintain your active session.
86+
<strong>Internet addresses.</strong> Our servers’ request logs record the
87+
internet address of each request, as on any website. Usage statistics do not
88+
include it.
6589
</li>
6690
</ul>
91+
<p>
92+
For a question about your data, or for help with the DFO, write to{' '}
93+
<a href="mailto:data@one.org">data@one.org</a>. ONE’s{' '}
94+
<a
95+
href="https://www.one.org/info/privacy-policy/"
96+
target="_blank"
97+
rel="noopener noreferrer"
98+
>
99+
privacy policy
100+
</a>{' '}
101+
covers the rest of this website.
102+
</p>
67103
</Policy>
68104
</main>
69105
);

‎src/app/mcp/[[...path]]/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const proxy = async (req: NextRequest, { params }: { params: Promise<{ path?: st
4545
bearer: MCP_TOKEN,
4646
caller: requestCaller(req.headers),
4747
unreachable: 'The MCP servers are unreachable.',
48+
// MCP responses pass through as the harness sent them.
49+
compress: false,
4850
});
4951
};
5052

‎src/components/organisms/BuildTools/BuildTools.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,22 @@ const PACKAGES_HREF = docsSectionHref('oda_reader');
1616
const boxes = [
1717
{
1818
title: 'REST API',
19-
text: 'Three core endpoints: search indicators, query observations, and availability to check data coverage',
19+
text: 'Turn a plain-English question into a dataset in CSV, Excel, or Parquet, with its sources and citation. Look up names and codes.',
2020
href: '/docs',
2121
},
2222
{
2323
title: 'Python Packages',
24-
text: 'Pull clean, sourced development finance data into your pipeline in minutes. Data refreshes automatically as DFO updates',
24+
text: 'Load OECD, IMF, and TOSSD data into pandas straight from the publisher, with our open-source packages.',
2525
href: PACKAGES_HREF,
2626
},
2727
{
2828
title: 'MCP Server',
29-
text: 'Add DFO to Claude, Cursor, or any MCP-compatible environment. All DFO tools become available in your session instantly',
29+
text: 'Connect the DFO to Claude, ChatGPT, or Gemini. Your assistant answers from DFO data and names the source of each number.',
3030
href: '/docs',
3131
},
3232
{
3333
title: 'Embed Anywhere',
34-
text: 'Drop DFO charts with live data directly into your own reports, dashboards, or other digital tools.',
34+
text: 'Paste iframe code from Studio into any web page to show a whole canvas or a single chart, on live data or as a snapshot.',
3535
href: '/docs',
3636
},
3737
];

‎src/components/organisms/DataSearch/DataSearch.module.scss‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,6 +1992,16 @@ span.slotMarked {
19921992
margin-top: rem(8);
19931993
}
19941994

1995+
// Why the part offers this reading, in the same small type as the
1996+
// closest-match lead.
1997+
.cardBasis {
1998+
@include font-size(14px, 1.3);
1999+
2000+
font-family: var(--font-dm-mono);
2001+
color: var(--color-sage);
2002+
letter-spacing: -0.01em;
2003+
}
2004+
19952005
.cardRetry {
19962006
margin-top: auto;
19972007
}

‎src/components/organisms/DataSearch/ReadingCards.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { TokenAbout } from './TokenAbout';
88

99
import Button from '@/components/atoms/Button/Button';
1010
import { isClosestMatch } from '@/lib/search/departures';
11-
import { readingOf, readingsSummary } from '@/lib/search/readings';
11+
import { basisNote, readingOf, readingsSummary } from '@/lib/search/readings';
1212
import { tokenAbout } from '@/lib/search/tokenAbout';
1313
import type { Part } from '@/lib/search/types';
1414
import { statVarsUnreadable } from '@/lib/search/view';
@@ -17,8 +17,9 @@ import { statVarsUnreadable } from '@/lib/search/view';
1717
* A candidates part's readings, one card per candidate spec: the reading's
1818
* heading and byline as plain text, since an edit to a reading the reader
1919
* has not chosen has nothing to apply to, and its measure's About block
20-
* shown open, since the definition is what tells two readings apart. The
21-
* card's one action picks the reading. The part's own summary plan is the
20+
* shown open, since the definition is what tells two readings apart. A
21+
* rival reading leads with why the part offers it (`basis`). The card's
22+
* one action picks the reading. The part's own summary plan is the
2223
* block's screen-reader text, and a closest-match part draws its lead once
2324
* above the cards.
2425
*/
@@ -50,6 +51,7 @@ const ReadingCards = ({
5051
return {
5152
spec,
5253
reading,
54+
note: basisNote(spec.basis),
5355
about: tokenAbout(measure, spec, reading.statedElsewhere),
5456
};
5557
}),
@@ -69,7 +71,7 @@ const ReadingCards = ({
6971
{readingsSummary(part.sentence_plan)}
7072
</p>
7173
<ul className={styles.cards}>
72-
{readings.map(({ spec, reading, about }, index) => (
74+
{readings.map(({ spec, reading, note, about }, index) => (
7375
<li
7476
key={spec.spec_id}
7577
className={styles.readingCard}
@@ -91,6 +93,7 @@ const ReadingCards = ({
9193
</>
9294
) : (
9395
<>
96+
{note && <p className={styles.cardBasis}>{note}</p>}
9497
<div aria-hidden="true">
9598
<p className={styles.cardTitle}>{reading.heading}</p>
9699
{reading.byline && (

‎src/components/organisms/DataSearch/useSearchRun.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,9 +529,10 @@ export const useSearchRun = () => {
529529
});
530530
}
531531
break;
532-
// `shape` carries variable_index too (would map the
533-
// same way as `step`), but nothing renders its
534-
// payload. A deliberate no-op.
532+
// `frame` and `shape` carry variable_index too (would
533+
// map the same way as `step`), but nothing renders
534+
// their payloads. A deliberate no-op.
535+
case 'frame':
535536
case 'shape':
536537
break;
537538
case 'sentence':

‎src/components/organisms/Studio/canvas/Whiteboard.module.scss‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@
172172
}
173173

174174
// The resize grip: a corner glyph at the card's bottom-right, hover-only.
175-
// Dragging it resizes the card cell by cell within its kind's range.
175+
// Dragging it resizes the card cell by cell within its kind's range. On a
176+
// chart view it changes the width alone (the height follows the chart).
176177
.resizeGrip {
177178
@include flex(row, center, center);
178179

@@ -200,6 +201,10 @@
200201
}
201202
}
202203

204+
.resizeGripWidth {
205+
cursor: ew-resize;
206+
}
207+
203208
.slot:hover .resizeGrip {
204209
opacity: 1;
205210
}

‎src/components/organisms/Studio/canvas/Whiteboard.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
effectiveKind,
1515
FILTER_KIND,
1616
GAP,
17+
heightIsDerived,
1718
resolveCardView,
1819
widgetTitle,
1920
} from './layout';
@@ -149,6 +150,7 @@ const Whiteboard = ({
149150
const { slots, setSlots, fitRows, raise } = useSlots({
150151
widgets,
151152
pinnedIds,
153+
views,
152154
initialLayout,
153155
viewCols,
154156
onLayoutChange,
@@ -422,6 +424,9 @@ const Whiteboard = ({
422424
chartSource,
423425
} = resolveCardView(widget, views[widget.id]);
424426
const isText = widget.kind === 'text';
427+
// A chart view's height follows its content, so its grip
428+
// changes the width alone.
429+
const widthOnly = heightIsDerived(widget, cardView);
425430
const dimmed = !!dimKind && effectiveKind(widget, views[widget.id]) !== dimKind;
426431
const extras: CardExtras = {
427432
pinned: pinnedIds.includes(widget.id),
@@ -551,9 +556,12 @@ const Whiteboard = ({
551556
)}
552557
{!isLeaving && !readOnly && !isText && (
553558
<span
554-
className={styles.resizeGrip}
559+
className={classNames(
560+
styles.resizeGrip,
561+
widthOnly && styles.resizeGripWidth,
562+
)}
555563
data-nowave=""
556-
onPointerDown={resizing.startResize(widget.id)}
564+
onPointerDown={resizing.startResize(widget.id, widthOnly)}
557565
onPointerMove={resizing.moveResize}
558566
onPointerUp={resizing.endResize}
559567
onPointerCancel={resizing.endResize}
@@ -570,6 +578,7 @@ const Whiteboard = ({
570578
scale={f}
571579
theme={theme}
572580
widthCells={slot.w}
581+
heightCells={slot.h}
573582
onFit={(rows) => fitRows(widget.id, rows)}
574583
extras={extras}
575584
/>

0 commit comments

Comments
 (0)