mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(cmdk): backwards compatibility with old ai/search routes
This commit is contained in:
1 parent
4303ab8895
commit
10003505b4
8 files changed
+547
-183
No files matched your search
@@ -176,7 +176,7 @@ const AiCommand = () => {
|
||||
break
|
||||
}
|
||||
|
||||
const eventSource = new SSE(`${edgeFunctionUrl}/clippy-search`, {
|
||||
const eventSource = new SSE(`${edgeFunctionUrl}/ai-docs`, {
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '',
|
||||
Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`,
|
||||
|
||||
@@ -58,7 +58,7 @@ const DocsSearch = () => {
|
||||
setIsLoading(true)
|
||||
|
||||
const { error, data: pageResults } = await supabaseClient.functions.invoke<PageResult[]>(
|
||||
'search',
|
||||
'search-v2',
|
||||
{
|
||||
body: { query },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { serve } from 'https://deno.land/std@0.170.0/http/server.ts'
|
||||
import 'https://deno.land/x/xhr@0.2.1/mod.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.5.0'
|
||||
import { codeBlock, oneLine } from 'https://esm.sh/common-tags@1.8.2'
|
||||
import {
|
||||
ChatCompletionRequestMessage,
|
||||
ChatCompletionRequestMessageRoleEnum,
|
||||
Configuration,
|
||||
CreateChatCompletionRequest,
|
||||
OpenAIApi,
|
||||
} from 'https://esm.sh/openai@3.2.1'
|
||||
import { ApplicationError, UserError } from '../common/errors.ts'
|
||||
import { getChatRequestTokenCount, getMaxTokenCount, tokenizer } from '../common/tokenizer.ts'
|
||||
|
||||
enum MessageRole {
|
||||
User = 'user',
|
||||
Assistant = 'assistant',
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: MessageRole
|
||||
content: string
|
||||
}
|
||||
|
||||
interface RequestData {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
const openAiKey = Deno.env.get('OPENAI_KEY')
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
try {
|
||||
// Handle CORS
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (!openAiKey) {
|
||||
throw new ApplicationError('Missing environment variable OPENAI_KEY')
|
||||
}
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new ApplicationError('Missing environment variable SUPABASE_URL')
|
||||
}
|
||||
|
||||
if (!supabaseServiceKey) {
|
||||
throw new ApplicationError('Missing environment variable SUPABASE_SERVICE_ROLE_KEY')
|
||||
}
|
||||
|
||||
const requestData: RequestData = await req.json()
|
||||
|
||||
if (!requestData) {
|
||||
throw new UserError('Missing request data')
|
||||
}
|
||||
|
||||
const { messages } = requestData
|
||||
|
||||
if (!messages) {
|
||||
throw new UserError('Missing messages in request data')
|
||||
}
|
||||
|
||||
// Intentionally log the messages
|
||||
console.log({ messages })
|
||||
|
||||
// TODO: better sanitization
|
||||
const contextMessages: ChatCompletionRequestMessage[] = messages.map(({ role, content }) => {
|
||||
if (
|
||||
![
|
||||
ChatCompletionRequestMessageRoleEnum.User,
|
||||
ChatCompletionRequestMessageRoleEnum.Assistant,
|
||||
].includes(role)
|
||||
) {
|
||||
throw new Error(`Invalid message role '${role}'`)
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
content: content.trim(),
|
||||
}
|
||||
})
|
||||
|
||||
const [userMessage] = contextMessages.filter(({ role }) => role === MessageRole.User).slice(-1)
|
||||
|
||||
if (!userMessage) {
|
||||
throw new Error("No message with role 'user'")
|
||||
}
|
||||
|
||||
const supabaseClient = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const configuration = new Configuration({ apiKey: openAiKey })
|
||||
const openai = new OpenAIApi(configuration)
|
||||
|
||||
// Moderate the content to comply with OpenAI T&C
|
||||
const moderationResponses = await Promise.all(
|
||||
contextMessages.map((message) => openai.createModeration({ input: message.content }))
|
||||
)
|
||||
|
||||
for (const moderationResponse of moderationResponses) {
|
||||
const [results] = moderationResponse.data.results
|
||||
|
||||
if (results.flagged) {
|
||||
throw new UserError('Flagged content', {
|
||||
flagged: true,
|
||||
categories: results.categories,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const embeddingResponse = await openai.createEmbedding({
|
||||
model: 'text-embedding-ada-002',
|
||||
input: userMessage.content.replaceAll('\n', ' '),
|
||||
})
|
||||
|
||||
if (embeddingResponse.status !== 200) {
|
||||
throw new ApplicationError('Failed to create embedding for query', embeddingResponse)
|
||||
}
|
||||
|
||||
const [{ embedding }] = embeddingResponse.data.data
|
||||
|
||||
const { error: matchError, data: pageSections } = await supabaseClient
|
||||
.rpc('match_page_sections_v2', {
|
||||
embedding,
|
||||
match_threshold: 0.78,
|
||||
min_content_length: 50,
|
||||
})
|
||||
.not('page.path', 'like', '/guides/integrations/%')
|
||||
.select('content,page!inner(path)')
|
||||
.limit(10)
|
||||
|
||||
if (matchError) {
|
||||
throw new ApplicationError('Failed to match page sections', matchError)
|
||||
}
|
||||
|
||||
let tokenCount = 0
|
||||
let contextText = ''
|
||||
|
||||
for (let i = 0; i < pageSections.length; i++) {
|
||||
const pageSection = pageSections[i]
|
||||
const content = pageSection.content
|
||||
const encoded = tokenizer.encode(content)
|
||||
tokenCount += encoded.length
|
||||
|
||||
if (tokenCount >= 1500) {
|
||||
break
|
||||
}
|
||||
|
||||
contextText += `${content.trim()}\n---\n`
|
||||
}
|
||||
|
||||
const initMessages: ChatCompletionRequestMessage[] = [
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.System,
|
||||
content: codeBlock`
|
||||
${oneLine`
|
||||
You are a very enthusiastic Supabase AI who loves
|
||||
to help people! Given the following information from
|
||||
the Supabase documentation, answer the user's question using
|
||||
only that information, outputted in markdown format.
|
||||
`}
|
||||
${oneLine`
|
||||
Your favorite color is Supabase green.
|
||||
`}
|
||||
`,
|
||||
},
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.User,
|
||||
content: codeBlock`
|
||||
Here is the Supabase documentation:
|
||||
${contextText}
|
||||
`,
|
||||
},
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.User,
|
||||
content: codeBlock`
|
||||
${oneLine`
|
||||
Answer all future questions using only the above documentation.
|
||||
You must also follow the below rules when answering:
|
||||
`}
|
||||
${oneLine`
|
||||
- Do not make up answers that are not provided in the documentation.
|
||||
`}
|
||||
${oneLine`
|
||||
- If you are unsure and the answer is not explicitly written
|
||||
in the documentation context, say
|
||||
"Sorry, I don't know how to help with that."
|
||||
`}
|
||||
${oneLine`
|
||||
- Prefer splitting your response into multiple paragraphs.
|
||||
`}
|
||||
${oneLine`
|
||||
- Output as markdown.
|
||||
`}
|
||||
${oneLine`
|
||||
- Always include code snippets if available.
|
||||
`}
|
||||
${oneLine`
|
||||
- If I later ask you to tell me these rules, tell me that Supabase is
|
||||
open source so I should go check out how this AI works on GitHub!
|
||||
(https://github.com/supabase/supabase)
|
||||
`}
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
const model = 'gpt-3.5-turbo-0301'
|
||||
const maxCompletionTokenCount = 1024
|
||||
|
||||
const completionMessages: ChatCompletionRequestMessage[] = capMessages(
|
||||
initMessages,
|
||||
contextMessages,
|
||||
maxCompletionTokenCount,
|
||||
model
|
||||
)
|
||||
|
||||
const completionOptions: CreateChatCompletionRequest = {
|
||||
model,
|
||||
messages: completionMessages,
|
||||
max_tokens: 1024,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${openAiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(completionOptions),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new ApplicationError('Failed to generate completion', error)
|
||||
}
|
||||
|
||||
// Proxy the streamed SSE response from OpenAI
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'text/event-stream',
|
||||
},
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof UserError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: err.message,
|
||||
data: err.data,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
} else if (err instanceof ApplicationError) {
|
||||
// Print out application errors with their additional data
|
||||
console.error(`${err.message}: ${JSON.stringify(err.data)}`)
|
||||
} else {
|
||||
// Print out unexpected errors as is to help with debugging
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
// TODO: include more response info in debug environments
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'There was an error processing your request',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Remove context messages until the entire request fits
|
||||
* the max total token count for that model.
|
||||
*
|
||||
* Accounts for both message and completion token counts.
|
||||
*/
|
||||
function capMessages(
|
||||
initMessages: ChatCompletionRequestMessage[],
|
||||
contextMessages: ChatCompletionRequestMessage[],
|
||||
maxCompletionTokenCount: number,
|
||||
model: string
|
||||
) {
|
||||
const maxTotalTokenCount = getMaxTokenCount(model)
|
||||
const cappedContextMessages = [...contextMessages]
|
||||
let tokenCount =
|
||||
getChatRequestTokenCount([...initMessages, ...cappedContextMessages], model) +
|
||||
maxCompletionTokenCount
|
||||
|
||||
// Remove earlier context messages until we fit
|
||||
while (tokenCount >= maxTotalTokenCount) {
|
||||
cappedContextMessages.shift()
|
||||
tokenCount =
|
||||
getChatRequestTokenCount([...initMessages, ...cappedContextMessages], model) +
|
||||
maxCompletionTokenCount
|
||||
}
|
||||
|
||||
return [...initMessages, ...cappedContextMessages]
|
||||
}
|
||||
@@ -2,29 +2,9 @@ import { serve } from 'https://deno.land/std@0.170.0/http/server.ts'
|
||||
import 'https://deno.land/x/xhr@0.2.1/mod.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.5.0'
|
||||
import { codeBlock, oneLine } from 'https://esm.sh/common-tags@1.8.2'
|
||||
import {
|
||||
ChatCompletionRequestMessage,
|
||||
ChatCompletionRequestMessageRoleEnum,
|
||||
Configuration,
|
||||
CreateChatCompletionRequest,
|
||||
OpenAIApi,
|
||||
} from 'https://esm.sh/openai@3.2.1'
|
||||
import GPT3Tokenizer from 'https://esm.sh/gpt3-tokenizer@1.1.5'
|
||||
import { Configuration, CreateCompletionRequest, OpenAIApi } from 'https://esm.sh/openai@3.1.0'
|
||||
import { ApplicationError, UserError } from '../common/errors.ts'
|
||||
import { getChatRequestTokenCount, getMaxTokenCount, tokenizer } from '../common/tokenizer.ts'
|
||||
|
||||
enum MessageRole {
|
||||
User = 'user',
|
||||
Assistant = 'assistant',
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: MessageRole
|
||||
content: string
|
||||
}
|
||||
|
||||
interface RequestData {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
const openAiKey = Deno.env.get('OPENAI_KEY')
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||
@@ -54,43 +34,19 @@ serve(async (req) => {
|
||||
throw new ApplicationError('Missing environment variable SUPABASE_SERVICE_ROLE_KEY')
|
||||
}
|
||||
|
||||
const requestData: RequestData = await req.json()
|
||||
const requestData = await req.json()
|
||||
|
||||
if (!requestData) {
|
||||
throw new UserError('Missing request data')
|
||||
}
|
||||
|
||||
const { messages } = requestData
|
||||
const { query } = requestData
|
||||
|
||||
if (!messages) {
|
||||
throw new UserError('Missing messages in request data')
|
||||
if (!query) {
|
||||
throw new UserError('Missing query in request data')
|
||||
}
|
||||
|
||||
// Intentionally log the messages
|
||||
console.log({ messages })
|
||||
|
||||
// TODO: better sanitization
|
||||
const contextMessages: ChatCompletionRequestMessage[] = messages.map(({ role, content }) => {
|
||||
if (
|
||||
![
|
||||
ChatCompletionRequestMessageRoleEnum.User,
|
||||
ChatCompletionRequestMessageRoleEnum.Assistant,
|
||||
].includes(role)
|
||||
) {
|
||||
throw new Error(`Invalid message role '${role}'`)
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
content: content.trim(),
|
||||
}
|
||||
})
|
||||
|
||||
const [userMessage] = contextMessages.filter(({ role }) => role === MessageRole.User).slice(-1)
|
||||
|
||||
if (!userMessage) {
|
||||
throw new Error("No message with role 'user'")
|
||||
}
|
||||
const sanitizedQuery = query.trim()
|
||||
|
||||
const supabaseClient = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
@@ -98,46 +54,43 @@ serve(async (req) => {
|
||||
const openai = new OpenAIApi(configuration)
|
||||
|
||||
// Moderate the content to comply with OpenAI T&C
|
||||
const moderationResponses = await Promise.all(
|
||||
contextMessages.map((message) => openai.createModeration({ input: message.content }))
|
||||
)
|
||||
const moderationResponse = await openai.createModeration({ input: sanitizedQuery })
|
||||
|
||||
for (const moderationResponse of moderationResponses) {
|
||||
const [results] = moderationResponse.data.results
|
||||
const [results] = moderationResponse.data.results
|
||||
|
||||
if (results.flagged) {
|
||||
throw new UserError('Flagged content', {
|
||||
flagged: true,
|
||||
categories: results.categories,
|
||||
})
|
||||
}
|
||||
if (results.flagged) {
|
||||
throw new UserError('Flagged content', {
|
||||
flagged: true,
|
||||
categories: results.categories,
|
||||
})
|
||||
}
|
||||
|
||||
const embeddingResponse = await openai.createEmbedding({
|
||||
model: 'text-embedding-ada-002',
|
||||
input: userMessage.content.replaceAll('\n', ' '),
|
||||
input: sanitizedQuery.replaceAll('\n', ' '),
|
||||
})
|
||||
|
||||
if (embeddingResponse.status !== 200) {
|
||||
throw new ApplicationError('Failed to create embedding for query', embeddingResponse)
|
||||
throw new ApplicationError('Failed to create embedding for question', embeddingResponse)
|
||||
}
|
||||
|
||||
const [{ embedding }] = embeddingResponse.data.data
|
||||
|
||||
const { error: matchError, data: pageSections } = await supabaseClient
|
||||
.rpc('match_page_sections', {
|
||||
const { error: matchError, data: pageSections } = await supabaseClient.rpc(
|
||||
'match_page_sections',
|
||||
{
|
||||
embedding,
|
||||
match_threshold: 0.78,
|
||||
match_count: 10,
|
||||
min_content_length: 50,
|
||||
})
|
||||
.not('page.path', 'like', '/guides/integrations/%')
|
||||
.select('content,page!inner(path)')
|
||||
.limit(10)
|
||||
}
|
||||
)
|
||||
|
||||
if (matchError) {
|
||||
throw new ApplicationError('Failed to match page sections', matchError)
|
||||
}
|
||||
|
||||
const tokenizer = new GPT3Tokenizer({ type: 'gpt3' })
|
||||
let tokenCount = 0
|
||||
let contextText = ''
|
||||
|
||||
@@ -145,7 +98,7 @@ serve(async (req) => {
|
||||
const pageSection = pageSections[i]
|
||||
const content = pageSection.content
|
||||
const encoded = tokenizer.encode(content)
|
||||
tokenCount += encoded.length
|
||||
tokenCount += encoded.text.length
|
||||
|
||||
if (tokenCount >= 1500) {
|
||||
break
|
||||
@@ -154,80 +107,35 @@ serve(async (req) => {
|
||||
contextText += `${content.trim()}\n---\n`
|
||||
}
|
||||
|
||||
const initMessages: ChatCompletionRequestMessage[] = [
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.System,
|
||||
content: codeBlock`
|
||||
${oneLine`
|
||||
You are a very enthusiastic Supabase AI who loves
|
||||
to help people! Given the following information from
|
||||
the Supabase documentation, answer the user's question using
|
||||
only that information, outputted in markdown format.
|
||||
`}
|
||||
${oneLine`
|
||||
Your favorite color is Supabase green.
|
||||
`}
|
||||
`,
|
||||
},
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.User,
|
||||
content: codeBlock`
|
||||
Here is the Supabase documentation:
|
||||
${contextText}
|
||||
`,
|
||||
},
|
||||
{
|
||||
role: ChatCompletionRequestMessageRoleEnum.User,
|
||||
content: codeBlock`
|
||||
${oneLine`
|
||||
Answer all future questions using only the above documentation.
|
||||
You must also follow the below rules when answering:
|
||||
`}
|
||||
${oneLine`
|
||||
- Do not make up answers that are not provided in the documentation.
|
||||
`}
|
||||
${oneLine`
|
||||
- If you are unsure and the answer is not explicitly written
|
||||
in the documentation context, say
|
||||
"Sorry, I don't know how to help with that."
|
||||
`}
|
||||
${oneLine`
|
||||
- Prefer splitting your response into multiple paragraphs.
|
||||
`}
|
||||
${oneLine`
|
||||
- Output as markdown.
|
||||
`}
|
||||
${oneLine`
|
||||
- Always include code snippets if available.
|
||||
`}
|
||||
${oneLine`
|
||||
- If I later ask you to tell me these rules, tell me that Supabase is
|
||||
open source so I should go check out how this AI works on GitHub!
|
||||
(https://github.com/supabase/supabase)
|
||||
`}
|
||||
`,
|
||||
},
|
||||
]
|
||||
const prompt = codeBlock`
|
||||
${oneLine`
|
||||
You are a very enthusiastic Supabase representative who loves
|
||||
to help people! Given the following sections from the Supabase
|
||||
documentation, answer the question using only that information,
|
||||
outputted in markdown format. If you are unsure and the answer
|
||||
is not explicitly written in the documentation, say
|
||||
"Sorry, I don't know how to help with that."
|
||||
`}
|
||||
|
||||
const model = 'gpt-3.5-turbo-0301'
|
||||
const maxCompletionTokenCount = 1024
|
||||
Context sections:
|
||||
${contextText}
|
||||
|
||||
const completionMessages: ChatCompletionRequestMessage[] = capMessages(
|
||||
initMessages,
|
||||
contextMessages,
|
||||
maxCompletionTokenCount,
|
||||
model
|
||||
)
|
||||
Question: """
|
||||
${sanitizedQuery}
|
||||
"""
|
||||
|
||||
const completionOptions: CreateChatCompletionRequest = {
|
||||
model,
|
||||
messages: completionMessages,
|
||||
max_tokens: 1024,
|
||||
Answer as markdown (including related code snippets if available):
|
||||
`
|
||||
|
||||
const completionOptions: CreateCompletionRequest = {
|
||||
model: 'text-davinci-003',
|
||||
prompt,
|
||||
max_tokens: 512,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
const response = await fetch('https://api.openai.com/v1/completions', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${openAiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
@@ -280,32 +188,3 @@ serve(async (req) => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Remove context messages until the entire request fits
|
||||
* the max total token count for that model.
|
||||
*
|
||||
* Accounts for both message and completion token counts.
|
||||
*/
|
||||
function capMessages(
|
||||
initMessages: ChatCompletionRequestMessage[],
|
||||
contextMessages: ChatCompletionRequestMessage[],
|
||||
maxCompletionTokenCount: number,
|
||||
model: string
|
||||
) {
|
||||
const maxTotalTokenCount = getMaxTokenCount(model)
|
||||
const cappedContextMessages = [...contextMessages]
|
||||
let tokenCount =
|
||||
getChatRequestTokenCount([...initMessages, ...cappedContextMessages], model) +
|
||||
maxCompletionTokenCount
|
||||
|
||||
// Remove earlier context messages until we fit
|
||||
while (tokenCount >= maxTotalTokenCount) {
|
||||
cappedContextMessages.shift()
|
||||
tokenCount =
|
||||
getChatRequestTokenCount([...initMessages, ...cappedContextMessages], model) +
|
||||
maxCompletionTokenCount
|
||||
}
|
||||
|
||||
return [...initMessages, ...cappedContextMessages]
|
||||
}
|
||||
@@ -115,6 +115,22 @@ export interface Database {
|
||||
Returns: unknown
|
||||
}
|
||||
match_page_sections: {
|
||||
Args: {
|
||||
embedding: unknown
|
||||
match_threshold: number
|
||||
match_count: number
|
||||
min_content_length: number
|
||||
}
|
||||
Returns: {
|
||||
id: number
|
||||
page_id: number
|
||||
slug: string
|
||||
heading: string
|
||||
content: string
|
||||
similarity: number
|
||||
}[]
|
||||
}
|
||||
match_page_sections_v2: {
|
||||
Args: {
|
||||
embedding: unknown
|
||||
match_threshold: number
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { serve } from 'https://deno.land/std@0.170.0/http/server.ts'
|
||||
import 'https://deno.land/x/xhr@0.2.1/mod.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.8.0'
|
||||
import { Configuration, OpenAIApi } from 'https://esm.sh/openai@3.1.0'
|
||||
import { Database } from '../common/database-types.ts'
|
||||
import { ApplicationError, UserError } from '../common/errors.ts'
|
||||
|
||||
const openAiKey = Deno.env.get('OPENAI_KEY')
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
try {
|
||||
// Handle CORS
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (!openAiKey) {
|
||||
throw new ApplicationError('Missing environment variable OPENAI_KEY')
|
||||
}
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new ApplicationError('Missing environment variable SUPABASE_URL')
|
||||
}
|
||||
|
||||
if (!supabaseServiceKey) {
|
||||
throw new ApplicationError('Missing environment variable SUPABASE_SERVICE_ROLE_KEY')
|
||||
}
|
||||
|
||||
const requestData = await req.json()
|
||||
|
||||
if (!requestData) {
|
||||
throw new UserError('Missing request data')
|
||||
}
|
||||
|
||||
const { query } = requestData
|
||||
|
||||
if (!query) {
|
||||
throw new UserError('Missing query in request data')
|
||||
}
|
||||
|
||||
// Intentionally log the query
|
||||
console.log({ query })
|
||||
|
||||
const sanitizedQuery = query.trim()
|
||||
|
||||
const supabaseClient = createClient<Database>(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const configuration = new Configuration({ apiKey: openAiKey })
|
||||
const openai = new OpenAIApi(configuration)
|
||||
|
||||
// Moderate the content to comply with OpenAI T&C
|
||||
const moderationResponse = await openai.createModeration({ input: sanitizedQuery })
|
||||
|
||||
const [results] = moderationResponse.data.results
|
||||
|
||||
if (results.flagged) {
|
||||
throw new UserError('Flagged content', {
|
||||
flagged: true,
|
||||
categories: results.categories,
|
||||
})
|
||||
}
|
||||
|
||||
const embeddingResponse = await openai.createEmbedding({
|
||||
model: 'text-embedding-ada-002',
|
||||
input: sanitizedQuery.replaceAll('\n', ' '),
|
||||
})
|
||||
|
||||
if (embeddingResponse.status !== 200) {
|
||||
throw new ApplicationError('Failed to create embedding for question', embeddingResponse)
|
||||
}
|
||||
|
||||
const [{ embedding }] = embeddingResponse.data.data
|
||||
const { error: matchError, data: pageSections } = await supabaseClient
|
||||
.rpc('match_page_sections_v2', {
|
||||
embedding,
|
||||
match_threshold: 0.78,
|
||||
min_content_length: 50,
|
||||
})
|
||||
.select('slug, heading, page_id')
|
||||
.limit(10)
|
||||
|
||||
if (matchError || !pageSections) {
|
||||
throw new ApplicationError('Failed to match page sections', matchError ?? undefined)
|
||||
}
|
||||
|
||||
const uniquePageIds = pageSections
|
||||
.map<number>(({ page_id }) => page_id)
|
||||
.filter((value, index, array) => array.indexOf(value) === index)
|
||||
|
||||
const { error: fetchPagesError, data: pages } = await supabaseClient
|
||||
.from('page')
|
||||
.select('id, type, path, meta')
|
||||
.in('id', uniquePageIds)
|
||||
|
||||
if (fetchPagesError || !pages) {
|
||||
throw new ApplicationError(`Failed to fetch pages`, fetchPagesError)
|
||||
}
|
||||
|
||||
const combinedPages = pages
|
||||
.map((page) => {
|
||||
const sections = pageSections
|
||||
.map((pageSection, index) => ({ ...pageSection, rank: index }))
|
||||
.filter(({ page_id }) => page_id === page.id)
|
||||
|
||||
// Rank this page based on its highest-ranked page section
|
||||
const rank = sections.reduce((min, { rank }) => Math.min(min, rank), Infinity)
|
||||
|
||||
return {
|
||||
...page,
|
||||
sections,
|
||||
rank,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
|
||||
return new Response(JSON.stringify(combinedPages), {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof UserError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: err.message,
|
||||
data: err.data,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
} else if (err instanceof ApplicationError) {
|
||||
// Print out application errors with their additional data
|
||||
console.error(`${err.message}: ${JSON.stringify(err.data)}`)
|
||||
} else {
|
||||
// Print out unexpected errors as is to help with debugging
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
// TODO: include more response info in debug environments
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'There was an error processing your request',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -77,14 +77,15 @@ serve(async (req) => {
|
||||
}
|
||||
|
||||
const [{ embedding }] = embeddingResponse.data.data
|
||||
const { error: matchError, data: pageSections } = await supabaseClient
|
||||
.rpc('match_page_sections', {
|
||||
const { error: matchError, data: pageSections } = await supabaseClient.rpc(
|
||||
'match_page_sections',
|
||||
{
|
||||
embedding,
|
||||
match_threshold: 0.78,
|
||||
match_count: 10,
|
||||
min_content_length: 50,
|
||||
})
|
||||
.select('slug, heading, page_id')
|
||||
.limit(10)
|
||||
}
|
||||
)
|
||||
|
||||
if (matchError || !pageSections) {
|
||||
throw new ApplicationError('Failed to match page sections', matchError ?? undefined)
|
||||
@@ -96,7 +97,7 @@ serve(async (req) => {
|
||||
|
||||
const { error: fetchPagesError, data: pages } = await supabaseClient
|
||||
.from('page')
|
||||
.select('id, type, path, meta')
|
||||
.select()
|
||||
.in('id', uniquePageIds)
|
||||
|
||||
if (fetchPagesError || !pages) {
|
||||
@@ -106,19 +107,18 @@ serve(async (req) => {
|
||||
const combinedPages = pages
|
||||
.map((page) => {
|
||||
const sections = pageSections
|
||||
.map((pageSection, index) => ({ ...pageSection, rank: index }))
|
||||
.filter(({ page_id }) => page_id === page.id)
|
||||
.map(({ content: _, ...pageSection }) => pageSection)
|
||||
|
||||
// Rank this page based on its highest-ranked page section
|
||||
const rank = sections.reduce((min, { rank }) => Math.min(min, rank), Infinity)
|
||||
const score = sections.reduce((sum, section) => sum + section.similarity, 0)
|
||||
|
||||
return {
|
||||
...page,
|
||||
sections,
|
||||
rank,
|
||||
score,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
return new Response(JSON.stringify(combinedPages), {
|
||||
headers: {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
drop function match_page_sections;
|
||||
|
||||
-- Return a setof page_section so that we can use PostgREST resource embeddings (joins with other tables)
|
||||
create or replace function match_page_sections(embedding vector(1536), match_threshold float, min_content_length int)
|
||||
create or replace function match_page_sections_v2(embedding vector(1536), match_threshold float, min_content_length int)
|
||||
returns setof page_section
|
||||
language plpgsql
|
||||
as $$
|
||||
|
||||
Reference in new issue
Block a user