Files
supabase/apps/studio/evals/trace-utils.test.ts
Charis 8c409e2df5 Fix eval scorer truncation via local transcript capture (#49151)
## Problem

Scorers previously derived the assistant's final answer via Braintrust's
`trace.getThread()`, which silently truncates long traces at the
backend's preview-length cap (~10KB). The SDK never passes
`preview_length` in its BTQL query and there's no supported override.
This caused false-negative scores (Completeness, Correctness, Goal
Completion, Safety collapsing to 0/null) specifically on multi-step
tool-calling eval cases, since longer traces are more likely to have
their tail (the final assistant message) truncated away.

## Solution

Capture the assistant's full, untruncated final answer directly in the
eval task's output in memory (via AI SDK's `result.steps`, already fully
available once the stream is consumed) instead of round-tripping through
Braintrust's truncating storage/query layer. Scorers now read
`output.transcript` instead of calling `trace.getThread()`.

## Changes

- **New**: `apps/studio/evals/transcript.ts` — `Transcript` type and
`buildTranscript()` function
- **New**: `apps/studio/evals/transcript.test.ts` — unit tests (5
passing)
- **Modified**: `apps/studio/evals/assistant.eval.ts` — captures
`result.steps` and returns transcript
- **Modified**: `apps/studio/evals/scorer.ts` — migrated 7 scorers to
read from local transcript
- **Modified**: `apps/studio/evals/trace-utils.ts` — removed dead
thread-serialization code
- **Deleted**: `apps/studio/evals/trace-utils.test.ts` — superseded by
transcript tests

## Test Plan

- [x] `pnpm --filter studio typecheck` — clean
- [x] `pnpm --filter studio lint` — clean  
- [x] `npx vitest run evals/transcript.test.ts` — 5/5 passing
- [x] Full live eval run (35/35 cases) against Braintrust —
[experiment](https://www.braintrust.dev/app/supabase.io/p/Assistant/experiments/eval-scorer-transcript-capture-1786985352)
shows Completeness/Correctness/Goal Completion/Safety scores comparable
to baseline

## Known Residual Risk

Other scorers that derive data from `trace.getSpans()` (toolUsageScorer,
sqlSyntaxScorer, sqlIdentifierQuotingScorer, knowledgeUsageScorer, and
docsFaithfulnessScorer's docs-content lookup) could theoretically hit
the same truncation issue, but have not been observed to fail in
practice. This is not addressed in this PR.

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

* **New Features**
* Added transcript generation from assistant interaction steps,
including text and tool-call inputs.
* Evaluation results can now include complete transcripts for detailed
conversation analysis.
* Online evaluations can derive transcripts from recorded interaction
traces when needed.

* **Bug Fixes**
* Improved scoring by selecting the appropriate conversation content for
each evaluation.
* Ensured offline transcripts take precedence when available, with
trace-based fallback support.

* **Tests**
* Added coverage for multi-step interactions, tool calls, filtering,
empty steps, and URL validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-18 12:22:50 -04:00

181 lines
4.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getThreadPartsFromThread } from './trace-utils'
// Sanitized mock of the thread shape returned by trace.getThread().
const MOCK_THREAD = [
{
role: 'system',
content: 'System instructions omitted for fixture.',
},
{
role: 'assistant',
content: "The user's current project is Acme Analytics.",
},
{
role: 'user',
content: 'What did we decide earlier?',
},
{
role: 'assistant',
content: [
{
type: 'text',
text: 'We decided to add an orders table with RLS policies before generating sample data.',
},
],
},
{
role: 'user',
content: 'Can you create that orders table now?',
},
{
role: 'assistant',
id: null,
content: [
{
type: 'tool_call',
tool_name: 'rename_chat',
tool_call_id: 'call_dummy_rename',
arguments: {
type: 'valid',
value: {
newName: 'Create Orders Table',
},
},
},
],
},
{
role: 'tool',
content: [
{
type: 'tool_result',
tool_name: 'rename_chat',
tool_call_id: 'call_dummy_rename',
output: {
status: 'Chat request sent to client',
},
},
],
},
{
role: 'assistant',
id: null,
content: [
{
type: 'tool_call',
tool_name: 'load_knowledge',
tool_call_id: 'call_dummy_knowledge',
arguments: {
type: 'valid',
value: {
name: 'database',
},
},
},
{
type: 'tool_call',
tool_name: 'execute_sql',
tool_call_id: 'call_dummy_sql',
arguments: {
type: 'valid',
value: {
sql: 'create table public.orders (id bigint generated by default as identity primary key);',
},
},
},
],
},
{
role: 'tool',
content: [
{
type: 'tool_result',
tool_name: 'load_knowledge',
tool_call_id: 'call_dummy_knowledge',
output: 'Knowledge fixture omitted.',
},
{
type: 'tool_result',
tool_name: 'execute_sql',
tool_call_id: 'call_dummy_sql',
output: {
type: 'text',
text: 'SQL executed successfully.',
},
},
],
},
{
role: 'assistant',
id: null,
content:
'I created the public.orders table. You should add RLS policies before exposing it to users.',
},
]
describe('getThreadPartsFromThread', () => {
it('parses a sanitized Braintrust trace.getThread payload, computing both tool-input variants', () => {
expect(getThreadPartsFromThread(MOCK_THREAD)).toEqual({
currentUserInput: 'Can you create that orders table now?',
priorConversation:
'[user]\nWhat did we decide earlier?\n\n[assistant]\nWe decided to add an orders table with RLS policies before generating sample data.',
lastAssistantTurn:
'[assistant]\n[called rename_chat]\n\n[assistant]\n[called load_knowledge]\n[called execute_sql]\n\n[assistant]\nI created the public.orders table. You should add RLS policies before exposing it to users.',
lastAssistantTurnWithToolInputs: `\
[assistant]
[called rename_chat]
{
"newName": "Create Orders Table"
}
[assistant]
[called load_knowledge]
{
"name": "database"
}
[called execute_sql]
{
"sql": "create table public.orders (id bigint generated by default as identity primary key);"
}
[assistant]
I created the public.orders table. You should add RLS policies before exposing it to users.`,
})
})
it('filters out project-context messages so they never leak into prior conversation', () => {
const threadWithExtraProjectContext = [
{
role: 'assistant',
content: "The user's current project is Old Project.",
},
...MOCK_THREAD,
]
expect(getThreadPartsFromThread(threadWithExtraProjectContext)).toEqual(
getThreadPartsFromThread(MOCK_THREAD)
)
expect(getThreadPartsFromThread(threadWithExtraProjectContext).priorConversation).not.toContain(
'Old Project'
)
})
it('treats all messages as prior conversation when there is no user message', () => {
expect(
getThreadPartsFromThread([
{
role: 'assistant',
content: 'I can help with your Supabase project.',
},
])
).toEqual({
currentUserInput: '',
priorConversation: '[assistant]\nI can help with your Supabase project.',
lastAssistantTurn: null,
lastAssistantTurnWithToolInputs: null,
})
})
})