From b1a90858dca575f952a497e3da26c5fdd6b23c4f Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Thu, 5 Feb 2026 20:41:28 +0000 Subject: [PATCH] revert 1af0358e651f8dc747b00c29eba6796fa5e37172 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: https://github.lci.ulsa.mx/AlexRG/genesis-2/pulls/12 --- supabase/functions/_shared/openai-service.ts | 356 ++++++++++-------- .../create-chat-conversation/index.ts | 37 +- 2 files changed, 214 insertions(+), 179 deletions(-) diff --git a/supabase/functions/_shared/openai-service.ts b/supabase/functions/_shared/openai-service.ts index 0f46ff6..afa0cd4 100644 --- a/supabase/functions/_shared/openai-service.ts +++ b/supabase/functions/_shared/openai-service.ts @@ -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 export type StructuredResponseOptions = - OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming; + OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming; export type StructuredResponseSuccess = { - ok: true; - output?: TOutput; // parsed JSON when available - outputText?: string; // raw text when parsing is not possible - model: string; - usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null; - responseId: string; - conversationId?: string | null; - references: { - uploadedToStorage: string[]; // supabase storage paths - openaiFileIds: string[]; // file ids in OpenAI - }; - openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers + ok: true; + output?: TOutput; // parsed JSON when available + outputText?: string; // raw text when parsing is not possible + model: string; + usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null; + responseId: string; + conversationId?: string | null; + references: { + uploadedToStorage: string[]; // supabase storage paths + openaiFileIds: string[]; // file ids in OpenAI + }; + openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers }; export type StructuredResponseFailure = { - ok: false; - code: - | "MissingEnv" - | "StorageUploadFailed" - | "OpenAIFileUploadFailed" - | "OpenAIRequestFailed"; - message: string; - cause?: unknown; + ok: false; + code: + | "MissingEnv" + | "StorageUploadFailed" + | "OpenAIFileUploadFailed" + | "OpenAIRequestFailed"; + message: string; + cause?: unknown; }; export type StructuredResponseResult = - | StructuredResponseSuccess - | StructuredResponseFailure; + | StructuredResponseSuccess + | StructuredResponseFailure; export interface OpenAIServiceConfig { - supabaseUrl: string; - serviceRoleKey: string; - openAIApiKey: string; - bucket?: string; // default: ai-storage + supabaseUrl: string; + serviceRoleKey: string; + openAIApiKey: string; + bucket?: string; // default: ai-storage } export class OpenAIService { - private readonly supabase: SupabaseClient; - private readonly openai: OpenAI; - private readonly bucket: string; + private readonly supabase: SupabaseClient; + private readonly openai: OpenAI; + private readonly bucket: string; - private constructor(client: SupabaseClient, openai: OpenAI, bucket: string) { - this.supabase = client; - this.openai = openai; - this.bucket = bucket; - } - - static fromEnv(): StructuredResponseFailure | OpenAIService { - const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; - 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", - }; + private constructor( + client: SupabaseClient, + openai: OpenAI, + bucket: string, + ) { + this.supabase = client; + this.openai = openai; + this.bucket = bucket; } - const client = createClient(supabaseUrl, serviceRoleKey); - const openai = new OpenAI({ apiKey: openAIApiKey }); - return new OpenAIService(client, openai, bucket); - } + static fromEnv(): StructuredResponseFailure | OpenAIService { + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + 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) { - const conversation = await this.openai.conversations.create({ - metadata, - }); - return conversation; - } + if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) { + return { + ok: false, + code: "MissingEnv", + message: + "Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY", + }; + } - async createStructuredResponse( - options: StructuredResponseOptions, - files?: File[], - ): Promise> { - try { - const uploadedToStorage = await this.uploadFilesToStorage(files ?? []); - const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []); + const client = createClient(supabaseUrl, serviceRoleKey); + const openai = new OpenAI({ apiKey: openAIApiKey }); + return new OpenAIService(client, openai, bucket); + } - // 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; + async createStructuredResponse( + options: StructuredResponseOptions, + files?: File[], + ): Promise> { try { - output = JSON.parse(maybeOutputText) as TOutput; - } catch { - /* non-JSON text, keep as text only */ + const uploadedToStorage = await this.uploadFilesToStorage( + files ?? [], + ); + 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; - if (typeof maybeOutput === "object" && maybeOutput != null) { - try { - outputText = JSON.stringify(maybeOutput); - output = maybeOutput as TOutput; - } catch { - /* ignore */ - } + } + + private async uploadFilesToStorage(files: File[]): Promise { + const paths: string[] = []; + for (const file of files) { + const safeName = this.sanitizeFilename(file.name); + const path = `${crypto.randomUUID()}-${safeName}`; + const { data, error } = await this.supabase.storage + .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 { - 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 }; + return paths; } - } - private async uploadFilesToStorage(files: File[]): Promise { - const paths: string[] = []; - for (const file of files) { - const safeName = this.sanitizeFilename(file.name); - const path = `${crypto.randomUUID()}-${safeName}`; - const { data, error } = await this.supabase.storage - .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); + private async uploadFilesToOpenAI(files: File[]): Promise { + const ids: string[] = []; + for (const file of files) { + try { + const created = await this.openai.files.create({ + 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; } - return paths; - } - private async uploadFilesToOpenAI(files: File[]): Promise { - const ids: string[] = []; - for (const file of files) { - try { - const created = await this.openai.files.create({ - file, - purpose: "user_data", - }); - ids.push(created.id); - } catch (e) { - throw new Error( - `OpenAI file upload failed: ${(e as Error)?.message ?? String(e)}`, - ); - } + private sanitizeFilename(name: string): string { + return name + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-zA-Z0-9.-]/g, "_"); } - return ids; - } - - private sanitizeFilename(name: string): string { - return name - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-zA-Z0-9.-]/g, "_"); - } } diff --git a/supabase/functions/create-chat-conversation/index.ts b/supabase/functions/create-chat-conversation/index.ts index 59d377e..cadbed2 100644 --- a/supabase/functions/create-chat-conversation/index.ts +++ b/supabase/functions/create-chat-conversation/index.ts @@ -1,7 +1,5 @@ // Setup type definitions for built-in Supabase Runtime APIs 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"; type WebhookPayload = { @@ -15,6 +13,9 @@ type WebhookPayload = { const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; const 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_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) { return new Response(JSON.stringify(body), { status, - headers: { "Content-Type": "application/json", ...corsHeaders }, + headers: { "Content-Type": "application/json" }, }); } -async function createConversationId( - openaiService: OpenAIService, - metadata: Record, -) { - const conversation = await openaiService.createConversation(metadata); - const conversationId = conversation?.id as string | undefined; +async function createConversationId(metadata: Record) { + const response = await fetch(`${OPENAI_BASE_URL}/conversations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + 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) { throw new Error("OpenAI response missing conversation id"); @@ -53,9 +65,8 @@ Deno.serve(async (req) => { return jsonResponse(500, { error: "Supabase env vars missing" }); } - const openaiService = OpenAIService.fromEnv(); - if (!(openaiService instanceof OpenAIService)) { - return jsonResponse(500, { error: openaiService.message }); + if (!OPENAI_API_KEY) { + return jsonResponse(500, { error: "OPENAI_API_KEY missing" }); } let payload: WebhookPayload; @@ -97,7 +108,7 @@ Deno.serve(async (req) => { let conversationId: string; try { - conversationId = await createConversationId(openaiService, { + conversationId = await createConversationId({ table, record_id: String(recordId), });