Files
supabase__supabase/apps/studio/lib/ai/generate-assistant-response.ts
claude[bot] 8790e657e9 feat(ai): include org slug in Assistant Braintrust span metadata (#49692)
<!-- ccr-slack-attribution -->
_Requested by **Matt Rossman** · [Slack
thread](https://supabase.slack.com/archives/D0A79RYJKRB/p1787926891744399)_


# Problem

Assistant spans in Braintrust record only the numeric `orgId`, whereas
support tickets show org slug.

This incurs an extra manual step to resolve the ID through admin studio
before the trace can be found.

# Fix

Adds `orgSlug` to spans, sourced from the same verified org lookup that
produces `orgId`.

Renamed the request body's `orgSlug` to `rawOrgSlug` to distinguish the
verified slug from getAIDetails, following the existing
rawRequestedModel / requestedModel pattern.

## How to review

See sample trace
[94863b6d-aaa9-449a-a9c9-981ad40e614a](https://www.braintrust.dev/app/supabase.io/p/Assistant/trace?object_type=project_logs&object_id=5a8d02e5-b3b6-40cc-ba76-ecee286478f4&r=223112cd-33f4-45c4-a273-8d3781689448&s=223112cd-33f4-45c4-a273-8d3781689448)
produced from sending a chat from the
[Preview](https://studio-staging-git-mattrossman-ai-1149-include-698a5f-supabase.vercel.app/dashboard/org)
on this PR.

Note it now includes the org slug in span metadata:

<img width="873" height="548" alt="CleanShot 2026-08-28 at 10 59 49@2x"
src="https://github.com/user-attachments/assets/bcf47a94-6782-434a-9006-c7b9c95f1c37"
/>

If desired you can test yourself too by chatting with Assistant in the
preview and looking up the corresponding Chat ID from Braintrust
[logs](https://www.braintrust.dev/app/supabase.io/p/Assistant/logs).

Closes AI-1149

---
_Generated by [Claude
Code](https://claude.ai/code/session_01N2ziJech9dV19pJ9MisYdX)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 11:48:38 -04:00

189 lines
5.8 KiB
TypeScript

import * as ai from 'ai'
import {
convertToModelMessages,
isStepCount,
type LanguageModel,
type ModelMessage,
type SystemModelMessage,
type ToolSet,
type UIMessage,
} from 'ai'
import { startSpan, traced, withCurrent, wrapAISDK, type Span } from 'braintrust'
import { source } from 'common-tags'
import type { AssistantEvalInput } from '@/evals/scorer'
import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context'
import { IS_TRACING_ENABLED } from '@/lib/ai/braintrust-logger'
import { prepareMessagesForModel } from '@/lib/ai/generate-assistant-response.utils'
import {
CHAT_PROMPT,
GENERAL_PROMPT,
LIMITATIONS_PROMPT,
NOTEBOOKS_PROMPT,
SECURITY_PROMPT,
} from '@/lib/ai/prompts'
const { streamText: tracedStreamText } = wrapAISDK(ai)
export async function generateAssistantResponse({
messages: rawMessages,
model,
tools,
aiOptInLevel = 'schema',
getSchemas,
projectRef,
chatId,
chatName,
allowTracing,
supportMode,
userId,
orgId,
orgSlug,
planId,
includesLogsSnippets,
isExplorerEnabled,
systemProviderOptions,
providerOptions,
requestedModel,
abortSignal,
onSpanCreated,
}: {
messages: UIMessage[]
model: LanguageModel
tools: ToolSet
aiOptInLevel?: AiOptInLevel
getSchemas?: () => Promise<string>
projectRef?: string
chatId?: string
chatName?: string
allowTracing?: boolean
supportMode?: boolean
userId?: string
orgId?: number
orgSlug?: string
planId?: string
/** Whether any user message in the conversation attached a logs (ClickHouse) query. */
includesLogsSnippets?: boolean
isExplorerEnabled?: boolean
requestedModel?: string
systemProviderOptions?: Record<string, any>
providerOptions?: Record<string, any>
abortSignal?: AbortSignal
onSpanCreated?: (spanId: string) => void
}) {
const shouldTrace = allowTracing ?? IS_TRACING_ENABLED
const run = async (span?: Span) => {
const messages = prepareMessagesForModel(rawMessages, aiOptInLevel)
const schemasString =
aiOptInLevel !== 'disabled' && getSchemas
? shouldTrace
? await traced(async () => getSchemas(), { name: 'getSchemas', type: 'function' })
: await getSchemas()
: NO_SCHEMA_ACCESS_MESSAGE
// Important: do not use per-request dynamic content in the system prompt or Bedrock will
// not cache it. isExplorerEnabled is a per-user flag, not per-request, so it only produces
// two prompt variants (on/off) rather than defeating caching.
const system = source`
${GENERAL_PROMPT}
${CHAT_PROMPT}
${isExplorerEnabled ? NOTEBOOKS_PROMPT : ''}
${SECURITY_PROMPT}
${LIMITATIONS_PROMPT}
## Available Knowledge
Before writing SQL or answering questions about the following topics, call \`load_knowledge\` to load detailed knowledge:
- \`pg_best_practices\` — PostgreSQL best practices. Always load before writing any SQL, even simple queries.
- \`logs\` — ClickHouse SQL against the project's logs table. Always load before calling \`query_logs\`.
- \`rls\` — Row Level Security policies for database tables.
- \`storage\` — Supabase Storage buckets, public/private bucket access, and \`storage.objects\` policies. Always load before creating Storage buckets or \`storage.objects\` policies.
- \`edge_functions\` — Supabase Edge Functions
- \`realtime\` — Supabase Realtime
`
const systemMessage: SystemModelMessage = {
role: 'system',
content: system,
...(systemProviderOptions && { providerOptions: systemProviderOptions }),
}
const coreMessages: ModelMessage[] = [
...buildAssistantContextMessages({
projectRef,
chatName,
schemasString,
supportMode,
includesLogsSnippets,
}),
...(await convertToModelMessages(messages)),
]
const streamTextFn = shouldTrace ? tracedStreamText : ai.streamText
return streamTextFn({
model,
instructions: systemMessage,
stopWhen: isStepCount(10),
messages: coreMessages,
...(providerOptions && { providerOptions }),
tools,
...(abortSignal && { abortSignal }),
...(span && {
onEnd: ({ steps, finishReason }) => {
const metadata: Record<string, unknown> = {
isFinalStep: finishReason === 'stop',
}
for (const step of steps) {
for (const toolCall of step.toolCalls) {
if (toolCall.toolName === 'rename_chat') {
const { newName } = toolCall.input as { newName: string }
metadata.chatName = newName
}
}
}
span.log({ metadata })
span.end()
},
}),
} satisfies Parameters<typeof ai.streamText>[0])
}
if (shouldTrace) {
// startSpan instead of traced() so we control when the span closes via onEnd.
// Scorers read from child spans (LLM + tool) in the trace rather than a root span output field.
const span = startSpan({ name: 'generateAssistantResponse', type: 'function' })
onSpanCreated?.(span.id)
const lastUserMessage = rawMessages.findLast((m) => m.role === 'user')
const lastUserText = lastUserMessage?.parts
?.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('\n')
span.log({
input: { prompt: lastUserText ?? '' } satisfies AssistantEvalInput,
metadata: {
projectRef,
chatId,
chatName,
aiOptInLevel,
userId,
orgId,
orgSlug,
planId,
requestedModel,
gitBranch: process.env.VERCEL_GIT_COMMIT_REF,
environment: process.env.NEXT_PUBLIC_ENVIRONMENT,
},
})
return withCurrent(span, () => run(span))
}
return run()
}