Files
supabase/apps/studio/evals/scorer.test.ts
Charis 7a2e237892 Add update_notebook evals; fix prompt gaps they surfaced (#49324)
## Summary
- Add `update_notebook` eval cases (insert/replace/delete/move, a
combined delete+insert, and guard/safety cases) mirroring the existing
`create_notebook` cases, targeting the notebooks already seeded in the
mock tool harness.
- Fix two behavior gaps in `NOTEBOOKS_PROMPT`/`LIMITATIONS_PROMPT` that
these cases surfaced when run live: the assistant asking the user for a
notebook id instead of resolving it via `list_notebooks`, and the
destructive-operations warning rule not being connected to SQL written
into notebook cells.
- Soften the destructive-SQL case's `correctAnswer` to match
`update_notebook`'s real approval-gated behavior — a warning
accompanying the reported change is acceptable, not only one strictly
preceding the tool call.

## Test plan
- [x] `pnpm run typecheck` (apps/studio) — clean
- [x] `pnpm exec prettier --check` on both changed files — clean
- [x] `evals/scorer.test.ts`, `evals/transcript.test.ts`,
`evals/trace-utils.test.ts` — 21/21 pass
- [x] Ran the new eval cases live against OpenAI (bypassing the
Braintrust proxy) via Braintrust MCP; confirmed via trace inspection
that the prompt fix resolved the id-resolution gap (Tool Usage 0% → 100%
across 3 trials) and that the assistant now includes an explicit
irreversibility warning when destructive SQL is written into a notebook
cell

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Improved notebook creation and editing support across SQL, query,
chart, and time-range cells.
- Added clearer handling for saved notebooks, recurring requests, and
one-time SQL execution.
  - Enhanced validation for database cells and notebook configuration.

- **Bug Fixes**
- Improved safeguards and warnings for destructive queries, including
saved notebook queries.
  - Better handling of missing tables and notebooks.
  - More precise notebook cell updates and tool usage validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-21 08:39:46 -04:00

194 lines
7.2 KiB
TypeScript

import type { Trace } from 'braintrust'
import { describe, expect, it, vi } from 'vitest'
import {
toolUsageScorer,
urlValidityScorer,
type AssistantEvalOutput,
type Expected,
} from './scorer'
import type { Transcript } from './transcript'
const DOCS_URL = 'https://supabase.com/docs/guides/auth'
/**
* Minimal stand-in for a live Trace. Only getThread is exercised by the online
* fallback path (see resolveTranscript in scorer.ts).
*/
const mockTrace = (thread: unknown[]) => {
const getThread = vi.fn().mockResolvedValue(thread)
return { trace: { getThread } as unknown as Trace, getThread }
}
const THREAD_WITH_DOCS_URL = [
{ role: 'user', content: 'Where are the auth docs?' },
{ role: 'assistant', content: [{ type: 'text', text: `See ${DOCS_URL} for details.` }] },
]
const transcript = (lastAssistantTurn: string): Transcript => ({
currentUserInput: 'Where are the auth docs?',
priorConversation: null,
lastAssistantTurn,
lastAssistantTurnWithToolInputs: lastAssistantTurn,
})
const runUrlValidityScorer = (output: AssistantEvalOutput | null, trace?: Trace) =>
urlValidityScorer({
input: { prompt: 'Where are the auth docs?' },
expected: {},
output,
trace,
})
/**
* Minimal stand-in for a live Trace exposing only the tool spans
* toolUsageScorer reads via getToolSpans. `input`, when provided as a plain
* object, passes straight through getToolSpanInput's AI-SDK-tuple unwrap
* (which only fires for array-shaped span.input), so it lands as-is.
*/
const mockToolTrace = (calls: Array<{ name: string; input?: unknown }>) => {
const getSpans = vi
.fn()
.mockResolvedValue(calls.map(({ name, input }) => ({ span_attributes: { name }, input })))
return { trace: { getSpans } as unknown as Trace }
}
const runToolUsageScorer = (expected: Expected, trace?: Trace) =>
toolUsageScorer({ input: { prompt: 'irrelevant' }, expected, output: null, trace })
describe('scorers with online (null) output', () => {
// Online scorers run against live production logs, which have no eval task
// and therefore no output at all — Braintrust passes null.
it('returns null instead of throwing when there is neither output nor trace', async () => {
await expect(runUrlValidityScorer(null)).resolves.toBeNull()
})
it('derives the transcript from the trace when output is null', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 })
vi.stubGlobal('fetch', fetchMock)
const { trace, getThread } = mockTrace(THREAD_WITH_DOCS_URL)
const result = await runUrlValidityScorer(null, trace)
expect(getThread).toHaveBeenCalled()
expect(result).toMatchObject({ name: 'URL Validity', score: 1, metadata: { urls: [DOCS_URL] } })
vi.unstubAllGlobals()
})
it('prefers the offline task transcript over the trace when both are present', async () => {
const { trace, getThread } = mockTrace(THREAD_WITH_DOCS_URL)
const output = {
finishReason: 'stop' as const,
transcript: transcript('No links here, just prose.'),
}
// No supabase URLs in the offline transcript, so the scorer opts out.
await expect(runUrlValidityScorer(output, trace)).resolves.toBeNull()
expect(getThread).not.toHaveBeenCalled()
})
})
describe('toolUsageScorer', () => {
it('returns null when expected specifies neither requiredTools nor forbiddenTools', async () => {
const { trace } = mockToolTrace([])
await expect(runToolUsageScorer({}, trace)).resolves.toBeNull()
})
it('returns null when requiredTools is an empty array and forbiddenTools is unset', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql' }])
await expect(runToolUsageScorer({ requiredTools: [] }, trace)).resolves.toBeNull()
})
it('returns null when forbiddenTools is an empty array and requiredTools is unset', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql' }])
await expect(runToolUsageScorer({ forbiddenTools: [] }, trace)).resolves.toBeNull()
})
it('returns null when there is no trace', async () => {
await expect(runToolUsageScorer({ requiredTools: ['execute_sql'] })).resolves.toBeNull()
})
it('scores 1 when every required tool is called and no forbidden tool is called', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql' }, { name: 'list_tables' }])
const result = await runToolUsageScorer(
{ requiredTools: ['execute_sql', 'list_tables'], forbiddenTools: ['execute_sql_v2'] },
trace
)
expect(result).toMatchObject({ name: 'Tool Usage', score: 1 })
})
it('gives partial credit for a missing required tool rather than scoring 0', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql' }])
const result = await runToolUsageScorer(
{ requiredTools: ['execute_sql', 'list_tables'] },
trace
)
expect(result).toMatchObject({ score: 0.5 })
})
it('gives partial credit when a forbidden tool is called alongside all required tools', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql' }, { name: 'drop_table' }])
const result = await runToolUsageScorer(
{ requiredTools: ['execute_sql'], forbiddenTools: ['drop_table'] },
trace
)
expect(result).toMatchObject({
score: 0.5,
metadata: { violatedForbiddenTools: ['drop_table'] },
})
})
it('scores 0 when only a forbidden tool is called and the required tool is missing', async () => {
const { trace } = mockToolTrace([{ name: 'drop_table' }])
const result = await runToolUsageScorer(
{ requiredTools: ['execute_sql'], forbiddenTools: ['drop_table'] },
trace
)
expect(result).toMatchObject({
score: 0,
metadata: { violatedForbiddenTools: ['drop_table'] },
})
})
it('omits violation metadata when no forbidden tool is called', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql' }])
const result = await runToolUsageScorer(
{ requiredTools: ['execute_sql'], forbiddenTools: ['drop_table'] },
trace
)
expect(result).toMatchObject({ score: 1, metadata: undefined })
})
it('matches a required tool object by exact input field', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql', input: { sql: 'select 1' } }])
const result = await runToolUsageScorer(
{ requiredTools: [{ name: 'execute_sql', input: { sql: { equals: 'select 1' } } }] },
trace
)
expect(result).toMatchObject({ score: 1 })
})
it('matches a required tool object by partial input via stringIncludes', async () => {
const { trace } = mockToolTrace([
{ name: 'execute_sql', input: { sql: 'select * from users where id = 1' } },
])
const result = await runToolUsageScorer(
{
requiredTools: [{ name: 'execute_sql', input: { sql: { stringIncludes: 'from users' } } }],
},
trace
)
expect(result).toMatchObject({ score: 1 })
})
it('does not match a required tool object when the input field differs', async () => {
const { trace } = mockToolTrace([{ name: 'execute_sql', input: { sql: 'select 1' } }])
const result = await runToolUsageScorer(
{ requiredTools: [{ name: 'execute_sql', input: { sql: { equals: 'select 2' } } }] },
trace
)
expect(result).toMatchObject({ score: 0 })
})
})