Skip to content

Commit 0c26af3

Browse files
committed
fix(llmobs): address dataset review feedback
1 parent e3f8e89 commit 0c26af3

4 files changed

Lines changed: 169 additions & 21 deletions

File tree

packages/dd-trace/src/llmobs/experiments/dataset.js

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
const { API_BASE_PATH } = require('./client')
44

5-
// Immutable dataset record: { input, expectedOutput?, metadata?, id? }.
5+
// Dataset record: { input, expectedOutput?, metadata?, id? }.
6+
// `id` may be user-provided before push or filled from the backend-created record.
67
class DatasetRecord {
78
constructor (input, expectedOutput = null, metadata = {}, id = null) {
89
this.input = input
@@ -12,6 +13,24 @@ class DatasetRecord {
1213
}
1314
}
1415

16+
function createdRecordsFromResponse (response) {
17+
if (Array.isArray(response?.records)) return response.records
18+
if (Array.isArray(response?.data)) return response.data
19+
return []
20+
}
21+
22+
function recordIdFromCreatedRecord (record) {
23+
return String(record?.id ?? record?.attributes?.id ?? '')
24+
}
25+
26+
function versionFromCreatedRecords (records) {
27+
const versions = records
28+
.map(record => Number(record?.attributes?.valid_from_version ?? record?.attributes?.version))
29+
.filter(Number.isFinite)
30+
if (versions.length === 0) return null
31+
return Math.max(...versions)
32+
}
33+
1534
// A local buffer of dataset records, created remotely and pushed on first run
1635
// (or eagerly via push()). Pushes are incremental.
1736
class Dataset {
@@ -115,6 +134,9 @@ class Dataset {
115134
throw new Error(`Failed to create dataset '${this.#name}': ${err.message}`)
116135
}
117136
this.#id = response?.data?.id ?? null
137+
if (this.#id === null) {
138+
throw new Error(`Failed to create dataset '${this.#name}': backend response is missing dataset id`)
139+
}
118140
this.#projectId = projectId
119141
this.#version = response?.data?.attributes?.current_version ?? this.#version
120142
this.#latestVersion = response?.data?.attributes?.current_version ?? this.#latestVersion
@@ -148,20 +170,30 @@ class Dataset {
148170
throw new Error(`Failed to push records to dataset '${this.#name}': ${err.message}`)
149171
}
150172

151-
// The append-records response returns created records under a top-level
152-
// `records` field, not the usual `data` envelope.
153-
const created = response?.records
173+
// The append-records response has used both a top-level `records` array
174+
// and JSON:API `data` resources. Accept either so generated/custom record
175+
// ids are preserved for experiment row tagging.
176+
const created = createdRecordsFromResponse(response)
177+
const pushedVersion = versionFromCreatedRecords(created)
178+
if (pushedVersion === null) {
179+
// The dataset contents changed, but the backend did not report the new
180+
// version. Avoid pinning later experiments to the pre-append create version.
181+
this.#version = null
182+
} else {
183+
this.#version = pushedVersion
184+
this.#latestVersion = Math.max(Number(this.#latestVersion ?? pushedVersion), pushedVersion)
185+
}
186+
154187
let pushedCount = 0
155-
if (Array.isArray(created)) {
156-
for (const node of created) {
157-
const recordId = String(node?.id ?? '')
158-
if (recordId !== '') pushedCount++
159-
this.#recordIds.push(recordId)
188+
for (const [index, node] of created.entries()) {
189+
const recordId = recordIdFromCreatedRecord(node)
190+
if (recordId !== '') {
191+
pushedCount++
192+
pending[index].id = recordId
160193
}
161-
for (let i = created.length; i < pending.length; i++) this.#recordIds.push('')
162-
} else {
163-
for (let i = 0; i < pending.length; i++) this.#recordIds.push('')
194+
this.#recordIds.push(recordId)
164195
}
196+
for (let i = created.length; i < pending.length; i++) this.#recordIds.push('')
165197

166198
// Advance by the snapshotted pending count, not the live records length,
167199
// so records added while this push was in flight aren't skipped by the next push.

packages/dd-trace/src/llmobs/experiments/index.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ function recordsFromCsv (csvPath, options) {
6969
delimiter: csvDelimiter,
7070
bom: true,
7171
relaxColumnCount: true,
72+
skipEmptyLines: true,
7273
})
7374
const header = rows.shift()
7475
if (!header || header.every(column => column === '')) {
@@ -98,7 +99,7 @@ function recordsFromCsv (csvPath, options) {
9899
for (let i = 0; i < header.length; i++) row[header[i]] = values[i] ?? ''
99100
return {
100101
inputData: selectedColumns(row, inputDataColumns),
101-
expectedOutput: selectedColumns(row, expectedOutputColumns),
102+
expectedOutput: expectedOutputColumns.length === 0 ? undefined : selectedColumns(row, expectedOutputColumns),
102103
metadata: selectedColumns(row, metadataColumns),
103104
id: idColumn === undefined ? undefined : row[idColumn],
104105
}
@@ -189,7 +190,7 @@ class Experiments {
189190
for (;;) {
190191
const query = new URLSearchParams()
191192
if (cursor) query.set('page[cursor]', cursor)
192-
if (version !== undefined && version !== null) query.set('filter[version]', String(version))
193+
if (datasetVersion !== null) query.set('filter[version]', String(datasetVersion))
193194
const queryString = query.toString() ? `?${query.toString()}` : ''
194195
// eslint-disable-next-line no-await-in-loop
195196
const resp = await this.#client.request(
@@ -198,8 +199,14 @@ class Experiments {
198199
)
199200
for (const item of resp?.data ?? []) {
200201
const attrs = item?.attributes ?? item
201-
recs.push(new DatasetRecord(attrs?.input ?? null, attrs?.expected_output ?? null, attrs?.metadata ?? {}))
202-
ids.push(String(item?.id ?? ''))
202+
const recordId = String(item?.id ?? attrs?.id ?? '')
203+
recs.push(new DatasetRecord(
204+
attrs?.input ?? null,
205+
attrs?.expected_output ?? null,
206+
attrs?.metadata ?? {},
207+
recordId === '' ? null : recordId
208+
))
209+
ids.push(recordId)
203210
}
204211
cursor = resp?.meta?.after ?? ''
205212
if (!cursor) break

packages/dd-trace/test/llmobs/experiments/experiment.spec.js

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { afterEach, beforeEach, describe, it } = require('mocha')
55
const sinon = require('sinon')
66

77
const { ExperimentsClient } = require('../../../src/llmobs/experiments/client')
8-
const { Dataset } = require('../../../src/llmobs/experiments/dataset')
8+
const { Dataset, DatasetRecord } = require('../../../src/llmobs/experiments/dataset')
99
const { Experiment } = require('../../../src/llmobs/experiments/experiment')
1010

1111
// Routes the control-plane + events calls a run makes, recording each request.
@@ -240,7 +240,6 @@ describe('LLMObs Experiments — dataset + experiment run', () => {
240240
})
241241

242242
it('experiment create body carries project_id, dataset_id, dataset_version, config and ensure_unique', async () => {
243-
const { DatasetRecord } = require('../../../src/llmobs/experiments/dataset')
244243
const dataset = Dataset.fromExisting(
245244
client,
246245
'demo',
@@ -267,6 +266,23 @@ describe('LLMObs Experiments — dataset + experiment run', () => {
267266
assert.deepEqual(create.body.data.attributes.config, { approach: 'kw' })
268267
})
269268

269+
it('uses the version returned by appending records when creating an experiment', async () => {
270+
installFetch(calls, {
271+
'POST /api/v2/llm-obs/v1/proj/datasets': { data: { id: 'ds', attributes: { current_version: 1 } } },
272+
'POST /api/v2/llm-obs/v1/proj/datasets/ds/records': {
273+
data: [{ id: 'rec-0', attributes: { valid_from_version: 2 } }],
274+
},
275+
})
276+
const dataset = new Dataset(client, 'demo').addRecord('x')
277+
278+
await new Experiment(client, { name: 'exp-demo', dataset, task: (i) => i }).run()
279+
280+
const create = calls.find(c =>
281+
c.method === 'POST' && c.path.endsWith('/experiments') && !c.path.includes('/events')
282+
)
283+
assert.equal(create.body.data.attributes.dataset_version, 2)
284+
})
285+
270286
it('validates required options', () => {
271287
const dataset = new Dataset(client, 'demo')
272288
assert.throws(() => new Experiment(client, { dataset, task: (i) => i }), /name/)
@@ -275,7 +291,6 @@ describe('LLMObs Experiments — dataset + experiment run', () => {
275291
})
276292

277293
it('exposes dataset getters and accepts a DatasetRecord instance', () => {
278-
const { DatasetRecord } = require('../../../src/llmobs/experiments/dataset')
279294
const dataset = new Dataset(client, 'my-name', 'desc').addRecord(new DatasetRecord('in', 'out', { m: 1 }))
280295
assert.equal(dataset.name(), 'my-name')
281296
assert.equal(dataset.id(), null)
@@ -292,12 +307,39 @@ describe('LLMObs Experiments — dataset + experiment run', () => {
292307
const result = await dataset.push()
293308
assert.deepEqual(dataset.recordIds(), ['', ''])
294309
assert.deepEqual(result, { pushedCount: 0, totalCount: 2 })
310+
assert.equal(dataset.version(), null)
295311
})
296312

297313
it('resolves with the pushed/total record counts on a successful push', async () => {
298314
const dataset = new Dataset(client, 'demo').addRecord('a').addRecord('b')
299315
const result = await dataset.push()
300316
assert.deepEqual(result, { pushedCount: 2, totalCount: 2 })
317+
assert.deepEqual(dataset.recordIds(), ['rec-0', 'rec-1'])
318+
assert.deepEqual(dataset.records().map(record => record.id), ['rec-0', 'rec-1'])
319+
})
320+
321+
it('submits custom record ids and keeps ids from JSON:API push responses', async () => {
322+
installFetch(calls, {
323+
'POST /api/v2/llm-obs/v1/proj/datasets/ds/records': {
324+
data: [
325+
{ id: 'custom-a', attributes: { valid_from_version: 2 } },
326+
{ id: 'custom-b', attributes: { valid_from_version: 2 } },
327+
],
328+
},
329+
})
330+
const dataset = new Dataset(client, 'demo')
331+
.addRecord(new DatasetRecord('a', null, {}, 'custom-a'))
332+
.addRecord(new DatasetRecord('b', null, {}, 'custom-b'))
333+
334+
const result = await dataset.push()
335+
336+
const recordsCall = calls.find(call => call.path === '/api/v2/llm-obs/v1/proj/datasets/ds/records')
337+
assert.deepEqual(recordsCall.body.data.attributes.records.map(record => record.id), ['custom-a', 'custom-b'])
338+
assert.deepEqual(dataset.recordIds(), ['custom-a', 'custom-b'])
339+
assert.deepEqual(dataset.records().map(record => record.id), ['custom-a', 'custom-b'])
340+
assert.equal(dataset.version(), 2)
341+
assert.equal(dataset.latestVersion(), 2)
342+
assert.deepEqual(result, { pushedCount: 2, totalCount: 2 })
301343
})
302344

303345
it('resolves with a lower pushedCount when the backend confirms fewer records than sent', async () => {
@@ -308,6 +350,8 @@ describe('LLMObs Experiments — dataset + experiment run', () => {
308350
const result = await dataset.push()
309351
assert.deepEqual(result, { pushedCount: 1, totalCount: 2 })
310352
assert.deepEqual(dataset.recordIds(), ['rec-0', ''])
353+
assert.deepEqual(dataset.records().map(record => record.id), ['rec-0', null])
354+
assert.equal(dataset.version(), null)
311355
})
312356

313357
it('resolves with zero counts when there is nothing new to push', async () => {
@@ -340,12 +384,12 @@ describe('LLMObs Experiments — dataset + experiment run', () => {
340384
assert.equal(experiment.url(), 'https://app.datadoghq.com/llm/experiments/exp')
341385
})
342386

343-
it('throws when the dataset has no id after push', async () => {
387+
it('throws when the dataset create response has no id', async () => {
344388
installFetch(calls, { 'POST /api/v2/llm-obs/v1/proj/datasets': { data: {} } })
345389
const dataset = new Dataset(client, 'demo').addRecord('x')
346390
await assert.rejects(
347391
() => new Experiment(client, { name: 'exp-demo', dataset, task: (i) => i }).run(),
348-
/has no id after push/
392+
/backend response is missing dataset id/
349393
)
350394
})
351395

packages/dd-trace/test/llmobs/experiments/index.spec.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,47 @@ describe('LLMObs Experiments facade', () => {
115115
}
116116
})
117117

118+
it('omits expected output and skips blank lines for input-only CSV datasets', async () => {
119+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-trace-llmobs-csv-'))
120+
const csvPath = path.join(dir, 'dataset.csv')
121+
fs.writeFileSync(csvPath, 'question\nhello\n\nworld\n')
122+
123+
const calls = []
124+
global.fetch.callsFake(async (url, opts) => {
125+
const u = new URL(url)
126+
const body = opts.body ? JSON.parse(opts.body) : undefined
127+
calls.push({ method: opts.method, path: u.pathname, body })
128+
let payload = {}
129+
if (u.pathname === '/api/v2/llm-obs/v1/projects') {
130+
payload = { data: { id: 'proj' } }
131+
} else if (u.pathname === '/api/v2/llm-obs/v1/proj/datasets') {
132+
payload = { data: { id: 'ds' } }
133+
} else if (u.pathname === '/api/v2/llm-obs/v1/proj/datasets/ds/records') {
134+
payload = { records: [{ id: 'rec-1' }, { id: 'rec-2' }] }
135+
}
136+
return { ok: true, status: 200, text: sinon.stub().resolves(JSON.stringify(payload)) }
137+
})
138+
139+
try {
140+
const dataset = createExperiments(enabledConfig()).createDatasetFromCsv(csvPath, 'csv-dataset', {
141+
inputDataColumns: ['question'],
142+
})
143+
assert.equal(dataset.records().length, 2)
144+
assert.deepEqual(dataset.records().map(record => record.input), [{ question: 'hello' }, { question: 'world' }])
145+
assert.deepEqual(dataset.records().map(record => record.expectedOutput), [null, null])
146+
147+
await dataset.push()
148+
149+
const recordsCall = calls.find(call => call.path === '/api/v2/llm-obs/v1/proj/datasets/ds/records')
150+
assert.deepEqual(recordsCall.body.data.attributes.records, [
151+
{ input: { question: 'hello' } },
152+
{ input: { question: 'world' } },
153+
])
154+
} finally {
155+
fs.rmSync(dir, { recursive: true, force: true })
156+
}
157+
})
158+
118159
it('validates CSV headers before creating a dataset', () => {
119160
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-trace-llmobs-csv-'))
120161
const csvPath = path.join(dir, 'dataset.csv')
@@ -207,6 +248,8 @@ describe('LLMObs Experiments facade', () => {
207248
assert.deepEqual(ds.records()[0].input, { q: '2+2' })
208249
assert.equal(ds.records()[0].expectedOutput, '4')
209250
assert.deepEqual(ds.records()[0].metadata, { a: 1 })
251+
assert.equal(ds.records()[0].id, 'r1')
252+
assert.equal(ds.records()[1].id, 'r2')
210253
})
211254

212255
it('passes explicit dataset version when reading records', async () => {
@@ -231,6 +274,28 @@ describe('LLMObs Experiments facade', () => {
231274
assert.equal(ds.latestVersion(), 7)
232275
})
233276

277+
it('pins the current version when pulling latest records', async () => {
278+
global.fetch.callsFake(async (url) => {
279+
const u = new URL(url)
280+
let payload
281+
if (u.pathname === '/api/v2/llm-obs/v1/projects') {
282+
payload = { data: { id: 'proj' } }
283+
} else if (u.pathname === '/api/v2/llm-obs/v1/proj/datasets') {
284+
payload = { data: [{ id: 'ds9', attributes: { name: 'wanted', description: 'd', current_version: 7 } }] }
285+
} else if (u.pathname === '/api/v2/llm-obs/v1/proj/datasets/ds9/records') {
286+
assert.equal(u.searchParams.get('filter[version]'), '7')
287+
payload = { data: [{ id: 'r1', attributes: { input: 'i1' } }] }
288+
} else {
289+
payload = {}
290+
}
291+
return { ok: true, status: 200, text: sinon.stub().resolves(JSON.stringify(payload)) }
292+
})
293+
294+
const ds = await createExperiments(enabledConfig()).pullDataset('wanted')
295+
assert.equal(ds.version(), 7)
296+
assert.equal(ds.records().length, 1)
297+
})
298+
234299
it('waits (backoff) until the expected record count is readable', async () => {
235300
const one = { data: [{ id: 'r1', attributes: { input: 'i1' } }] }
236301
const two = { data: [{ id: 'r1', attributes: { input: 'i1' } }, { id: 'r2', attributes: { input: 'i2' } }] }

0 commit comments

Comments
 (0)