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
}
+17 -28
View File
@@ -3,11 +3,9 @@
import OpenAI from "npm:openai@6.16.0"; import OpenAI from "npm:openai@6.16.0";
import type * as OpenAITypes 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"; 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
@@ -22,7 +20,6 @@ export type StructuredResponseSuccess<TOutput = unknown> = {
}; };
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:
@@ -33,35 +30,33 @@ export type StructuredResponseFailure = {
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(
private constructor(client: SupabaseClient, openai: OpenAI, bucket: string) { client: SupabaseClient,
openai: OpenAI,
bucket: string,
) {
this.supabase = client; this.supabase = client;
this.openai = openai; this.openai = openai;
this.bucket = bucket; this.bucket = bucket;
} }
static fromEnv(): StructuredResponseFailure | OpenAIService { static fromEnv(): StructuredResponseFailure | OpenAIService {
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? ""; const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage"; const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) { if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
return { return {
ok: false, ok: false,
@@ -70,37 +65,35 @@ export class OpenAIService {
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY", "Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
}; };
} }
const client = createClient(supabaseUrl, serviceRoleKey); const client = createClient(supabaseUrl, serviceRoleKey);
const openai = new OpenAI({ apiKey: openAIApiKey }); const openai = new OpenAI({ apiKey: openAIApiKey });
return new OpenAIService(client, openai, bucket); return new OpenAIService(client, openai, bucket);
} }
async createConversation(metadata?: Record<string, string>) { async createConversation(metadata?: Record<string, string>) {
const conversation = await this.openai.conversations.create({ const conversation = await this.openai.conversations.create({
metadata, metadata,
}); });
return conversation; return conversation;
} }
async createStructuredResponse<TOutput = unknown>( async createStructuredResponse<TOutput = unknown>(
options: StructuredResponseOptions, options: StructuredResponseOptions,
files?: File[], files?: File[],
): Promise<StructuredResponseResult<TOutput>> { ): Promise<StructuredResponseResult<TOutput>> {
try { try {
const uploadedToStorage = await this.uploadFilesToStorage(files ?? []); const uploadedToStorage = await this.uploadFilesToStorage(
files ?? [],
);
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []); const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
const newOptions = { ...options }; const newOptions = { ...options };
// Attach file references to the request as an extra user message // Attach file references to the request as an extra user message
if (openaiFileIds.length > 0) { if (openaiFileIds.length > 0) {
const fileParts: OpenAITypes.OpenAI.Responses.ResponseInputFile[] = const fileParts:
OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
openaiFileIds.map((id) => ({ openaiFileIds.map((id) => ({
type: "input_file", type: "input_file",
file_id: id, file_id: id,
})); }));
const arr = Array.isArray(options.input) ? options.input : []; const arr = Array.isArray(options.input) ? options.input : [];
arr.push({ arr.push({
role: "user", role: "user",
@@ -119,7 +112,6 @@ export class OpenAIService {
const openaiRaw = (await this.openai.responses.create( const openaiRaw = (await this.openai.responses.create(
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming, newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
)) as OpenAITypes.OpenAI.Responses.Response; )) as OpenAITypes.OpenAI.Responses.Response;
const { model, id: responseId } = openaiRaw; const { model, id: responseId } = openaiRaw;
const usage = openaiRaw?.usage ?? null; const usage = openaiRaw?.usage ?? null;
const conversationId = ( const conversationId = (
@@ -127,14 +119,15 @@ export class OpenAIService {
conversation_id?: string | null; conversation_id?: string | null;
} }
).conversation_id ?? null; ).conversation_id ?? null;
// Try to read structured JSON output // Try to read structured JSON output
let output: TOutput | undefined = undefined; let output: TOutput | undefined = undefined;
let outputText: string | undefined = undefined; let outputText: string | undefined = undefined;
// Prefer `output_text` if present (SDK convenience) // Prefer `output_text` if present (SDK convenience)
const maybeOutputText = openaiRaw.output_text; const maybeOutputText = openaiRaw.output_text;
if (typeof maybeOutputText === "string" && maybeOutputText.length > 0) { if (
typeof maybeOutputText === "string" &&
maybeOutputText.length > 0
) {
outputText = maybeOutputText; outputText = maybeOutputText;
try { try {
output = JSON.parse(maybeOutputText) as TOutput; output = JSON.parse(maybeOutputText) as TOutput;
@@ -153,7 +146,6 @@ export class OpenAIService {
} }
} }
} }
return { return {
ok: true, ok: true,
output, output,
@@ -173,11 +165,9 @@ export class OpenAIService {
: message.includes("OpenAI file upload failed") : message.includes("OpenAI file upload failed")
? "OpenAIFileUploadFailed" ? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed"; : "OpenAIRequestFailed";
return { ok: false, code, message, cause: err }; return { ok: false, code, message, cause: err };
} }
} }
private async uploadFilesToStorage(files: File[]): Promise<string[]> { private async uploadFilesToStorage(files: File[]): Promise<string[]> {
const paths: string[] = []; const paths: string[] = [];
for (const file of files) { for (const file of files) {
@@ -189,7 +179,6 @@ export class OpenAIService {
contentType: file.type || "application/octet-stream", contentType: file.type || "application/octet-stream",
upsert: false, upsert: false,
}); });
if (error) { if (error) {
throw new Error(`Supabase upload failed: ${error.message}`); throw new Error(`Supabase upload failed: ${error.message}`);
} }
@@ -197,7 +186,6 @@ export class OpenAIService {
} }
return paths; return paths;
} }
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> { private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
const ids: string[] = []; const ids: string[] = [];
for (const file of files) { for (const file of files) {
@@ -209,13 +197,14 @@ export class OpenAIService {
ids.push(created.id); ids.push(created.id);
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`OpenAI file upload failed: ${(e as Error)?.message ?? String(e)}`, `OpenAI file upload failed: ${
(e as Error)?.message ?? String(e)
}`,
); );
} }
} }
return ids; return ids;
} }
private sanitizeFilename(name: string): string { private sanitizeFilename(name: string): string {
return name return name
.normalize("NFD") .normalize("NFD")
@@ -1,9 +1,6 @@
// 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 = {
type: "INSERT" | "UPDATE" | "DELETE"; type: "INSERT" | "UPDATE" | "DELETE";
table: string; table: string;
@@ -11,79 +8,73 @@ type WebhookPayload = {
record: Record<string, unknown> | null; record: Record<string, unknown> | null;
old_record: Record<string, unknown> | null; old_record: Record<string, unknown> | null;
}; };
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"]);
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false }, auth: { persistSession: false },
}); });
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(metadata: Record<string, string>) {
async function createConversationId( const response = await fetch(`${OPENAI_BASE_URL}/conversations`, {
openaiService: OpenAIService, method: "POST",
metadata: Record<string, string>, headers: {
) { "Content-Type": "application/json",
const conversation = await openaiService.createConversation(metadata); Authorization: `Bearer ${OPENAI_API_KEY}`,
const conversationId = conversation?.id as string | undefined; },
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");
} }
return conversationId; return conversationId;
} }
Deno.serve(async (req) => { Deno.serve(async (req) => {
if (req.method !== "POST") { if (req.method !== "POST") {
return jsonResponse(405, { error: "Method not allowed" }); return jsonResponse(405, { error: "Method not allowed" });
} }
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
return jsonResponse(500, { error: "Supabase env vars missing" }); return jsonResponse(500, { error: "Supabase env vars missing" });
} }
if (!OPENAI_API_KEY) {
const openaiService = OpenAIService.fromEnv(); return jsonResponse(500, { error: "OPENAI_API_KEY missing" });
if (!(openaiService instanceof OpenAIService)) {
return jsonResponse(500, { error: openaiService.message });
} }
let payload: WebhookPayload; let payload: WebhookPayload;
try { try {
payload = await req.json(); payload = await req.json();
} catch { } catch {
return jsonResponse(400, { error: "Invalid JSON body" }); return jsonResponse(400, { error: "Invalid JSON body" });
} }
const { type, table, schema, record } = payload; const { type, table, schema, record } = payload;
if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) { if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) {
return jsonResponse(400, { error: "Table not allowed" }); return jsonResponse(400, { error: "Table not allowed" });
} }
if (type !== "INSERT") { if (type !== "INSERT") {
return jsonResponse(200, { status: "ignored", reason: "event_type" }); return jsonResponse(200, { status: "ignored", reason: "event_type" });
} }
if (!record || typeof record !== "object") { if (!record || typeof record !== "object") {
return jsonResponse(400, { error: "Missing record" }); return jsonResponse(400, { error: "Missing record" });
} }
const recordId = record["id"] as string | number | undefined; const recordId = record["id"] as string | number | undefined;
if (!recordId) { if (!recordId) {
return jsonResponse(400, { error: "Record id missing" }); return jsonResponse(400, { error: "Record id missing" });
} }
const existingConversationId = record["conversation_id"] as const existingConversationId = record["conversation_id"] as
| string | string
| null | null
@@ -94,26 +85,22 @@ Deno.serve(async (req) => {
conversation_id: existingConversationId, conversation_id: existingConversationId,
}); });
} }
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),
}); });
} catch (error) { } catch (error) {
return jsonResponse(502, { error: (error as Error).message }); return jsonResponse(502, { error: (error as Error).message });
} }
const { error } = await supabase const { error } = await supabase
.from(table) .from(table)
.update({ conversation_id: conversationId }) .update({ conversation_id: conversationId })
.eq("id", recordId); .eq("id", recordId);
if (error) { if (error) {
return jsonResponse(500, { error: error.message }); return jsonResponse(500, { error: error.message });
} }
return jsonResponse(200, { return jsonResponse(200, {
status: "updated", status: "updated",
table, table,