mirror of
https://github.com/supabase/supabase.git
synced 2026-09-08 10:59:38 +08:00
## 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 -->
194 lines
7.2 KiB
TypeScript
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 })
|
|
})
|
|
})
|