Files
supabase/apps/studio/evals/trace-utils.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

228 lines
7.7 KiB
TypeScript

import type { SpanData, Trace } from 'braintrust'
import { z } from 'zod'
import type { Transcript } from './transcript'
/**
* Matches AI SDK tool spans as Braintrust records them: tool args first,
* execution context second.
*/
const aiSdkToolSpanInputSchema = z.tuple([
z.unknown(),
z
.object({
messages: z.unknown().optional(),
toolCallId: z.string().optional(),
})
.passthrough(),
])
/** Normalized Braintrust tool span with unwrapped tool input and raw output. */
export type ToolSpan = {
span: SpanData
input: unknown
output: unknown
}
/** Optional schemas used to validate and type a tool span's input and output. */
type ToolSpanSchemas<
TInputSchema extends z.ZodType | undefined,
TOutputSchema extends z.ZodType | undefined,
> = {
inputSchema?: TInputSchema
outputSchema?: TOutputSchema
}
/** Tool span whose input/output types are inferred from provided schemas. */
type ParsedToolSpan<
TInputSchema extends z.ZodType | undefined,
TOutputSchema extends z.ZodType | undefined,
> = {
span: SpanData
input: TInputSchema extends z.ZodType ? z.infer<TInputSchema> : unknown
output: TOutputSchema extends z.ZodType ? z.infer<TOutputSchema> : unknown
}
/** Extracts the actual tool args from Braintrust's traced function input shape. */
function getToolSpanInput(span: SpanData): unknown {
const result = aiSdkToolSpanInputSchema.safeParse(span.input)
return result.success ? result.data[0] : span.input
}
/** Returns normalized tool spans from the trace, optionally filtered to a specific tool name. */
export async function getToolSpans(trace: Trace, toolName?: string): Promise<ToolSpan[]> {
const spans = await trace.getSpans({ spanType: ['tool'] })
const toolSpans = spans.map((span) => ({
span,
input: getToolSpanInput(span),
output: span.output,
}))
if (!toolName) return toolSpans
return toolSpans.filter((s) => s.span.span_attributes?.name === toolName)
}
/** Returns only tool spans whose normalized input/output match the provided schemas. */
export async function getParsedToolSpans<
TInputSchema extends z.ZodType | undefined = undefined,
TOutputSchema extends z.ZodType | undefined = undefined,
>(
trace: Trace,
toolName: string,
schemas: ToolSpanSchemas<TInputSchema, TOutputSchema> = {}
): Promise<Array<ParsedToolSpan<TInputSchema, TOutputSchema>>> {
const spans = await getToolSpans(trace, toolName)
return spans.flatMap(({ span, input, output }) => {
const parsedInput = schemas.inputSchema?.safeParse(input)
if (parsedInput && !parsedInput.success) return []
const parsedOutput = schemas.outputSchema?.safeParse(output)
if (parsedOutput && !parsedOutput.success) return []
return [
{
span,
input: parsedInput ? parsedInput.data : input,
output: parsedOutput ? parsedOutput.data : output,
} as ParsedToolSpan<TInputSchema, TOutputSchema>,
]
})
}
// --- Thread parsing (fallback path for online scorers) ---
//
// Online scorers run against live production traces, which have no in-memory
// transcript to read (that only exists inside the offline eval task — see
// buildTranscript in transcript.ts). trace.getThread() is the only source of
// the full conversation available to them there, so we derive an equivalent
// Transcript from it. This is subject to Braintrust's getThread()
// preview-length truncation, unlike buildTranscript's in-memory path — that's
// a known, pre-existing limitation of the online-scoring path, not something
// this fallback introduces.
const projectContextPrefix = "The user's current project is "
const threadTextBlockSchema = z.object({ type: z.literal('text'), text: z.string() })
const threadToolCallArgumentsSchema = z.object({ type: z.literal('valid'), value: z.unknown() })
const threadToolCallBlockSchema = z.object({
type: z.literal('tool_call'),
tool_name: z.string(),
arguments: z.unknown().optional(),
})
const threadContentBlockSchema = z.union([threadTextBlockSchema, threadToolCallBlockSchema])
const threadContentSchema = z.union([
z.string(),
z.array(z.unknown()).transform((blocks) =>
blocks.flatMap((block) => {
const result = threadContentBlockSchema.safeParse(block)
return result.success ? [result.data] : []
})
),
])
const threadMessageSchema = z.object({
role: z.enum(['system', 'user', 'assistant', 'tool']),
content: threadContentSchema,
})
type ThreadMessage = z.infer<typeof threadMessageSchema>
type ThreadContentBlock = z.infer<typeof threadContentBlockSchema>
function unwrapToolCallArguments(args: unknown): unknown {
const result = threadToolCallArgumentsSchema.safeParse(args)
return result.success ? result.data.value : args
}
function serializeContentBlock(block: ThreadContentBlock, includeToolCallInputs: boolean): string {
if (block.type === 'text') return block.text
const marker = `[called ${block.tool_name}]`
if (!includeToolCallInputs || typeof block.arguments === 'undefined') return marker
return `${marker}\n${JSON.stringify(unwrapToolCallArguments(block.arguments), null, 2)}`
}
function serializeMessageContent(
message: ThreadMessage | undefined,
includeToolCallInputs: boolean
): string | null {
if (!message) return null
if (typeof message.content === 'string') return message.content || null
const content = message.content
.map((block) => serializeContentBlock(block, includeToolCallInputs))
.join('\n')
return content || null
}
function serializeMessages(
messages: ThreadMessage[],
includeToolCallInputs: boolean
): string | null {
const parts = messages.flatMap((message) => {
const content = serializeMessageContent(message, includeToolCallInputs)
return content ? [`[${message.role}]\n${content}`] : []
})
return parts.length > 0 ? parts.join('\n\n') : null
}
function isProjectContextMessage(message: ThreadMessage): boolean {
return (
message.role === 'assistant' &&
Boolean(serializeMessageContent(message, false)?.startsWith(projectContextPrefix))
)
}
function findLastUserIndex(messages: ThreadMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') return i
}
return -1
}
/**
* Parses a raw trace.getThread() payload into a Transcript. Pulled out from
* getThreadParts so it can be unit tested without a live Trace.
*/
export function getThreadPartsFromThread(thread: unknown[]): Transcript {
const messages = thread.flatMap((message) => {
const result = threadMessageSchema.safeParse(message)
if (!result.success || result.data.role === 'system' || result.data.role === 'tool') return []
return [result.data]
})
const chatMessages = messages.filter((message) => !isProjectContextMessage(message))
const lastUserIdx = findLastUserIndex(chatMessages)
if (lastUserIdx === -1) {
return {
currentUserInput: '',
priorConversation: serializeMessages(chatMessages, true),
lastAssistantTurn: null,
lastAssistantTurnWithToolInputs: null,
}
}
const assistantMessages = chatMessages
.slice(lastUserIdx + 1)
.filter((message) => message.role === 'assistant')
return {
currentUserInput: serializeMessageContent(chatMessages[lastUserIdx], false) ?? '',
priorConversation: serializeMessages(chatMessages.slice(0, lastUserIdx), true),
lastAssistantTurn: serializeMessages(assistantMessages, false),
lastAssistantTurnWithToolInputs: serializeMessages(assistantMessages, true),
}
}
/**
* Derives a Transcript from a live trace's thread. Used as the fallback for
* online scorers, which have no in-memory transcript to read (see
* getThreadPartsFromThread above for why, and why this is truncation-prone
* in a way the offline path isn't).
*/
export async function getThreadParts(trace: Trace): Promise<Transcript> {
return getThreadPartsFromThread(await trace.getThread())
}