Compare commits

..

7 Commits

Author SHA1 Message Date
Guillermo.Arrieta 00f0bddf1c Arreglo de reverts 2026-02-05 16:00:07 -06:00
Guillermo.Arrieta 1ca861c51d Primera versión de la función de ai-generate-subject 2026-02-05 15:54:04 -06:00
Guillermo.Arrieta 42a437afa3 fix(functions): clasificar y estandarizar errores en ai-generate-plan
- Corregido el problema donde la función devolvía siempre 400; ahora se lanzan HttpError con status adecuados (405, 401, 415, 422, 404, 409, 502, 500) según el caso.
- Refactor: usar sendError/sendSuccess y HttpError desde _shared/utils.ts para respuestas consistentes y logging seguro.
- Validación: agrupar errores de parse/validación en un único mensaje numerado (cada error en nueva línea).
- OpenAI: manejo explícito de fallos (MissingEnv, JSON inválido, salida estructurada ausente) y mapeo a status apropiados.
- Supabase: distinguir errores 404/409/500 en consultas e inserts; incluir detalles en internalDetails para diagnóstico server-side.
- Eliminada dependencia de invocación HTTP a ai-structured; usar servicio compartido directo.
- Ajustes menores en mensajes y configuración (deno imports/deno.json) y tests marcados como ignorados donde corresponde.
2026-02-05 15:54:04 -06:00
Guillermo.Arrieta 7660d2c3a0 inicio de funcion de generación de asignatura 2026-02-05 15:53:23 -06:00
Guillermo.Arrieta e5657d6b0f chore(db): actualizar esquema/backups (auditoría de planes, hashes y conversation_id)
- Agrega fn_log_cambios_planes_estudio y trigger para registrar cambios en cambios_plan, usando response_id (reemplaza interaccion_ia_id).
- Extiende planes_estudio con conversation_id (unique) y plan_hash; agrega asignatura_hash en asignaturas; corrige unaccent_immutable y añade extensión http.
- Actualiza seeds/backups (data_backup.sql / scheme_backup.sql) y ajusta script de diff (sql_para_local_como_remoto.sql), elimina el diff viejo y actualiza archivos auxiliares (deno.lock, CLI temp).
2026-02-05 15:52:56 -06:00
alexrg b1a90858dc revert 1af0358e65
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
2026-02-05 20:41:28 +00:00
roberto.silva 132a51133b revert e1d865e6c3
Deploy Function / deploy (push) Failing after 45s
revert posible solución a issue #60
2026-02-05 16:11:09 +00:00
3 changed files with 214 additions and 441 deletions
-203
View File
@@ -1,203 +0,0 @@
{
"type": "object",
"required": [
"nombre_autorizado_de_la_institucion",
"nivel_y_nombre_del_plan_de_estudios",
"modalidad_educativa",
"vigencia",
"antecedente_academico",
"area_de_estudio",
"clave_del_plan_de_estudios",
"diseno_curricular",
"total_de_ciclos_del_plan_de_estudios",
"duracion_del_ciclo_escolar",
"carga_horaria_a_la_semana",
"fines_de_aprendizaje_o_formacion",
"perfil_de_egreso",
"programa_de_investigacion",
"curso_propedeutico",
"perfil_de_ingreso",
"administracion_y_operatividad_del_plan_de_estudios",
"sustento_teorico_del_modelo_curricular",
"justificacion_de_la_propuesta_curricular",
"propuesta_de_evaluacion_periodica_del_plan_de_estudios",
"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios"
],
"properties": {
"nombre_autorizado_de_la_institucion": {
"type": "string",
"title": "Nombre autorizado de la institución",
"examples": ["Universidad La Salle A.C."],
"description": "Proporcionar el nombre de la institución autorizado por SEP.",
"referencia_normativa": "Dato administrativo del Anexo 1."
},
"nivel_y_nombre_del_plan_de_estudios": {
"type": "string",
"title": "Nivel y nombre del plan de estudios",
"examples": [
"Licenciatura en Derecho",
"Maestría en Ciencias de la Computación"
],
"description": "Indicar el nivel educativo y denominación del plan de estudios.",
"referencia_normativa": "Corresponde al requisito 'Nivel y denominación del plan de estudios' del Artículo 8, fracción I del Acuerdo 17/11/17."
},
"modalidad_educativa": {
"enum": ["Escolar", "No escolarizada", "Mixta"],
"type": "string",
"title": "Modalidad educativa",
"examples": ["Escolar"],
"description": "Señalar la modalidad educativa en la que se impartirá el plan de estudios: escolar, no escolarizada o mixta.",
"referencia_normativa": "Corresponde al requisito de 'Modalidad educativa' del Artículo 8, fracción II del Acuerdo 17/11/17."
},
"vigencia": {
"type": "string",
"title": "Vigencia",
"examples": ["2024-2029"],
"description": "(Espacio para uso exclusivo de la autoridad educativa)",
"referencia_normativa": "Dato administrativo del Anexo 1."
},
"antecedente_academico": {
"type": "string",
"title": "Antecedente académico",
"examples": [
"Bachillerato concluido o equivalente.",
"Título de Licenciatura en áreas afines a la salud."
],
"description": "Señalar el nivel educativo requerido para ingresar al plan de estudios. Para posgrados, además del nivel educativo; indicar la(s) disciplina(s) o área(s) afín(es) al plan de estudios.",
"referencia_normativa": "Corresponde al requisito 'Perfil de ingreso' (antecedente académico necesario) del Artículo 8, fracción V del Acuerdo 17/11/17."
},
"area_de_estudio": {
"type": "string",
"title": "Área de estudio",
"examples": [
"Ingeniería, industria y construcción",
"Ciencias sociales y derecho"
],
"description": "Señalar el “campo amplio” al que pertenece el plan de estudios. Para ello, considerar la Clasificación Mexicana de Planes de Estudio por Campos de Formación Académica (CMPE 2016), publicado por INEGI.",
"referencia_normativa": "Deberá vincularse con el fin de aprendizaje y perfil de egreso del plan de estudios."
},
"clave_del_plan_de_estudios": {
"type": "string",
"title": "Clave / Año del plan",
"examples": ["2024"],
"description": "Señalar el año en el que solicita el trámite.",
"referencia_normativa": "Dato administrativo del Anexo 1."
},
"diseno_curricular": {
"enum": ["Rígido", "Flexible"],
"type": "string",
"title": "Diseño curricular",
"examples": ["Flexible"],
"description": "Indicar si es “Rígido” o “Flexible”.",
"referencia_normativa": "Define la estructura para el requisito 'Mapa curricular' del Artículo 8, fracción VII del Acuerdo 17/11/17."
},
"total_de_ciclos_del_plan_de_estudios": {
"type": "string",
"title": "Número de ciclos escolares que integrarán el plan de estudio",
"examples": ["9 cuatrimestres", "8 semestres"],
"description": "En diseño flexible especificar mínimo y máximo de ciclos. En diseño rígido especificar el total de ciclos.",
"referencia_normativa": "Corresponde a 'señalando el número de ciclos en que se impartirá' del Artículo 8, fracción III del Acuerdo 17/11/17."
},
"duracion_del_ciclo_escolar": {
"type": "string",
"title": "Duración mínima en semanas de cada ciclo escolar",
"examples": ["16 semanas"],
"description": "Señalar el número mínimo de semanas efectivas de clase que integran el tipo de ciclo en el que se imparte el plan de estudios.",
"referencia_normativa": "Corresponde exactamente al requisito 'Duración mínima en semanas' del Artículo 8, fracción III del Acuerdo 17/11/17."
},
"carga_horaria_a_la_semana": {
"type": "number",
"title": "Carga horaria a la semana",
"examples": [25],
"description": "Indicar la máxima carga horaria impartida a la semana; no mayor a 50 horas bajo la conducción de un académico.",
"referencia_normativa": "Corresponde a la restricción 'sin exceder una carga máxima de 50 horas efectivas por semana' del Artículo 8, fracción III del Acuerdo 17/11/17."
},
"fines_de_aprendizaje_o_formacion": {
"type": "string",
"title": "Fin de aprendizaje o formación",
"examples": [
"El estudiante analizará los principios fundamentales del derecho penal..."
],
"description": "Describir los aprendizajes que lograrán los estudiantes al concluir el plan de estudios. Deberá corresponder con el nivel educativo y la denominación del plan de estudios.",
"referencia_normativa": "Corresponde al requisito 'Descripción de los fines del aprendizaje o formación' del Artículo 8, fracción IV del Acuerdo 17/11/17."
},
"perfil_de_egreso": {
"type": "string",
"title": "Perfil de egreso",
"examples": ["El egresado será capaz de diseñar estrategias legales..."],
"description": "Describir los atributos que habrán desarrollado los estudiantes al concluir el plan de estudios, acordes al nivel educativo y denominación propuesta. De preferencia, dividirlo en conocimientos, habilidades y actitudes.",
"referencia_normativa": "Corresponde al requisito 'Perfil de egreso' del Artículo 8, fracción VI del Acuerdo 17/11/17."
},
"programa_de_investigacion": {
"type": ["string", "null"],
"title": "Programa de investigación",
"examples": [
"Línea 1: Innovación Educativa. Objetivo: Analizar el impacto de..."
],
"description": "(Obligatorio para el nivel de Doctorado y para aquéllas Maestrías orientadas a la investigación). Presentar el programa de investigación que describa los objetivos del programa y líneas de investigación con sus respectivos objetivos. Asimismo, detallar la vinculación con las asignaturas que conforman el plan de estudios, la metodología y presentación de las investigaciones, así como el perfil de los docentes que guiarán su desarrollo.",
"referencia_normativa": "Requisito obligatorio solo para Doctorados o Maestrías con orientación a investigación según el Artículo 10, fracción III incisos b) y c) del Acuerdo 17/11/17."
},
"curso_propedeutico": {
"type": ["string", "null"],
"title": "Curso propedéutico",
"examples": [
"Curso de nivelación de 40 horas obligatorio para aspirantes de áreas ajenas."
],
"description": "Señalar las situaciones en las que aplicará el curso propedéutico, la población a la que estará dirigido, su duración, la relación con el plan de estudios, así como la información que considere relevante.",
"referencia_normativa": "Elemento opcional mencionado en las guías de llenado del Anexo 1."
},
"perfil_de_ingreso": {
"type": "string",
"title": "Perfil de ingreso",
"examples": [
"Conocimientos básicos de matemáticas, capacidad de análisis y redacción."
],
"description": "Indicar las condiciones académicas ideales (conocimientos, habilidades y aptitudes), para incorporarse al plan de estudios. Deberá vincularse con el nivel educativo y campo de estudio.",
"referencia_normativa": "Corresponde al requisito 'Perfil de ingreso' (condiciones mínimas requeridas) del Artículo 8, fracción V del Acuerdo 17/11/17."
},
"administracion_y_operatividad_del_plan_de_estudios": {
"type": ["string", "null"],
"title": "Administración y operatividad del plan de estudios",
"examples": [
"El estudiante podrá inscribir un mínimo de 3 y un máximo de 6 asignaturas por ciclo..."
],
"description": "Describir la administración del plan de estudios, así como su operatividad; considerando el tipo de diseño curricular empleado. Si es flexible; incorporar el número mínimo y máximo de asignaturas que se cursarán por ciclo.",
"referencia_normativa": "Corresponde a la 'descripción detallada de la(s) forma(s) de administración y operatividad' permitida en el Artículo 8 (párrafo tercero) del Acuerdo 17/11/17."
},
"sustento_teorico_del_modelo_curricular": {
"type": ["string", "null"],
"title": "Sustento teórico del modelo curricular",
"examples": [
"Modelo por competencias profesionales fundamentado en el constructivismo..."
],
"description": "Presentar de forma concreta el marco conceptual en el que se justifica y fundamenta el diseño y organización curricular del plan de estudios.",
"referencia_normativa": "Corresponde al 'sustento teórico del modelo curricular' que el particular puede incluir según el Artículo 8 (párrafo tercero) del Acuerdo 17/11/17."
},
"justificacion_de_la_propuesta_curricular": {
"type": ["string", "null"],
"title": "Justificación de la propuesta curricular en la modalidad no escolarizada o mixta",
"examples": [
"Se utilizará una plataforma LMS asincrónica complementada con sesiones síncronas..."
],
"description": "(Exclusivo para modalidades no escolarizada y mixta). Describir cómo se impartirá el plan de estudios en la modalidad educativa elegida, los recursos tecnológicos y/o didácticos que emplearán para su desarrollo y su vinculación con las asignaturas y actividades de aprendizaje.",
"referencia_normativa": "Corresponde a lo solicitado en el Artículo 14 (justificación de modalidad) y Artículo 15 (especificaciones de plataforma/modelo) del Acuerdo 17/11/17."
},
"propuesta_de_evaluacion_periodica_del_plan_de_estudios": {
"type": ["string", "null"],
"title": "Propuesta de evaluación periódica del plan de estudios",
"examples": [
"Revisión quinquenal mediante encuestas a empleadores, egresados y análisis de tendencias del mercado."
],
"description": "Describir de manera detallada la metodología que emplearán para la evaluación del plan de estudios, incluyendo los instrumentos y periodicidad en la que se llevará a cabo; con la finalidad de mantenerlo actualizado y acorde a las necesidades educativas y disciplinares.",
"referencia_normativa": "Corresponde al requisito 'Propuesta de evaluación periódica del Plan de estudio' del Artículo 8, fracción VIII del Acuerdo 17/11/17."
},
"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios": {
"type": "string",
"title": "Nombre y cargo de la persona facultada para autorizar el plan de estudios",
"examples": ["Dr. Juan Pérez Gómez, Rector de la Universidad XYZ"],
"description": "Indicar el nombre y cargo de la persona facultada para autorizar el plan de estudios.",
"referencia_normativa": "Dato administrativo del Anexo 1."
}
},
"additionalProperties": false
}
+190 -201
View File
@@ -3,223 +3,212 @@
import OpenAI from "npm:openai@6.16.0";
import type * as OpenAITypes from "npm:openai@6.16.0";
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<TOutput = unknown> = {
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<TOutput = unknown> =
| StructuredResponseSuccess<TOutput>
| StructuredResponseFailure;
| StructuredResponseSuccess<TOutput>
| 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 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 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;
}
const client = createClient(supabaseUrl, serviceRoleKey);
const openai = new OpenAI({ apiKey: openAIApiKey });
return new OpenAIService(client, openai, bucket);
}
async createConversation(metadata?: Record<string, string>) {
const conversation = await this.openai.conversations.create({
metadata,
});
return conversation;
}
async createStructuredResponse<TOutput = unknown>(
options: StructuredResponseOptions,
files?: File[],
): Promise<StructuredResponseResult<TOutput>> {
try {
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;
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",
};
}
).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;
const client = createClient(supabaseUrl, serviceRoleKey);
const openai = new OpenAI({ apiKey: openAIApiKey });
return new OpenAIService(client, openai, bucket);
}
async createConversation(metadata?: Record<string, string>) {
const conversation = await this.openai.conversations.create({
metadata,
});
return conversation;
}
async createStructuredResponse<TOutput = unknown>(
options: StructuredResponseOptions,
files?: File[],
): Promise<StructuredResponseResult<TOutput>> {
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<string[]> {
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<string[]> {
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<string[]> {
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<string[]> {
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, "_");
}
}
@@ -1,9 +1,6 @@
// 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 = {
type: "INSERT" | "UPDATE" | "DELETE";
table: string;
@@ -11,79 +8,73 @@ type WebhookPayload = {
record: Record<string, unknown> | null;
old_record: Record<string, unknown> | null;
};
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SERVICE_ROLE_KEY =
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
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"]);
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
function jsonResponse(status: number, body: Record<string, unknown>) {
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<string, string>,
) {
const conversation = await openaiService.createConversation(metadata);
const conversationId = conversation?.id as string | undefined;
async function createConversationId(metadata: Record<string, string>) {
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");
}
return conversationId;
}
Deno.serve(async (req) => {
if (req.method !== "POST") {
return jsonResponse(405, { error: "Method not allowed" });
}
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
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;
try {
payload = await req.json();
} catch {
return jsonResponse(400, { error: "Invalid JSON body" });
}
const { type, table, schema, record } = payload;
if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) {
return jsonResponse(400, { error: "Table not allowed" });
}
if (type !== "INSERT") {
return jsonResponse(200, { status: "ignored", reason: "event_type" });
}
if (!record || typeof record !== "object") {
return jsonResponse(400, { error: "Missing record" });
}
const recordId = record["id"] as string | number | undefined;
if (!recordId) {
return jsonResponse(400, { error: "Record id missing" });
}
const existingConversationId = record["conversation_id"] as
| string
| null
@@ -94,26 +85,22 @@ Deno.serve(async (req) => {
conversation_id: existingConversationId,
});
}
let conversationId: string;
try {
conversationId = await createConversationId(openaiService, {
conversationId = await createConversationId({
table,
record_id: String(recordId),
});
} catch (error) {
return jsonResponse(502, { error: (error as Error).message });
}
const { error } = await supabase
.from(table)
.update({ conversation_id: conversationId })
.eq("id", recordId);
if (error) {
return jsonResponse(500, { error: error.message });
}
return jsonResponse(200, {
status: "updated",
table,