revert 1af0358e65
Deploy Function / deploy (push) Failing after 43s
Deploy Function / deploy (push) Failing after 43s
revert Merge pull request 'REFACTOR OpenAI service to manage env and convo creation' (#12) from issue/9-hacer-una-funcin-que-inserte-el-id-de-conversacin into main Reviewed-on: AlexRG/genesis-2#12
This commit is contained in:
@@ -6,197 +6,221 @@ import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2";
|
|||||||
|
|
||||||
// Use non-streaming params to ensure `responses.create` returns a typed Response
|
// Use non-streaming params to ensure `responses.create` returns a typed Response
|
||||||
export type StructuredResponseOptions =
|
export type StructuredResponseOptions =
|
||||||
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
|
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
|
||||||
|
|
||||||
export type StructuredResponseSuccess<TOutput = unknown> = {
|
export type StructuredResponseSuccess<TOutput = unknown> = {
|
||||||
ok: true;
|
ok: true;
|
||||||
output?: TOutput; // parsed JSON when available
|
output?: TOutput; // parsed JSON when available
|
||||||
outputText?: string; // raw text when parsing is not possible
|
outputText?: string; // raw text when parsing is not possible
|
||||||
model: string;
|
model: string;
|
||||||
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
|
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
|
||||||
responseId: string;
|
responseId: string;
|
||||||
conversationId?: string | null;
|
conversationId?: string | null;
|
||||||
references: {
|
references: {
|
||||||
uploadedToStorage: string[]; // supabase storage paths
|
uploadedToStorage: string[]; // supabase storage paths
|
||||||
openaiFileIds: string[]; // file ids in OpenAI
|
openaiFileIds: string[]; // file ids in OpenAI
|
||||||
};
|
};
|
||||||
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructuredResponseFailure = {
|
export type StructuredResponseFailure = {
|
||||||
ok: false;
|
ok: false;
|
||||||
code:
|
code:
|
||||||
| "MissingEnv"
|
| "MissingEnv"
|
||||||
| "StorageUploadFailed"
|
| "StorageUploadFailed"
|
||||||
| "OpenAIFileUploadFailed"
|
| "OpenAIFileUploadFailed"
|
||||||
| "OpenAIRequestFailed";
|
| "OpenAIRequestFailed";
|
||||||
message: string;
|
message: string;
|
||||||
cause?: unknown;
|
cause?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructuredResponseResult<TOutput = unknown> =
|
export type StructuredResponseResult<TOutput = unknown> =
|
||||||
| StructuredResponseSuccess<TOutput>
|
| StructuredResponseSuccess<TOutput>
|
||||||
| StructuredResponseFailure;
|
| StructuredResponseFailure;
|
||||||
|
|
||||||
export interface OpenAIServiceConfig {
|
export interface OpenAIServiceConfig {
|
||||||
supabaseUrl: string;
|
supabaseUrl: string;
|
||||||
serviceRoleKey: string;
|
serviceRoleKey: string;
|
||||||
openAIApiKey: string;
|
openAIApiKey: string;
|
||||||
bucket?: string; // default: ai-storage
|
bucket?: string; // default: ai-storage
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenAIService {
|
export class OpenAIService {
|
||||||
private readonly supabase: SupabaseClient;
|
private readonly supabase: SupabaseClient;
|
||||||
private readonly openai: OpenAI;
|
private readonly openai: OpenAI;
|
||||||
private readonly bucket: string;
|
private readonly bucket: string;
|
||||||
|
|
||||||
private constructor(client: SupabaseClient, openai: OpenAI, bucket: string) {
|
private constructor(
|
||||||
this.supabase = client;
|
client: SupabaseClient,
|
||||||
this.openai = openai;
|
openai: OpenAI,
|
||||||
this.bucket = bucket;
|
bucket: string,
|
||||||
}
|
) {
|
||||||
|
this.supabase = client;
|
||||||
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
this.openai = openai;
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
this.bucket = bucket;
|
||||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
|
||||||
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
|
||||||
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
|
||||||
|
|
||||||
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: "MissingEnv",
|
|
||||||
message:
|
|
||||||
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient(supabaseUrl, serviceRoleKey);
|
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
||||||
const openai = new OpenAI({ apiKey: openAIApiKey });
|
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||||
return new OpenAIService(client, openai, bucket);
|
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||||
}
|
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||||
|
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||||
|
|
||||||
async createConversation(metadata?: Record<string, string>) {
|
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
||||||
const conversation = await this.openai.conversations.create({
|
return {
|
||||||
metadata,
|
ok: false,
|
||||||
});
|
code: "MissingEnv",
|
||||||
return conversation;
|
message:
|
||||||
}
|
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async createStructuredResponse<TOutput = unknown>(
|
const client = createClient(supabaseUrl, serviceRoleKey);
|
||||||
options: StructuredResponseOptions,
|
const openai = new OpenAI({ apiKey: openAIApiKey });
|
||||||
files?: File[],
|
return new OpenAIService(client, openai, bucket);
|
||||||
): Promise<StructuredResponseResult<TOutput>> {
|
}
|
||||||
try {
|
|
||||||
const uploadedToStorage = await this.uploadFilesToStorage(files ?? []);
|
|
||||||
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
|
||||||
|
|
||||||
// Narrow to non-streaming response
|
async createStructuredResponse<TOutput = unknown>(
|
||||||
const openaiRaw = (await this.openai.responses.create(
|
options: StructuredResponseOptions,
|
||||||
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
|
files?: File[],
|
||||||
)) as OpenAITypes.OpenAI.Responses.Response;
|
): Promise<StructuredResponseResult<TOutput>> {
|
||||||
|
|
||||||
const { model, id: responseId } = openaiRaw;
|
|
||||||
const usage = openaiRaw?.usage ?? null;
|
|
||||||
const conversationId =
|
|
||||||
(
|
|
||||||
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
|
|
||||||
conversation_id?: string | null;
|
|
||||||
}
|
|
||||||
).conversation_id ?? null;
|
|
||||||
|
|
||||||
// Try to read structured JSON output
|
|
||||||
let output: TOutput | undefined = undefined;
|
|
||||||
let outputText: string | undefined = undefined;
|
|
||||||
|
|
||||||
// Prefer `output_text` if present (SDK convenience)
|
|
||||||
const maybeOutputText = openaiRaw.output_text;
|
|
||||||
if (typeof maybeOutputText === "string" && maybeOutputText.length > 0) {
|
|
||||||
outputText = maybeOutputText;
|
|
||||||
try {
|
try {
|
||||||
output = JSON.parse(maybeOutputText) as TOutput;
|
const uploadedToStorage = await this.uploadFilesToStorage(
|
||||||
} catch {
|
files ?? [],
|
||||||
/* non-JSON text, keep as text only */
|
);
|
||||||
|
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
||||||
|
|
||||||
|
const newOptions = { ...options };
|
||||||
|
|
||||||
|
// Attach file references to the request as an extra user message
|
||||||
|
if (openaiFileIds.length > 0) {
|
||||||
|
const fileParts:
|
||||||
|
OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
|
||||||
|
openaiFileIds.map((id) => ({
|
||||||
|
type: "input_file",
|
||||||
|
file_id: id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const arr = Array.isArray(options.input) ? options.input : [];
|
||||||
|
arr.push({
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
...fileParts,
|
||||||
|
{
|
||||||
|
type: "input_text",
|
||||||
|
text: "Usa estos archivos como referencia",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
newOptions.input = arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow to non-streaming response
|
||||||
|
const openaiRaw = (await this.openai.responses.create(
|
||||||
|
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
|
||||||
|
)) as OpenAITypes.OpenAI.Responses.Response;
|
||||||
|
|
||||||
|
const { model, id: responseId } = openaiRaw;
|
||||||
|
const usage = openaiRaw?.usage ?? null;
|
||||||
|
const conversationId = (
|
||||||
|
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
|
||||||
|
conversation_id?: string | null;
|
||||||
|
}
|
||||||
|
).conversation_id ?? null;
|
||||||
|
|
||||||
|
// Try to read structured JSON output
|
||||||
|
let output: TOutput | undefined = undefined;
|
||||||
|
let outputText: string | undefined = undefined;
|
||||||
|
|
||||||
|
// Prefer `output_text` if present (SDK convenience)
|
||||||
|
const maybeOutputText = openaiRaw.output_text;
|
||||||
|
if (
|
||||||
|
typeof maybeOutputText === "string" &&
|
||||||
|
maybeOutputText.length > 0
|
||||||
|
) {
|
||||||
|
outputText = maybeOutputText;
|
||||||
|
try {
|
||||||
|
output = JSON.parse(maybeOutputText) as TOutput;
|
||||||
|
} catch { /* non-JSON text, keep as text only */ }
|
||||||
|
} else {
|
||||||
|
// Fallback: attempt to serialize `openaiRaw.output` into text
|
||||||
|
const maybeOutput = openaiRaw.output as unknown;
|
||||||
|
if (typeof maybeOutput === "object" && maybeOutput != null) {
|
||||||
|
try {
|
||||||
|
outputText = JSON.stringify(maybeOutput);
|
||||||
|
output = maybeOutput as TOutput;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
output,
|
||||||
|
outputText,
|
||||||
|
model: String(model),
|
||||||
|
usage,
|
||||||
|
responseId: String(responseId),
|
||||||
|
conversationId: conversationId ? String(conversationId) : null,
|
||||||
|
references: { uploadedToStorage, openaiFileIds },
|
||||||
|
openaiRaw,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error;
|
||||||
|
const message = e?.message ?? "Unknown error";
|
||||||
|
const code = message.includes("Supabase upload failed")
|
||||||
|
? "StorageUploadFailed"
|
||||||
|
: message.includes("OpenAI file upload failed")
|
||||||
|
? "OpenAIFileUploadFailed"
|
||||||
|
: "OpenAIRequestFailed";
|
||||||
|
|
||||||
|
return { ok: false, code, message, cause: err };
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Fallback: attempt to serialize `openaiRaw.output` into text
|
|
||||||
const maybeOutput = openaiRaw.output as unknown;
|
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
|
||||||
if (typeof maybeOutput === "object" && maybeOutput != null) {
|
const paths: string[] = [];
|
||||||
try {
|
for (const file of files) {
|
||||||
outputText = JSON.stringify(maybeOutput);
|
const safeName = this.sanitizeFilename(file.name);
|
||||||
output = maybeOutput as TOutput;
|
const path = `${crypto.randomUUID()}-${safeName}`;
|
||||||
} catch {
|
const { data, error } = await this.supabase.storage
|
||||||
/* ignore */
|
.from(this.bucket)
|
||||||
}
|
.upload(path, file, {
|
||||||
|
contentType: file.type || "application/octet-stream",
|
||||||
|
upsert: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Supabase upload failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
paths.push(data.path);
|
||||||
}
|
}
|
||||||
}
|
return paths;
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
output,
|
|
||||||
outputText,
|
|
||||||
model: String(model),
|
|
||||||
usage,
|
|
||||||
responseId: String(responseId),
|
|
||||||
conversationId: conversationId ? String(conversationId) : null,
|
|
||||||
references: { uploadedToStorage, openaiFileIds },
|
|
||||||
openaiRaw,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as Error;
|
|
||||||
const message = e?.message ?? "Unknown error";
|
|
||||||
const code = message.includes("Supabase upload failed")
|
|
||||||
? "StorageUploadFailed"
|
|
||||||
: message.includes("OpenAI file upload failed")
|
|
||||||
? "OpenAIFileUploadFailed"
|
|
||||||
: "OpenAIRequestFailed";
|
|
||||||
|
|
||||||
return { ok: false, code, message, cause: err };
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
|
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
|
||||||
const paths: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const safeName = this.sanitizeFilename(file.name);
|
try {
|
||||||
const path = `${crypto.randomUUID()}-${safeName}`;
|
const created = await this.openai.files.create({
|
||||||
const { data, error } = await this.supabase.storage
|
file,
|
||||||
.from(this.bucket)
|
purpose: "user_data",
|
||||||
.upload(path, file, {
|
});
|
||||||
contentType: file.type || "application/octet-stream",
|
ids.push(created.id);
|
||||||
upsert: false,
|
} catch (e) {
|
||||||
});
|
throw new Error(
|
||||||
|
`OpenAI file upload failed: ${
|
||||||
if (error) {
|
(e as Error)?.message ?? String(e)
|
||||||
throw new Error(`Supabase upload failed: ${error.message}`);
|
}`,
|
||||||
}
|
);
|
||||||
paths.push(data.path);
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
|
private sanitizeFilename(name: string): string {
|
||||||
const ids: string[] = [];
|
return name
|
||||||
for (const file of files) {
|
.normalize("NFD")
|
||||||
try {
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
const created = await this.openai.files.create({
|
.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||||
file,
|
|
||||||
purpose: "user_data",
|
|
||||||
});
|
|
||||||
ids.push(created.id);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`OpenAI file upload failed: ${(e as Error)?.message ?? String(e)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sanitizeFilename(name: string): string {
|
|
||||||
return name
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// Setup type definitions for built-in Supabase Runtime APIs
|
// Setup type definitions for built-in Supabase Runtime APIs
|
||||||
import "@supabase/functions-js/edge-runtime.d.ts";
|
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
|
||||||
import { OpenAIService } from "../_shared/openai-service.ts";
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
type WebhookPayload = {
|
type WebhookPayload = {
|
||||||
@@ -15,6 +13,9 @@ type WebhookPayload = {
|
|||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
||||||
const SUPABASE_SERVICE_ROLE_KEY =
|
const SUPABASE_SERVICE_ROLE_KEY =
|
||||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||||
|
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||||
|
const OPENAI_BASE_URL =
|
||||||
|
Deno.env.get("OPENAI_BASE_URL") ?? "https://api.openai.com/v1";
|
||||||
|
|
||||||
const ALLOWED_SCHEMA = "public";
|
const ALLOWED_SCHEMA = "public";
|
||||||
const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]);
|
const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]);
|
||||||
@@ -26,16 +27,27 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
|||||||
function jsonResponse(status: number, body: Record<string, unknown>) {
|
function jsonResponse(status: number, body: Record<string, unknown>) {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
status,
|
status,
|
||||||
headers: { "Content-Type": "application/json", ...corsHeaders },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createConversationId(
|
async function createConversationId(metadata: Record<string, string>) {
|
||||||
openaiService: OpenAIService,
|
const response = await fetch(`${OPENAI_BASE_URL}/conversations`, {
|
||||||
metadata: Record<string, string>,
|
method: "POST",
|
||||||
) {
|
headers: {
|
||||||
const conversation = await openaiService.createConversation(metadata);
|
"Content-Type": "application/json",
|
||||||
const conversationId = conversation?.id as string | undefined;
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ metadata }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OpenAI error: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const conversationId = data?.id as string | undefined;
|
||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
throw new Error("OpenAI response missing conversation id");
|
throw new Error("OpenAI response missing conversation id");
|
||||||
@@ -53,9 +65,8 @@ Deno.serve(async (req) => {
|
|||||||
return jsonResponse(500, { error: "Supabase env vars missing" });
|
return jsonResponse(500, { error: "Supabase env vars missing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const openaiService = OpenAIService.fromEnv();
|
if (!OPENAI_API_KEY) {
|
||||||
if (!(openaiService instanceof OpenAIService)) {
|
return jsonResponse(500, { error: "OPENAI_API_KEY missing" });
|
||||||
return jsonResponse(500, { error: openaiService.message });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: WebhookPayload;
|
let payload: WebhookPayload;
|
||||||
@@ -97,7 +108,7 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
let conversationId: string;
|
let conversationId: string;
|
||||||
try {
|
try {
|
||||||
conversationId = await createConversationId(openaiService, {
|
conversationId = await createConversationId({
|
||||||
table,
|
table,
|
||||||
record_id: String(recordId),
|
record_id: String(recordId),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user