mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
350 lines
9.7 KiB
TypeScript
350 lines
9.7 KiB
TypeScript
import { FinishReason } from 'ai'
|
|
import { LLMClassifierFromTemplate } from 'autoevals'
|
|
import { EvalCase, EvalScorer } from 'braintrust'
|
|
import { stripIndent } from 'common-tags'
|
|
|
|
import { extractUrls } from '@/lib/helpers'
|
|
|
|
const LLM_AS_A_JUDGE_MODEL = 'gpt-5.2' // NOTE: `gpt-5.2-2025-12-11` snapshot not yet working with online scorers
|
|
|
|
export type AssistantEvalInput = {
|
|
prompt: string
|
|
mockTables?: Record<
|
|
string,
|
|
Array<{
|
|
name: string
|
|
rls_enabled: boolean
|
|
columns: Array<{ name: string; data_type: string }>
|
|
}>
|
|
>
|
|
}
|
|
|
|
export type AssistantEvalOutput = {
|
|
finishReason: FinishReason
|
|
steps: Array<{ text: string; toolCalls: Array<{ toolName: string; input: unknown }> }>
|
|
toolNames: string[]
|
|
sqlQueries: string[]
|
|
docs: string[]
|
|
}
|
|
|
|
export type Expected = {
|
|
requiredTools?: string[]
|
|
requiredKnowledge?: string[]
|
|
correctAnswer?: string
|
|
}
|
|
|
|
// Based on categories in the AssistantMessageRatingSubmittedEvent
|
|
export type AssistantEvalCaseCategory =
|
|
| 'sql_generation'
|
|
| 'schema_design'
|
|
| 'rls_policies'
|
|
| 'edge_functions'
|
|
| 'database_optimization'
|
|
| 'debugging'
|
|
| 'general_help'
|
|
| 'other'
|
|
|
|
export type AssistantEvalCaseMetadata = {
|
|
category?: AssistantEvalCaseCategory[]
|
|
description?: string
|
|
}
|
|
|
|
export type AssistantEvalCase = EvalCase<AssistantEvalInput, Expected, AssistantEvalCaseMetadata>
|
|
|
|
/**
|
|
* Serialize steps into a string representation including text and tool calls
|
|
*/
|
|
function serializeSteps(steps: AssistantEvalOutput['steps']): string {
|
|
return steps
|
|
.map((step) => {
|
|
const toolCalls = step.toolCalls
|
|
?.map((call) => JSON.stringify({ tool: call.toolName, input: call.input }))
|
|
.join('\n')
|
|
return toolCalls ? `${step.text}\n${toolCalls}` : step.text
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Extract only the text content from steps, filtering out empty text
|
|
*/
|
|
function extractTextOnly(steps: AssistantEvalOutput['steps']): string {
|
|
return steps
|
|
.map((step) => step.text)
|
|
.filter((text) => text && text.trim().length > 0)
|
|
.join('\n')
|
|
}
|
|
|
|
export const toolUsageScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ output, expected }) => {
|
|
if (!expected.requiredTools) return null
|
|
|
|
const presentCount = expected.requiredTools.filter((tool) =>
|
|
output.toolNames.includes(tool)
|
|
).length
|
|
const totalCount = expected.requiredTools.length
|
|
const ratio = totalCount === 0 ? 1 : presentCount / totalCount
|
|
|
|
return {
|
|
name: 'Tool Usage',
|
|
score: ratio,
|
|
}
|
|
}
|
|
|
|
export const knowledgeUsageScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ output, expected }) => {
|
|
if (!expected.requiredKnowledge) return null
|
|
|
|
const loadedKnowledge = output.steps
|
|
.flatMap((step) => step.toolCalls)
|
|
.filter((call) => call.toolName === 'load_knowledge')
|
|
.flatMap((call) => {
|
|
const input = call.input
|
|
if (
|
|
typeof input !== 'object' ||
|
|
input === null ||
|
|
!('name' in input) ||
|
|
typeof input.name !== 'string'
|
|
)
|
|
return []
|
|
return [input.name]
|
|
})
|
|
|
|
const presentCount = expected.requiredKnowledge.filter((knowledge) =>
|
|
loadedKnowledge.includes(knowledge)
|
|
).length
|
|
const totalCount = expected.requiredKnowledge.length
|
|
const ratio = totalCount === 0 ? 1 : presentCount / totalCount
|
|
|
|
return {
|
|
name: 'Knowledge Usage',
|
|
score: ratio,
|
|
}
|
|
}
|
|
|
|
const concisenessEvaluator = LLMClassifierFromTemplate<{ input: string }>({
|
|
name: 'Conciseness',
|
|
promptTemplate: stripIndent`
|
|
Evaluate the conciseness of this response.
|
|
|
|
Input: {{input}}
|
|
Output: {{output}}
|
|
|
|
Is the response concise and free of unnecessary words?
|
|
a) Very concise - no wasted words
|
|
b) Acceptable verbosity - some verbosity but acceptable
|
|
c) Too verbose - contains superfluous wording or overly verbose
|
|
`,
|
|
choiceScores: { a: 1, b: 0.5, c: 0 },
|
|
useCoT: true,
|
|
model: LLM_AS_A_JUDGE_MODEL,
|
|
})
|
|
|
|
export const concisenessScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ input, output }) => {
|
|
return await concisenessEvaluator({
|
|
input: input.prompt,
|
|
output: extractTextOnly(output.steps),
|
|
})
|
|
}
|
|
|
|
const completenessEvaluator = LLMClassifierFromTemplate<{ input: string }>({
|
|
name: 'Completeness',
|
|
promptTemplate: stripIndent`
|
|
Evaluate whether this response is complete and finished, or if it appears cut off or incomplete.
|
|
|
|
Input: {{input}}
|
|
Output: {{output}}
|
|
|
|
Does the response appear complete and finished?
|
|
a) Complete - response is complete and finished
|
|
b) Incomplete - response appears cut off, missing parts, or severely incomplete
|
|
`,
|
|
choiceScores: { a: 1, b: 0 },
|
|
useCoT: true,
|
|
model: LLM_AS_A_JUDGE_MODEL,
|
|
})
|
|
|
|
export const completenessScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ input, output }) => {
|
|
return await completenessEvaluator({
|
|
input: input.prompt,
|
|
output: serializeSteps(output.steps),
|
|
})
|
|
}
|
|
|
|
const goalCompletionEvaluator = LLMClassifierFromTemplate<{ input: string }>({
|
|
name: 'Goal Completion',
|
|
promptTemplate: stripIndent`
|
|
Evaluate whether this response addresses what the user asked.
|
|
|
|
Input: {{input}}
|
|
Output: {{output}}
|
|
|
|
Does the response address what the user asked?
|
|
a) Fully addresses - completely answers the question or fulfills the request
|
|
b) Partially addresses - addresses some aspects but misses key parts
|
|
c) Doesn't address - off-topic or fails to address the request
|
|
`,
|
|
choiceScores: { a: 1, b: 0.5, c: 0 },
|
|
useCoT: true,
|
|
model: LLM_AS_A_JUDGE_MODEL,
|
|
})
|
|
|
|
export const goalCompletionScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ input, output }) => {
|
|
return await goalCompletionEvaluator({
|
|
input: input.prompt,
|
|
output: serializeSteps(output.steps),
|
|
})
|
|
}
|
|
|
|
const docsFaithfulnessEvaluator = LLMClassifierFromTemplate<{ docs: string }>({
|
|
name: 'Docs Faithfulness',
|
|
promptTemplate: stripIndent`
|
|
Evaluate whether the assistant's response accurately reflects the information in the retrieved documentation.
|
|
|
|
Retrieved Documentation:
|
|
{{docs}}
|
|
|
|
Assistant Response:
|
|
{{output}}
|
|
|
|
Does the assistant's response accurately reflect the documentation without contradicting it or adding unsupported claims?
|
|
a) Faithful - response accurately reflects the docs, no contradictions or unsupported claims
|
|
b) Partially faithful - mostly accurate but has minor inaccuracies or unsupported details
|
|
c) Not faithful - contradicts the docs or makes significant unsupported claims
|
|
`,
|
|
choiceScores: { a: 1, b: 0.5, c: 0 },
|
|
useCoT: true,
|
|
model: LLM_AS_A_JUDGE_MODEL,
|
|
})
|
|
|
|
export const docsFaithfulnessScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ output }) => {
|
|
// Skip scoring if no docs were retrieved
|
|
if (!output.docs || output.docs.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const docsText = output.docs.join('\n\n')
|
|
|
|
return await docsFaithfulnessEvaluator({
|
|
docs: docsText,
|
|
output: extractTextOnly(output.steps),
|
|
})
|
|
}
|
|
|
|
const correctnessEvaluator = LLMClassifierFromTemplate<{ input: string; expected: string }>({
|
|
name: 'Correctness',
|
|
promptTemplate: stripIndent`
|
|
Evaluate whether the assistant's answer is correct according to the expected answer.
|
|
|
|
Question:
|
|
{{input}}
|
|
|
|
Expected Answer:
|
|
{{expected}}
|
|
|
|
Assistant Response:
|
|
{{output}}
|
|
|
|
Is the assistant's response correct? The response can contain additional information beyond the expected answer, but it must:
|
|
- Include the expected answer (or equivalent information)
|
|
- Not contradict the expected answer
|
|
|
|
a) Correct - response includes the expected answer, no contradictions or omissions
|
|
b) Partially correct - includes most of the expected answer but has minor omissions or contradictions
|
|
c) Incorrect - contradicts or fails to provide the expected answer
|
|
`,
|
|
choiceScores: { a: 1, b: 0.5, c: 0 },
|
|
useCoT: true,
|
|
model: LLM_AS_A_JUDGE_MODEL,
|
|
})
|
|
|
|
export const correctnessScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ input, output, expected }) => {
|
|
// Skip scoring if no ground truth is provided
|
|
if (!expected.correctAnswer) {
|
|
return null
|
|
}
|
|
|
|
return await correctnessEvaluator({
|
|
input: input.prompt,
|
|
expected: expected.correctAnswer,
|
|
output: extractTextOnly(output.steps),
|
|
})
|
|
}
|
|
|
|
export const urlValidityScorer: EvalScorer<
|
|
AssistantEvalInput,
|
|
AssistantEvalOutput,
|
|
Expected
|
|
> = async ({ output }) => {
|
|
const responseText = extractTextOnly(output.steps)
|
|
const allUrls = extractUrls(responseText, { excludeCodeBlocks: true, excludeTemplates: true })
|
|
const urls = allUrls.filter((url) => {
|
|
try {
|
|
const { hostname } = new URL(url)
|
|
return hostname === 'supabase.com' || hostname.endsWith('.supabase.com')
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
// Skip if no URLs found
|
|
if (urls.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const results = await Promise.all(
|
|
urls.map(async (url) => {
|
|
try {
|
|
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
|
|
if (response.ok) {
|
|
return { valid: true }
|
|
}
|
|
return { valid: false, error: `${url} returned ${response.status}` }
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
return { valid: false, error: `${url} failed: ${errorMessage}` }
|
|
}
|
|
})
|
|
)
|
|
|
|
const errors = results.flatMap((r) => (r.error ? [r.error] : []))
|
|
const validUrls = results.filter((r) => r.valid).length
|
|
|
|
const metadata = {
|
|
urls,
|
|
errors: errors.length > 0 ? errors : undefined,
|
|
}
|
|
|
|
return {
|
|
name: 'URL Validity',
|
|
score: validUrls / urls.length,
|
|
metadata,
|
|
}
|
|
}
|