Creación de planes y asignaturas en segundo plano y con webhook de openai #40
@@ -5,6 +5,7 @@
|
|||||||
"jsr:@david/dax@~0.44.2": "0.44.2",
|
"jsr:@david/dax@~0.44.2": "0.44.2",
|
||||||
"jsr:@david/path@0.2": "0.2.0",
|
"jsr:@david/path@0.2": "0.2.0",
|
||||||
"jsr:@david/which@~0.4.1": "0.4.1",
|
"jsr:@david/which@~0.4.1": "0.4.1",
|
||||||
|
"jsr:@helpr/express@*": "0.1.1",
|
||||||
"jsr:@openai/openai@*": "6.16.0",
|
"jsr:@openai/openai@*": "6.16.0",
|
||||||
"jsr:@openai/openai@^6.16.0": "6.16.0",
|
"jsr:@openai/openai@^6.16.0": "6.16.0",
|
||||||
"jsr:@std/bytes@^1.0.6": "1.0.6",
|
"jsr:@std/bytes@^1.0.6": "1.0.6",
|
||||||
@@ -55,6 +56,9 @@
|
|||||||
"@david/which@0.4.1": {
|
"@david/which@0.4.1": {
|
||||||
"integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e"
|
"integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e"
|
||||||
},
|
},
|
||||||
|
"@helpr/express@0.1.1": {
|
||||||
|
"integrity": "15391b350b92ab5102919c555179c954ff3d5974d7f170b99880836e249756d2"
|
||||||
|
},
|
||||||
"@openai/openai@6.16.0": {
|
"@openai/openai@6.16.0": {
|
||||||
"integrity": "ccee548f61c382d715091fff0c2c3390a4487823644430b8235df543d6d6a78b",
|
"integrity": "ccee548f61c382d715091fff0c2c3390a4487823644430b8235df543d6d6a78b",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|||||||
+563
-401
@@ -1,415 +1,577 @@
|
|||||||
{
|
{
|
||||||
"cells": [
|
"cells": [
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 1,
|
|
||||||
"id": "bb61ec88",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import { load } from \"jsr:@std/dotenv\";\n",
|
|
||||||
"import OpenAI from \"jsr:@openai/openai\";\n",
|
|
||||||
"\n",
|
|
||||||
"const env = await load({\n",
|
|
||||||
" export: true,\n",
|
|
||||||
"});\n",
|
|
||||||
"\n",
|
|
||||||
"const openai = new OpenAI();"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 2,
|
|
||||||
"id": "8baca5f2",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import { createClient } from \"@supabase/supabase-js\";\n",
|
|
||||||
"const supabaseUrl = Deno.env.get(\"SUPABASE_URL\") || env.SUPABASE_URL;\n",
|
|
||||||
"const supabaseKey = Deno.env.get(\"SUPABASE_ANON_KEY\") || env.SUPABASE_ANON_KEY;\n",
|
|
||||||
"const supabase = createClient(supabaseUrl, supabaseKey);"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 7,
|
|
||||||
"id": "26b3bb36",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"// plan Crear un plan en la base (sin datos) estado generando\n",
|
|
||||||
"const { data: plan, error } = await supabase.from(\"planes_estudio\").insert({\n",
|
|
||||||
" \"carrera_id\": \"8208da08-d549-4359-8865-9d806bc54f19\",\n",
|
|
||||||
" \"estructura_id\": \"69fb2b77-5a95-47e0-bf1f-389d384200e4\",\n",
|
|
||||||
" \"estado_actual_id\": \"18f49b67-8077-4371-be6e-2019a3be3562\",\n",
|
|
||||||
" \"nombre\": \"Ingeniearía en Cifbernética y Ciberseguridad (2025)\",\n",
|
|
||||||
" \"nivel\": \"Doctorado\",\n",
|
|
||||||
" \"tipo_ciclo\": \"Semestre\",\n",
|
|
||||||
" \"numero_ciclos\": 8,\n",
|
|
||||||
" \"activo\": true,\n",
|
|
||||||
" \"tipo_origen\": \"IA\",\n",
|
|
||||||
" \"creado_por\": \"11111111-1111-1111-1111-111111111111\",\n",
|
|
||||||
" \"actualizado_por\": \"11111111-1111-1111-1111-111111111111\",\n",
|
|
||||||
"}).select(\"*\").single();\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 9,
|
|
||||||
"id": "8abb080e",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
{
|
||||||
"data": {
|
"cell_type": "code",
|
||||||
"text/plain": [
|
"execution_count": 1,
|
||||||
"{\n",
|
"id": "bb61ec88",
|
||||||
" message: \u001b[32m\"TypeError: error sending request for url (http://127.0.0.1:54321/rest/v1/planes_estudio?select=*): client error (Connect): tcp connect error: No se puede establecer una conexión ya que el equipo de destino denegó expresamente dicha conexión. (os error 10061)\"\u001b[39m,\n",
|
"metadata": {},
|
||||||
" details: \u001b[32m\"TypeError: error sending request for url (http://127.0.0.1:54321/rest/v1/planes_estudio?select=*): client error (Connect): tcp connect error: No se puede establecer una conexión ya que el equipo de destino denegó expresamente dicha conexión. (os error 10061)\\n\"\u001b[39m +\n",
|
"outputs": [],
|
||||||
" \u001b[32m\" at async mainFetch (ext:deno_fetch/26_fetch.js:192:12)\\n\"\u001b[39m +\n",
|
"source": [
|
||||||
" \u001b[32m\" at async fetch (ext:deno_fetch/26_fetch.js:475:11)\\n\"\u001b[39m +\n",
|
"import { load } from \"jsr:@std/dotenv\";\n",
|
||||||
" \u001b[32m\" at async <anonymous>:2:31\"\u001b[39m,\n",
|
"import OpenAI from \"jsr:@openai/openai\";\n",
|
||||||
" hint: \u001b[32m\"\"\u001b[39m,\n",
|
"\n",
|
||||||
" code: \u001b[32m\"\"\u001b[39m\n",
|
"const env = await load({\n",
|
||||||
"}"
|
" export: true,\n",
|
||||||
|
"});\n",
|
||||||
|
"\n",
|
||||||
|
"const openai = new OpenAI();\n"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"execution_count": 9,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"error"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 12,
|
|
||||||
"id": "a382bdc6",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"{\n",
|
|
||||||
" id: \u001b[32m\"resp_0ae72cbd0894279200699c8e42941c8192ba4a09e280b21a17\"\u001b[39m,\n",
|
|
||||||
" object: \u001b[32m\"response\"\u001b[39m,\n",
|
|
||||||
" created_at: \u001b[33m1771867714\u001b[39m,\n",
|
|
||||||
" status: \u001b[32m\"queued\"\u001b[39m,\n",
|
|
||||||
" background: \u001b[33mtrue\u001b[39m,\n",
|
|
||||||
" completed_at: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" error: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" frequency_penalty: \u001b[33m0\u001b[39m,\n",
|
|
||||||
" incomplete_details: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" instructions: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" max_output_tokens: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" max_tool_calls: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" model: \u001b[32m\"gpt-5-mini-2025-08-07\"\u001b[39m,\n",
|
|
||||||
" output: [],\n",
|
|
||||||
" parallel_tool_calls: \u001b[33mtrue\u001b[39m,\n",
|
|
||||||
" presence_penalty: \u001b[33m0\u001b[39m,\n",
|
|
||||||
" previous_response_id: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" prompt_cache_key: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" prompt_cache_retention: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" reasoning: { effort: \u001b[32m\"medium\"\u001b[39m, summary: \u001b[1mnull\u001b[22m },\n",
|
|
||||||
" safety_identifier: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" service_tier: \u001b[32m\"auto\"\u001b[39m,\n",
|
|
||||||
" store: \u001b[33mtrue\u001b[39m,\n",
|
|
||||||
" temperature: \u001b[33m1\u001b[39m,\n",
|
|
||||||
" text: {\n",
|
|
||||||
" format: {\n",
|
|
||||||
" type: \u001b[32m\"json_schema\"\u001b[39m,\n",
|
|
||||||
" description: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" name: \u001b[32m\"response_schema\"\u001b[39m,\n",
|
|
||||||
" schema: {\n",
|
|
||||||
" type: \u001b[32m\"object\"\u001b[39m,\n",
|
|
||||||
" properties: { \u001b[32m\"ai-message\"\u001b[39m: \u001b[36m[Object]\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[36m[Object]\u001b[39m },\n",
|
|
||||||
" required: [ \u001b[32m\"ai-message\"\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m ],\n",
|
|
||||||
" additionalProperties: \u001b[33mfalse\u001b[39m\n",
|
|
||||||
" },\n",
|
|
||||||
" strict: \u001b[33mtrue\u001b[39m\n",
|
|
||||||
" },\n",
|
|
||||||
" verbosity: \u001b[32m\"medium\"\u001b[39m\n",
|
|
||||||
" },\n",
|
|
||||||
" tool_choice: \u001b[32m\"auto\"\u001b[39m,\n",
|
|
||||||
" tools: [],\n",
|
|
||||||
" top_logprobs: \u001b[33m0\u001b[39m,\n",
|
|
||||||
" top_p: \u001b[33m1\u001b[39m,\n",
|
|
||||||
" truncation: \u001b[32m\"disabled\"\u001b[39m,\n",
|
|
||||||
" usage: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" user: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" metadata: { tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"2\"\u001b[39m },\n",
|
|
||||||
" output_text: \u001b[32m\"\"\u001b[39m\n",
|
|
||||||
"}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 12,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"\n",
|
|
||||||
"let response = await openai.responses.create({\n",
|
|
||||||
" model: \"gpt-5-mini\",\n",
|
|
||||||
" input: \"Hola mundo\",\n",
|
|
||||||
" metadata: {\n",
|
|
||||||
" tabla: \"planes_estudio\",\n",
|
|
||||||
" id: '2',\n",
|
|
||||||
" },\n",
|
|
||||||
" text: {\n",
|
|
||||||
" format: {\n",
|
|
||||||
" type: \"json_schema\",\n",
|
|
||||||
" name: \"response_schema\",\n",
|
|
||||||
" schema: {\n",
|
|
||||||
" type: \"object\",\n",
|
|
||||||
" properties: {\n",
|
|
||||||
" \"ai-message\": {\n",
|
|
||||||
" type: \"string\",\n",
|
|
||||||
" description:\n",
|
|
||||||
" \"Un mensaje de texto que la IA generará para el usuario final. Este mensaje debe ser claro y directo, proporcionando la información solicitada por el usuario o indicando que no se puede procesar la solicitud si no está relacionada con planes de estudio o asignaturas.\",\n",
|
|
||||||
" },\n",
|
|
||||||
" \"suggested-topic\": {\n",
|
|
||||||
" type: \"string\",\n",
|
|
||||||
" description:\n",
|
|
||||||
" \"Un tema de investigación sugerido por la IA que sea relevante para el plan de estudio o asignatura mencionada en la solicitud del usuario. Este tema debe ser específico y relacionado con el área de estudio correspondiente.\",\n",
|
|
||||||
" },\n",
|
|
||||||
" },\n",
|
|
||||||
" required: [\"ai-message\", \"suggested-topic\"],\n",
|
|
||||||
" additionalProperties: false,\n",
|
|
||||||
" },\n",
|
|
||||||
" },\n",
|
|
||||||
" },\n",
|
|
||||||
" background: true,\n",
|
|
||||||
"});\n",
|
|
||||||
"\n",
|
|
||||||
"response;\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 13,
|
|
||||||
"id": "6b2470ed",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"Current status: queued\n",
|
|
||||||
"Current status: in_progress\n",
|
|
||||||
"Current status: in_progress\n"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"data": {
|
"cell_type": "code",
|
||||||
"text/plain": [
|
"execution_count": 2,
|
||||||
"{\n",
|
"id": "8baca5f2",
|
||||||
" id: \u001b[32m\"resp_0ae72cbd0894279200699c8e42941c8192ba4a09e280b21a17\"\u001b[39m,\n",
|
"metadata": {},
|
||||||
" object: \u001b[32m\"response\"\u001b[39m,\n",
|
"outputs": [],
|
||||||
" created_at: \u001b[33m1771867714\u001b[39m,\n",
|
"source": [
|
||||||
" status: \u001b[32m\"completed\"\u001b[39m,\n",
|
"import { createClient } from \"@supabase/supabase-js\";\n",
|
||||||
" background: \u001b[33mtrue\u001b[39m,\n",
|
"const supabaseUrl = Deno.env.get(\"SUPABASE_URL\") || env.SUPABASE_URL;\n",
|
||||||
" billing: { payer: \u001b[32m\"developer\"\u001b[39m },\n",
|
"const supabaseKey = Deno.env.get(\"SUPABASE_ANON_KEY\") || env.SUPABASE_ANON_KEY;\n",
|
||||||
" completed_at: \u001b[33m1771867721\u001b[39m,\n",
|
"const supabase = createClient(supabaseUrl, supabaseKey);\n"
|
||||||
" error: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" frequency_penalty: \u001b[33m0\u001b[39m,\n",
|
|
||||||
" incomplete_details: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" instructions: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" max_output_tokens: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" max_tool_calls: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" model: \u001b[32m\"gpt-5-mini-2025-08-07\"\u001b[39m,\n",
|
|
||||||
" output: [\n",
|
|
||||||
" {\n",
|
|
||||||
" id: \u001b[32m\"rs_0ae72cbd0894279200699c8e43cd4c8192bcbc70792b6aa621\"\u001b[39m,\n",
|
|
||||||
" type: \u001b[32m\"reasoning\"\u001b[39m,\n",
|
|
||||||
" summary: []\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" id: \u001b[32m\"msg_0ae72cbd0894279200699c8e48976481928e1f205210636717\"\u001b[39m,\n",
|
|
||||||
" type: \u001b[32m\"message\"\u001b[39m,\n",
|
|
||||||
" status: \u001b[32m\"completed\"\u001b[39m,\n",
|
|
||||||
" content: [\n",
|
|
||||||
" {\n",
|
|
||||||
" type: \u001b[32m\"output_text\"\u001b[39m,\n",
|
|
||||||
" annotations: [],\n",
|
|
||||||
" logprobs: [],\n",
|
|
||||||
" text: \u001b[32m'{\"ai-message\":\"¡Hola! ¿En qué puedo ayudarte hoy? Puedo mostrar ejemplos de \\\\\"Hola mundo\\\\\" en distintos lenguajes, explicar conceptos de programación, ayudar con una tarea o sugerir recursos. Dime qué prefieres.\",\"suggested-topic\":\"El papel del programa \\\\\"Hola Mundo\\\\\" en la enseñanza de la programación: comparación práctica entre Python, Java y C (sintaxis, tiempo de arranque, facilidad de aprendizaje y percepción del estudiante).\"}'\u001b[39m\n",
|
|
||||||
" }\n",
|
|
||||||
" ],\n",
|
|
||||||
" role: \u001b[32m\"assistant\"\u001b[39m\n",
|
|
||||||
" }\n",
|
|
||||||
" ],\n",
|
|
||||||
" parallel_tool_calls: \u001b[33mtrue\u001b[39m,\n",
|
|
||||||
" presence_penalty: \u001b[33m0\u001b[39m,\n",
|
|
||||||
" previous_response_id: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" prompt_cache_key: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" prompt_cache_retention: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" reasoning: { effort: \u001b[32m\"medium\"\u001b[39m, summary: \u001b[1mnull\u001b[22m },\n",
|
|
||||||
" safety_identifier: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" service_tier: \u001b[32m\"default\"\u001b[39m,\n",
|
|
||||||
" store: \u001b[33mtrue\u001b[39m,\n",
|
|
||||||
" temperature: \u001b[33m1\u001b[39m,\n",
|
|
||||||
" text: {\n",
|
|
||||||
" format: {\n",
|
|
||||||
" type: \u001b[32m\"json_schema\"\u001b[39m,\n",
|
|
||||||
" description: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" name: \u001b[32m\"response_schema\"\u001b[39m,\n",
|
|
||||||
" schema: {\n",
|
|
||||||
" type: \u001b[32m\"object\"\u001b[39m,\n",
|
|
||||||
" properties: { \u001b[32m\"ai-message\"\u001b[39m: \u001b[36m[Object]\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[36m[Object]\u001b[39m },\n",
|
|
||||||
" required: [ \u001b[32m\"ai-message\"\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m ],\n",
|
|
||||||
" additionalProperties: \u001b[33mfalse\u001b[39m\n",
|
|
||||||
" },\n",
|
|
||||||
" strict: \u001b[33mtrue\u001b[39m\n",
|
|
||||||
" },\n",
|
|
||||||
" verbosity: \u001b[32m\"medium\"\u001b[39m\n",
|
|
||||||
" },\n",
|
|
||||||
" tool_choice: \u001b[32m\"auto\"\u001b[39m,\n",
|
|
||||||
" tools: [],\n",
|
|
||||||
" top_logprobs: \u001b[33m0\u001b[39m,\n",
|
|
||||||
" top_p: \u001b[33m1\u001b[39m,\n",
|
|
||||||
" truncation: \u001b[32m\"disabled\"\u001b[39m,\n",
|
|
||||||
" usage: {\n",
|
|
||||||
" input_tokens: \u001b[33m167\u001b[39m,\n",
|
|
||||||
" input_tokens_details: { cached_tokens: \u001b[33m0\u001b[39m },\n",
|
|
||||||
" output_tokens: \u001b[33m482\u001b[39m,\n",
|
|
||||||
" output_tokens_details: { reasoning_tokens: \u001b[33m320\u001b[39m },\n",
|
|
||||||
" total_tokens: \u001b[33m649\u001b[39m\n",
|
|
||||||
" },\n",
|
|
||||||
" user: \u001b[1mnull\u001b[22m,\n",
|
|
||||||
" metadata: { tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"2\"\u001b[39m },\n",
|
|
||||||
" output_text: \u001b[32m'{\"ai-message\":\"¡Hola! ¿En qué puedo ayudarte hoy? Puedo mostrar ejemplos de \\\\\"Hola mundo\\\\\" en distintos lenguajes, explicar conceptos de programación, ayudar con una tarea o sugerir recursos. Dime qué prefieres.\",\"suggested-topic\":\"El papel del programa \\\\\"Hola Mundo\\\\\" en la enseñanza de la programación: comparación práctica entre Python, Java y C (sintaxis, tiempo de arranque, facilidad de aprendizaje y percepción del estudiante).\"}'\u001b[39m\n",
|
|
||||||
"}"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"execution_count": 13,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"while (response.status === \"queued\" || response.status === \"in_progress\") {\n",
|
|
||||||
" console.log(\"Current status: \" + response.status);\n",
|
|
||||||
" await new Promise((resolve) => setTimeout(resolve, 2000)); // wait 2 seconds\n",
|
|
||||||
" response = await openai.responses.retrieve(response.id);\n",
|
|
||||||
"}\n",
|
|
||||||
"\n",
|
|
||||||
"response;"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 24,
|
|
||||||
"id": "ebb67bd8",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
{
|
||||||
"data": {
|
"cell_type": "code",
|
||||||
"text/plain": [
|
"execution_count": 4,
|
||||||
"{ tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"e39952c1-a53b-43bf-8033-feda493a0651\"\u001b[39m }"
|
"id": "923b8e08",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Esperando a que la IA termine con la asignatura fe5eb09c-501b-4db6-b6d3-f02bdcfdc4b4...\n",
|
||||||
|
"Conexión WebSocket establecida. La celda está esperando cambios...\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ename": "",
|
||||||
|
"evalue": "",
|
||||||
|
"output_type": "error",
|
||||||
|
"traceback": [
|
||||||
|
"\u001b[1;31mThe Kernel crashed while executing code in the current cell or a previous cell. \n",
|
||||||
|
"\u001b[1;31mPlease review the code in the cell(s) to identify a possible cause of the failure. \n",
|
||||||
|
"\u001b[1;31mClick <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. \n",
|
||||||
|
"\u001b[1;31mView Jupyter <a href='command:jupyter.viewOutput'>log</a> for further details."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"const ID_ASIGNATURA = \"fe5eb09c-501b-4db6-b6d3-f02bdcfdc4b4\";\n",
|
||||||
|
"console.log(\n",
|
||||||
|
" `Esperando a que la IA termine con la asignatura ${ID_ASIGNATURA}...`,\n",
|
||||||
|
");\n",
|
||||||
|
"\n",
|
||||||
|
"if (!supabase) {\n",
|
||||||
|
" console.log(\"No hay supabase\");\n",
|
||||||
|
"} else {\n",
|
||||||
|
" // 2. Armas la \"trampa\" (el listener)\n",
|
||||||
|
" await new Promise((resolve) => {\n",
|
||||||
|
" const canal = supabase\n",
|
||||||
|
" .channel(\"test-asignatura\")\n",
|
||||||
|
" .on(\n",
|
||||||
|
" \"postgres_changes\",\n",
|
||||||
|
" {\n",
|
||||||
|
" event: \"UPDATE\",\n",
|
||||||
|
" schema: \"public\",\n",
|
||||||
|
" table: \"asignaturas\",\n",
|
||||||
|
" filter: `id=eq.${ID_ASIGNATURA}`,\n",
|
||||||
|
" },\n",
|
||||||
|
" (payload) => {\n",
|
||||||
|
" const nuevoEstado = payload.new.estado;\n",
|
||||||
|
"\n",
|
||||||
|
" if (nuevoEstado !== \"generando\") {\n",
|
||||||
|
" console.log(\n",
|
||||||
|
" `¡Proceso completado! El nuevo estado es: ${nuevoEstado}`,\n",
|
||||||
|
" );\n",
|
||||||
|
"\n",
|
||||||
|
" supabase.removeChannel(canal);\n",
|
||||||
|
" resolve(true); // ¡Aquí destrabamos la celda de Jupyter!\n",
|
||||||
|
" } else {\n",
|
||||||
|
" console.log(\"Hubo un update, pero sigue generando...\");\n",
|
||||||
|
" }\n",
|
||||||
|
" },\n",
|
||||||
|
" )\n",
|
||||||
|
" .subscribe((status) => {\n",
|
||||||
|
" if (status === \"SUBSCRIBED\") {\n",
|
||||||
|
" console.log(\n",
|
||||||
|
" \"Conexión WebSocket establecida. La celda está esperando cambios...\",\n",
|
||||||
|
" );\n",
|
||||||
|
" }\n",
|
||||||
|
" });\n",
|
||||||
|
" });\n",
|
||||||
|
"\n",
|
||||||
|
" console.log(\"Script finalizado correctamente.\");\n",
|
||||||
|
"}\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"execution_count": 24,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"response.metadata"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 27,
|
|
||||||
"id": "cd18fc2d",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
{
|
||||||
"data": {
|
"cell_type": "code",
|
||||||
"text/plain": [
|
"execution_count": null,
|
||||||
"{\n",
|
"id": "8a1509d8",
|
||||||
" error: \u001b[1mnull\u001b[22m,\n",
|
"metadata": {},
|
||||||
" data: {\n",
|
"outputs": [
|
||||||
" id: \u001b[32m\"e39952c1-a53b-43bf-8033-feda493a0651\"\u001b[39m,\n",
|
{
|
||||||
" carrera_id: \u001b[32m\"8208da08-d549-4359-8865-9d806bc54f19\"\u001b[39m,\n",
|
"name": "stdout",
|
||||||
" estructura_id: \u001b[32m\"69fb2b77-5a95-47e0-bf1f-389d384200e4\"\u001b[39m,\n",
|
"output_type": "stream",
|
||||||
" nombre: \u001b[32m\"Ingeniearía en Cifbernética y Ciberseguridad (2025)\"\u001b[39m,\n",
|
"text": [
|
||||||
" nivel: \u001b[32m\"Doctorado\"\u001b[39m,\n",
|
"Iniciando conexión abierta y sin filtros...\n",
|
||||||
" tipo_ciclo: \u001b[32m\"Semestre\"\u001b[39m,\n",
|
"Conectado. Esperando cualquier cambio en la tabla asignaturas...\n"
|
||||||
" numero_ciclos: \u001b[33m8\u001b[39m,\n",
|
]
|
||||||
" datos: {\n",
|
},
|
||||||
" \u001b[32m\"ai-message\"\u001b[39m: \u001b[32m\"¡Hola! ¿Cómo puedo ayudarte hoy? Si te refieres al clásico “Hola mundo” en programación, puedo mostrarte ejemplos en varios lenguajes, explicar su historia o ayudarte a crear tu primer programa. ¿Qué prefieres?\"\u001b[39m,\n",
|
{
|
||||||
" \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[32m\"El uso del programa “Hola mundo” como herramienta pedagógica en la enseñanza de la programación: impacto en la comprensión inicial, variaciones por lenguaje y mejores prácticas didácticas.\"\u001b[39m\n",
|
"ename": "",
|
||||||
" },\n",
|
"evalue": "",
|
||||||
" estado_actual_id: \u001b[32m\"18f49b67-8077-4371-be6e-2019a3be3562\"\u001b[39m,\n",
|
"output_type": "error",
|
||||||
" activo: \u001b[33mtrue\u001b[39m,\n",
|
"traceback": [
|
||||||
" tipo_origen: \u001b[32m\"IA\"\u001b[39m,\n",
|
"\u001b[1;31mThe Kernel crashed while executing code in the current cell or a previous cell. \n",
|
||||||
" meta_origen: {},\n",
|
"\u001b[1;31mPlease review the code in the cell(s) to identify a possible cause of the failure. \n",
|
||||||
" creado_por: \u001b[32m\"11111111-1111-1111-1111-111111111111\"\u001b[39m,\n",
|
"\u001b[1;31mClick <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. \n",
|
||||||
" actualizado_por: \u001b[32m\"11111111-1111-1111-1111-111111111111\"\u001b[39m,\n",
|
"\u001b[1;31mView Jupyter <a href='command:jupyter.viewOutput'>log</a> for further details."
|
||||||
" creado_en: \u001b[32m\"2026-02-20T17:11:28.995222+00:00\"\u001b[39m,\n",
|
]
|
||||||
" actualizado_en: \u001b[32m\"2026-02-20T17:12:40.170995+00:00\"\u001b[39m,\n",
|
}
|
||||||
" nombre_search: \u001b[32m\"ingeniearia en cifbernetica y ciberseguridad (2025)\"\u001b[39m,\n",
|
],
|
||||||
" plan_hash: \u001b[32m\"70d28457bbddfe4f5cde0574\"\u001b[39m\n",
|
"source": [
|
||||||
" },\n",
|
"console.log(`Iniciando conexión abierta y sin filtros...`);\n",
|
||||||
" count: \u001b[1mnull\u001b[22m,\n",
|
"\n",
|
||||||
" status: \u001b[33m200\u001b[39m,\n",
|
"await new Promise((resolve) => {\n",
|
||||||
" statusText: \u001b[32m\"OK\"\u001b[39m\n",
|
" const canal = supabase\n",
|
||||||
"}"
|
" .channel(\"test-global-asignaturas\")\n",
|
||||||
|
" .on(\n",
|
||||||
|
" \"postgres_changes\",\n",
|
||||||
|
" {\n",
|
||||||
|
" event: \"*\", // Escuchamos absolutamente TODO\n",
|
||||||
|
" schema: \"public\",\n",
|
||||||
|
" table: \"asignaturas\",\n",
|
||||||
|
" },\n",
|
||||||
|
" (payload) => {\n",
|
||||||
|
" // Imprimimos todo el objeto tal como llega del servidor\n",
|
||||||
|
" console.log(\"🚨 ¡EVENTO RECIBIDO EN CRUDO!\", payload);\n",
|
||||||
|
"\n",
|
||||||
|
" // Cerramos la conexión para liberar la celda\n",
|
||||||
|
" supabase.removeChannel(canal);\n",
|
||||||
|
" resolve(true);\n",
|
||||||
|
" },\n",
|
||||||
|
" )\n",
|
||||||
|
" .subscribe((status, err) => {\n",
|
||||||
|
" if (status === \"SUBSCRIBED\") {\n",
|
||||||
|
" console.log(\n",
|
||||||
|
" \"Conectado. Esperando cualquier cambio en la tabla asignaturas...\",\n",
|
||||||
|
" );\n",
|
||||||
|
" }\n",
|
||||||
|
" if (err) {\n",
|
||||||
|
" console.error(\"Error en el socket:\", err);\n",
|
||||||
|
" }\n",
|
||||||
|
" });\n",
|
||||||
|
"});\n",
|
||||||
|
"\n",
|
||||||
|
"console.log(\"Script finalizado.\");\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"execution_count": 27,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"await supabase.from(response.metadata.tabla).update({\n",
|
|
||||||
" datos: JSON.parse(response.output_text),\n",
|
|
||||||
"}).eq(\"id\", response.metadata.id).select(\"*\").single();"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "cbbfd8c0",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
{
|
||||||
"ename": "Expected ';', '}' or <eof> at file:///repl.tsx:2:93\n\n ...} ha sido actualizado con los datos generados por la IA.`: response.output_text,\n ~",
|
"cell_type": "code",
|
||||||
"evalue": "Expected ';', '}' or <eof> at file:///repl.tsx:2:93\n\n ...} ha sido actualizado con los datos generados por la IA.`: response.output_text,\n ~",
|
"execution_count": null,
|
||||||
"output_type": "error",
|
"id": "3602b930",
|
||||||
"traceback": []
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import(\"https://esm.sh/@supabase/supabase-js\").then(({ createClient }) => {\n",
|
||||||
|
" const supabase = createClient(\n",
|
||||||
|
" \"https://bxkskdxwppdlplrkidcz.supabase.co\",\n",
|
||||||
|
" \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJ4a3NrZHh3cHBkbHBscmtpZGN6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIwMzU2NDMsImV4cCI6MjA4NzYxMTY0M30.RIyvmB8Ij_qrsiMn0f65awiG5VaCUA5dvb117wuU3jo\",\n",
|
||||||
|
" );\n",
|
||||||
|
"\n",
|
||||||
|
" supabase.channel(\"prueba-consola\")\n",
|
||||||
|
" .on(\"postgres_changes\", {\n",
|
||||||
|
" event: \"*\",\n",
|
||||||
|
" schema: \"public\",\n",
|
||||||
|
" table: \"asignaturas\",\n",
|
||||||
|
" }, (payload) => {\n",
|
||||||
|
" console.log(\"🔥 ¡LLEGÓ EL EVENTO DESDE SUPABASE!\", payload);\n",
|
||||||
|
" })\n",
|
||||||
|
" .subscribe((status) => console.log(\"Estado de la conexión:\", status));\n",
|
||||||
|
"});\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 7,
|
||||||
|
"id": "26b3bb36",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"// plan Crear un plan en la base (sin datos) estado generando\n",
|
||||||
|
"const { data: plan, error } = await supabase.from(\"planes_estudio\").insert({\n",
|
||||||
|
" \"carrera_id\": \"8208da08-d549-4359-8865-9d806bc54f19\",\n",
|
||||||
|
" \"estructura_id\": \"69fb2b77-5a95-47e0-bf1f-389d384200e4\",\n",
|
||||||
|
" \"estado_actual_id\": \"18f49b67-8077-4371-be6e-2019a3be3562\",\n",
|
||||||
|
" \"nombre\": \"Ingeniearía en Cifbernética y Ciberseguridad (2025)\",\n",
|
||||||
|
" \"nivel\": \"Doctorado\",\n",
|
||||||
|
" \"tipo_ciclo\": \"Semestre\",\n",
|
||||||
|
" \"numero_ciclos\": 8,\n",
|
||||||
|
" \"activo\": true,\n",
|
||||||
|
" \"tipo_origen\": \"IA\",\n",
|
||||||
|
" \"creado_por\": \"11111111-1111-1111-1111-111111111111\",\n",
|
||||||
|
" \"actualizado_por\": \"11111111-1111-1111-1111-111111111111\",\n",
|
||||||
|
"}).select(\"*\").single();\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "8abb080e",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"{\n",
|
||||||
|
" message: \u001b[32m\"TypeError: error sending request for url (http://127.0.0.1:54321/rest/v1/planes_estudio?select=*): client error (Connect): tcp connect error: No se puede establecer una conexión ya que el equipo de destino denegó expresamente dicha conexión. (os error 10061)\"\u001b[39m,\n",
|
||||||
|
" details: \u001b[32m\"TypeError: error sending request for url (http://127.0.0.1:54321/rest/v1/planes_estudio?select=*): client error (Connect): tcp connect error: No se puede establecer una conexión ya que el equipo de destino denegó expresamente dicha conexión. (os error 10061)\\n\"\u001b[39m +\n",
|
||||||
|
" \u001b[32m\" at async mainFetch (ext:deno_fetch/26_fetch.js:192:12)\\n\"\u001b[39m +\n",
|
||||||
|
" \u001b[32m\" at async fetch (ext:deno_fetch/26_fetch.js:475:11)\\n\"\u001b[39m +\n",
|
||||||
|
" \u001b[32m\" at async <anonymous>:2:31\"\u001b[39m,\n",
|
||||||
|
" hint: \u001b[32m\"\"\u001b[39m,\n",
|
||||||
|
" code: \u001b[32m\"\"\u001b[39m\n",
|
||||||
|
"}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 9,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"plan.id;\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "a382bdc6",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"{\n",
|
||||||
|
" id: \u001b[32m\"resp_0ae72cbd0894279200699c8e42941c8192ba4a09e280b21a17\"\u001b[39m,\n",
|
||||||
|
" object: \u001b[32m\"response\"\u001b[39m,\n",
|
||||||
|
" created_at: \u001b[33m1771867714\u001b[39m,\n",
|
||||||
|
" status: \u001b[32m\"queued\"\u001b[39m,\n",
|
||||||
|
" background: \u001b[33mtrue\u001b[39m,\n",
|
||||||
|
" completed_at: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" error: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" frequency_penalty: \u001b[33m0\u001b[39m,\n",
|
||||||
|
" incomplete_details: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" instructions: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" max_output_tokens: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" max_tool_calls: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" model: \u001b[32m\"gpt-5-mini-2025-08-07\"\u001b[39m,\n",
|
||||||
|
" output: [],\n",
|
||||||
|
" parallel_tool_calls: \u001b[33mtrue\u001b[39m,\n",
|
||||||
|
" presence_penalty: \u001b[33m0\u001b[39m,\n",
|
||||||
|
" previous_response_id: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" prompt_cache_key: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" prompt_cache_retention: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" reasoning: { effort: \u001b[32m\"medium\"\u001b[39m, summary: \u001b[1mnull\u001b[22m },\n",
|
||||||
|
" safety_identifier: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" service_tier: \u001b[32m\"auto\"\u001b[39m,\n",
|
||||||
|
" store: \u001b[33mtrue\u001b[39m,\n",
|
||||||
|
" temperature: \u001b[33m1\u001b[39m,\n",
|
||||||
|
" text: {\n",
|
||||||
|
" format: {\n",
|
||||||
|
" type: \u001b[32m\"json_schema\"\u001b[39m,\n",
|
||||||
|
" description: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" name: \u001b[32m\"response_schema\"\u001b[39m,\n",
|
||||||
|
" schema: {\n",
|
||||||
|
" type: \u001b[32m\"object\"\u001b[39m,\n",
|
||||||
|
" properties: { \u001b[32m\"ai-message\"\u001b[39m: \u001b[36m[Object]\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[36m[Object]\u001b[39m },\n",
|
||||||
|
" required: [ \u001b[32m\"ai-message\"\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m ],\n",
|
||||||
|
" additionalProperties: \u001b[33mfalse\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" strict: \u001b[33mtrue\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" verbosity: \u001b[32m\"medium\"\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" tool_choice: \u001b[32m\"auto\"\u001b[39m,\n",
|
||||||
|
" tools: [],\n",
|
||||||
|
" top_logprobs: \u001b[33m0\u001b[39m,\n",
|
||||||
|
" top_p: \u001b[33m1\u001b[39m,\n",
|
||||||
|
" truncation: \u001b[32m\"disabled\"\u001b[39m,\n",
|
||||||
|
" usage: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" user: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" metadata: { tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"2\"\u001b[39m },\n",
|
||||||
|
" output_text: \u001b[32m\"\"\u001b[39m\n",
|
||||||
|
"}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 12,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"let response = await openai.responses.create({\n",
|
||||||
|
" model: \"gpt-5-mini\",\n",
|
||||||
|
" input: \"Hola mundo\",\n",
|
||||||
|
" metadata: {\n",
|
||||||
|
" tabla: \"planes_estudio\",\n",
|
||||||
|
" id: '2',\n",
|
||||||
|
" },\n",
|
||||||
|
" text: {\n",
|
||||||
|
" format: {\n",
|
||||||
|
" type: \"json_schema\",\n",
|
||||||
|
" name: \"response_schema\",\n",
|
||||||
|
" schema: {\n",
|
||||||
|
" type: \"object\",\n",
|
||||||
|
" properties: {\n",
|
||||||
|
" \"ai-message\": {\n",
|
||||||
|
" type: \"string\",\n",
|
||||||
|
" description:\n",
|
||||||
|
" \"Un mensaje de texto que la IA generará para el usuario final. Este mensaje debe ser claro y directo, proporcionando la información solicitada por el usuario o indicando que no se puede procesar la solicitud si no está relacionada con planes de estudio o asignaturas.\",\n",
|
||||||
|
" },\n",
|
||||||
|
" \"suggested-topic\": {\n",
|
||||||
|
" type: \"string\",\n",
|
||||||
|
" description:\n",
|
||||||
|
" \"Un tema de investigación sugerido por la IA que sea relevante para el plan de estudio o asignatura mencionada en la solicitud del usuario. Este tema debe ser específico y relacionado con el área de estudio correspondiente.\",\n",
|
||||||
|
" },\n",
|
||||||
|
" },\n",
|
||||||
|
" required: [\"ai-message\", \"suggested-topic\"],\n",
|
||||||
|
" additionalProperties: false,\n",
|
||||||
|
" },\n",
|
||||||
|
" },\n",
|
||||||
|
" },\n",
|
||||||
|
" background: true,\n",
|
||||||
|
"});\n",
|
||||||
|
"\n",
|
||||||
|
"response;\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "6b2470ed",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Current status: queued\n",
|
||||||
|
"Current status: in_progress\n",
|
||||||
|
"Current status: in_progress\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"{\n",
|
||||||
|
" id: \u001b[32m\"resp_0ae72cbd0894279200699c8e42941c8192ba4a09e280b21a17\"\u001b[39m,\n",
|
||||||
|
" object: \u001b[32m\"response\"\u001b[39m,\n",
|
||||||
|
" created_at: \u001b[33m1771867714\u001b[39m,\n",
|
||||||
|
" status: \u001b[32m\"completed\"\u001b[39m,\n",
|
||||||
|
" background: \u001b[33mtrue\u001b[39m,\n",
|
||||||
|
" billing: { payer: \u001b[32m\"developer\"\u001b[39m },\n",
|
||||||
|
" completed_at: \u001b[33m1771867721\u001b[39m,\n",
|
||||||
|
" error: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" frequency_penalty: \u001b[33m0\u001b[39m,\n",
|
||||||
|
" incomplete_details: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" instructions: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" max_output_tokens: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" max_tool_calls: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" model: \u001b[32m\"gpt-5-mini-2025-08-07\"\u001b[39m,\n",
|
||||||
|
" output: [\n",
|
||||||
|
" {\n",
|
||||||
|
" id: \u001b[32m\"rs_0ae72cbd0894279200699c8e43cd4c8192bcbc70792b6aa621\"\u001b[39m,\n",
|
||||||
|
" type: \u001b[32m\"reasoning\"\u001b[39m,\n",
|
||||||
|
" summary: []\n",
|
||||||
|
" },\n",
|
||||||
|
" {\n",
|
||||||
|
" id: \u001b[32m\"msg_0ae72cbd0894279200699c8e48976481928e1f205210636717\"\u001b[39m,\n",
|
||||||
|
" type: \u001b[32m\"message\"\u001b[39m,\n",
|
||||||
|
" status: \u001b[32m\"completed\"\u001b[39m,\n",
|
||||||
|
" content: [\n",
|
||||||
|
" {\n",
|
||||||
|
" type: \u001b[32m\"output_text\"\u001b[39m,\n",
|
||||||
|
" annotations: [],\n",
|
||||||
|
" logprobs: [],\n",
|
||||||
|
" text: \u001b[32m'{\"ai-message\":\"¡Hola! ¿En qué puedo ayudarte hoy? Puedo mostrar ejemplos de \\\\\"Hola mundo\\\\\" en distintos lenguajes, explicar conceptos de programación, ayudar con una tarea o sugerir recursos. Dime qué prefieres.\",\"suggested-topic\":\"El papel del programa \\\\\"Hola Mundo\\\\\" en la enseñanza de la programación: comparación práctica entre Python, Java y C (sintaxis, tiempo de arranque, facilidad de aprendizaje y percepción del estudiante).\"}'\u001b[39m\n",
|
||||||
|
" }\n",
|
||||||
|
" ],\n",
|
||||||
|
" role: \u001b[32m\"assistant\"\u001b[39m\n",
|
||||||
|
" }\n",
|
||||||
|
" ],\n",
|
||||||
|
" parallel_tool_calls: \u001b[33mtrue\u001b[39m,\n",
|
||||||
|
" presence_penalty: \u001b[33m0\u001b[39m,\n",
|
||||||
|
" previous_response_id: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" prompt_cache_key: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" prompt_cache_retention: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" reasoning: { effort: \u001b[32m\"medium\"\u001b[39m, summary: \u001b[1mnull\u001b[22m },\n",
|
||||||
|
" safety_identifier: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" service_tier: \u001b[32m\"default\"\u001b[39m,\n",
|
||||||
|
" store: \u001b[33mtrue\u001b[39m,\n",
|
||||||
|
" temperature: \u001b[33m1\u001b[39m,\n",
|
||||||
|
" text: {\n",
|
||||||
|
" format: {\n",
|
||||||
|
" type: \u001b[32m\"json_schema\"\u001b[39m,\n",
|
||||||
|
" description: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" name: \u001b[32m\"response_schema\"\u001b[39m,\n",
|
||||||
|
" schema: {\n",
|
||||||
|
" type: \u001b[32m\"object\"\u001b[39m,\n",
|
||||||
|
" properties: { \u001b[32m\"ai-message\"\u001b[39m: \u001b[36m[Object]\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[36m[Object]\u001b[39m },\n",
|
||||||
|
" required: [ \u001b[32m\"ai-message\"\u001b[39m, \u001b[32m\"suggested-topic\"\u001b[39m ],\n",
|
||||||
|
" additionalProperties: \u001b[33mfalse\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" strict: \u001b[33mtrue\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" verbosity: \u001b[32m\"medium\"\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" tool_choice: \u001b[32m\"auto\"\u001b[39m,\n",
|
||||||
|
" tools: [],\n",
|
||||||
|
" top_logprobs: \u001b[33m0\u001b[39m,\n",
|
||||||
|
" top_p: \u001b[33m1\u001b[39m,\n",
|
||||||
|
" truncation: \u001b[32m\"disabled\"\u001b[39m,\n",
|
||||||
|
" usage: {\n",
|
||||||
|
" input_tokens: \u001b[33m167\u001b[39m,\n",
|
||||||
|
" input_tokens_details: { cached_tokens: \u001b[33m0\u001b[39m },\n",
|
||||||
|
" output_tokens: \u001b[33m482\u001b[39m,\n",
|
||||||
|
" output_tokens_details: { reasoning_tokens: \u001b[33m320\u001b[39m },\n",
|
||||||
|
" total_tokens: \u001b[33m649\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" user: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" metadata: { tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"2\"\u001b[39m },\n",
|
||||||
|
" output_text: \u001b[32m'{\"ai-message\":\"¡Hola! ¿En qué puedo ayudarte hoy? Puedo mostrar ejemplos de \\\\\"Hola mundo\\\\\" en distintos lenguajes, explicar conceptos de programación, ayudar con una tarea o sugerir recursos. Dime qué prefieres.\",\"suggested-topic\":\"El papel del programa \\\\\"Hola Mundo\\\\\" en la enseñanza de la programación: comparación práctica entre Python, Java y C (sintaxis, tiempo de arranque, facilidad de aprendizaje y percepción del estudiante).\"}'\u001b[39m\n",
|
||||||
|
"}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 13,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"while (response.status === \"queued\" || response.status === \"in_progress\") {\n",
|
||||||
|
" console.log(\"Current status: \" + response.status);\n",
|
||||||
|
" await new Promise((resolve) => setTimeout(resolve, 2000)); // wait 2 seconds\n",
|
||||||
|
" response = await openai.responses.retrieve(response.id);\n",
|
||||||
|
"}\n",
|
||||||
|
"\n",
|
||||||
|
"response;\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "ebb67bd8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"{ tabla: \u001b[32m\"planes_estudio\"\u001b[39m, id: \u001b[32m\"e39952c1-a53b-43bf-8033-feda493a0651\"\u001b[39m }"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 24,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"response.metadata;\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "cd18fc2d",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"{\n",
|
||||||
|
" error: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" data: {\n",
|
||||||
|
" id: \u001b[32m\"e39952c1-a53b-43bf-8033-feda493a0651\"\u001b[39m,\n",
|
||||||
|
" carrera_id: \u001b[32m\"8208da08-d549-4359-8865-9d806bc54f19\"\u001b[39m,\n",
|
||||||
|
" estructura_id: \u001b[32m\"69fb2b77-5a95-47e0-bf1f-389d384200e4\"\u001b[39m,\n",
|
||||||
|
" nombre: \u001b[32m\"Ingeniearía en Cifbernética y Ciberseguridad (2025)\"\u001b[39m,\n",
|
||||||
|
" nivel: \u001b[32m\"Doctorado\"\u001b[39m,\n",
|
||||||
|
" tipo_ciclo: \u001b[32m\"Semestre\"\u001b[39m,\n",
|
||||||
|
" numero_ciclos: \u001b[33m8\u001b[39m,\n",
|
||||||
|
" datos: {\n",
|
||||||
|
" \u001b[32m\"ai-message\"\u001b[39m: \u001b[32m\"¡Hola! ¿Cómo puedo ayudarte hoy? Si te refieres al clásico “Hola mundo” en programación, puedo mostrarte ejemplos en varios lenguajes, explicar su historia o ayudarte a crear tu primer programa. ¿Qué prefieres?\"\u001b[39m,\n",
|
||||||
|
" \u001b[32m\"suggested-topic\"\u001b[39m: \u001b[32m\"El uso del programa “Hola mundo” como herramienta pedagógica en la enseñanza de la programación: impacto en la comprensión inicial, variaciones por lenguaje y mejores prácticas didácticas.\"\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" estado_actual_id: \u001b[32m\"18f49b67-8077-4371-be6e-2019a3be3562\"\u001b[39m,\n",
|
||||||
|
" activo: \u001b[33mtrue\u001b[39m,\n",
|
||||||
|
" tipo_origen: \u001b[32m\"IA\"\u001b[39m,\n",
|
||||||
|
" meta_origen: {},\n",
|
||||||
|
" creado_por: \u001b[32m\"11111111-1111-1111-1111-111111111111\"\u001b[39m,\n",
|
||||||
|
" actualizado_por: \u001b[32m\"11111111-1111-1111-1111-111111111111\"\u001b[39m,\n",
|
||||||
|
" creado_en: \u001b[32m\"2026-02-20T17:11:28.995222+00:00\"\u001b[39m,\n",
|
||||||
|
" actualizado_en: \u001b[32m\"2026-02-20T17:12:40.170995+00:00\"\u001b[39m,\n",
|
||||||
|
" nombre_search: \u001b[32m\"ingeniearia en cifbernetica y ciberseguridad (2025)\"\u001b[39m,\n",
|
||||||
|
" plan_hash: \u001b[32m\"70d28457bbddfe4f5cde0574\"\u001b[39m\n",
|
||||||
|
" },\n",
|
||||||
|
" count: \u001b[1mnull\u001b[22m,\n",
|
||||||
|
" status: \u001b[33m200\u001b[39m,\n",
|
||||||
|
" statusText: \u001b[32m\"OK\"\u001b[39m\n",
|
||||||
|
"}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 27,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"await supabase.from(response.metadata.tabla).update({\n",
|
||||||
|
" datos: JSON.parse(response.output_text),\n",
|
||||||
|
"}).eq(\"id\", response.metadata.id).select(\"*\").single();\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "cbbfd8c0",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"ename": "Expected ';', '}' or <eof> at file:///repl.tsx:2:93\n\n ...} ha sido actualizado con los datos generados por la IA.`: response.output_text,\n ~",
|
||||||
|
"evalue": "Expected ';', '}' or <eof> at file:///repl.tsx:2:93\n\n ...} ha sido actualizado con los datos generados por la IA.`: response.output_text,\n ~",
|
||||||
|
"output_type": "error",
|
||||||
|
"traceback": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "a00c79bd",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Deno",
|
||||||
|
"language": "typescript",
|
||||||
|
"name": "deno"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"codemirror_mode": "typescript",
|
||||||
|
"file_extension": ".ts",
|
||||||
|
"mimetype": "text/x.typescript",
|
||||||
|
"name": "typescript",
|
||||||
|
"nbconvert_exporter": "script",
|
||||||
|
"pygments_lexer": "typescript",
|
||||||
|
"version": "5.9.2"
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"source": []
|
|
||||||
},
|
},
|
||||||
{
|
"nbformat": 4,
|
||||||
"cell_type": "code",
|
"nbformat_minor": 5
|
||||||
"execution_count": null,
|
|
||||||
"id": "a00c79bd",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "Deno",
|
|
||||||
"language": "typescript",
|
|
||||||
"name": "deno"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"codemirror_mode": "typescript",
|
|
||||||
"file_extension": ".ts",
|
|
||||||
"mimetype": "text/x.typescript",
|
|
||||||
"name": "typescript",
|
|
||||||
"nbconvert_exporter": "script",
|
|
||||||
"pygments_lexer": "typescript",
|
|
||||||
"version": "5.9.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,23 @@
|
|||||||
" export: true,\n",
|
" export: true,\n",
|
||||||
"});\n",
|
"});\n",
|
||||||
"\n",
|
"\n",
|
||||||
"const openai = new OpenAI();"
|
"const openai = new OpenAI();\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 5,
|
||||||
|
"id": "3430f0fd",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"{\n",
|
||||||
|
" const response = await openai.responses.create({\n",
|
||||||
|
" model: \"gpt-5-nano\",\n",
|
||||||
|
" input: \"Cuál es el sentido de la vida?\",\n",
|
||||||
|
" background: true,\n",
|
||||||
|
" });\n",
|
||||||
|
"}\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -288,8 +304,8 @@
|
|||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"const { data: input } = await supabase.from(\"planes_estudio\")\n",
|
"const { data: input } = await supabase.from(\"planes_estudio\")\n",
|
||||||
".select(\"*, estructuras_plan (definicion)\").limit(1).single();\n",
|
" .select(\"*, estructuras_plan (definicion)\").limit(1).single();\n",
|
||||||
"input"
|
"input;\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -320,16 +336,22 @@
|
|||||||
" },\n",
|
" },\n",
|
||||||
"\n",
|
"\n",
|
||||||
" items: [\n",
|
" items: [\n",
|
||||||
" { type: \"message\", role: \"system\", content: \"En caso de que te pidan algo que no tiene nada que ver con planes de estudio o asignatura responde con un refusal.\" },\n",
|
" {\n",
|
||||||
|
" type: \"message\",\n",
|
||||||
|
" role: \"system\",\n",
|
||||||
|
" content:\n",
|
||||||
|
" \"En caso de que te pidan algo que no tiene nada que ver con planes de estudio o asignatura responde con un refusal.\",\n",
|
||||||
|
" },\n",
|
||||||
" ],\n",
|
" ],\n",
|
||||||
"});\n",
|
"});\n",
|
||||||
"\n",
|
"\n",
|
||||||
"const {data: conversationPlane} = await supabase.from(\"conversaciones_plan\").insert({\n",
|
"const { data: conversationPlane } = await supabase.from(\"conversaciones_plan\")\n",
|
||||||
" openai_conversation_id: openaiConversation.id,\n",
|
" .insert({\n",
|
||||||
" plan_estudio_id: input.id,\n",
|
" openai_conversation_id: openaiConversation.id,\n",
|
||||||
"}).select(\"id\").single();\n",
|
" plan_estudio_id: input.id,\n",
|
||||||
|
" }).select(\"id\").single();\n",
|
||||||
"\n",
|
"\n",
|
||||||
"conversationPlane"
|
"conversationPlane;\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -373,14 +395,16 @@
|
|||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"// Archivar una conversación\n",
|
"// Archivar una conversación\n",
|
||||||
"const items = await openai.conversations.items.list(conversation.openai_conversation_id);\n",
|
"const items = await openai.conversations.items.list(\n",
|
||||||
|
" conversation.openai_conversation_id,\n",
|
||||||
|
");\n",
|
||||||
"await supabase.from(\"conversaciones_plan\").update({\n",
|
"await supabase.from(\"conversaciones_plan\").update({\n",
|
||||||
" estado: \"ARCHIVANDO\",\n",
|
" estado: \"ARCHIVANDO\",\n",
|
||||||
" conversacion_json: items,\n",
|
" conversacion_json: items,\n",
|
||||||
" openai_conversation_id: null,\n",
|
" openai_conversation_id: null,\n",
|
||||||
"}).eq(\"id\", conversationPlane.id);\n",
|
"}).eq(\"id\", conversationPlane.id);\n",
|
||||||
"\n",
|
"\n",
|
||||||
"await openai.conversations.delete(conversation.openai_conversation_id);"
|
"await openai.conversations.delete(conversation.openai_conversation_id);\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -450,10 +474,13 @@
|
|||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"// Listar mensajes dado una conversacion_plan.id\n",
|
"// Listar mensajes dado una conversacion_plan.id\n",
|
||||||
"const { data: conversation } = await supabase.from(\"conversaciones_plan\").select(\"openai_conversation_id\").eq(\"id\", conversationPlane.id).single();\n",
|
"const { data: conversation } = await supabase.from(\"conversaciones_plan\")\n",
|
||||||
"const items = await openai.conversations.items.list(conversation.openai_conversation_id);\n",
|
" .select(\"openai_conversation_id\").eq(\"id\", conversationPlane.id).single();\n",
|
||||||
|
"const items = await openai.conversations.items.list(\n",
|
||||||
|
" conversation.openai_conversation_id,\n",
|
||||||
|
");\n",
|
||||||
"// items.data.filter((item) => item.type === \"message\" && [\"assistant\", \"user\"].includes(item.role))\n",
|
"// items.data.filter((item) => item.type === \"message\" && [\"assistant\", \"user\"].includes(item.role))\n",
|
||||||
"JSON.stringify(items, null, 2)"
|
"JSON.stringify(items, null, 2);\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -714,7 +741,7 @@
|
|||||||
"const { data: respuesta, error } = await supabase.functions.invoke(\n",
|
"const { data: respuesta, error } = await supabase.functions.invoke(\n",
|
||||||
" `create-chat-conversation/conversations/${data.conversation_plan.id}/messages`,\n",
|
" `create-chat-conversation/conversations/${data.conversation_plan.id}/messages`,\n",
|
||||||
" {\n",
|
" {\n",
|
||||||
" method: \"GET\"\n",
|
" method: \"GET\",\n",
|
||||||
" },\n",
|
" },\n",
|
||||||
");\n",
|
");\n",
|
||||||
"\n",
|
"\n",
|
||||||
@@ -767,7 +794,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"conversationToUpdate.openai_conversation_id"
|
"conversationToUpdate.openai_conversation_id;\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -792,7 +819,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"await openai.beta.threads.messages.retrieve('msg_057bd8aacc77d01f006994e942d98481938f35bf58dacf126d', {thread_id:conversationToUpdate.openai_conversation_id})"
|
"await openai.beta.threads.messages.retrieve(\n",
|
||||||
|
" \"msg_057bd8aacc77d01f006994e942d98481938f35bf58dacf126d\",\n",
|
||||||
|
" { thread_id: conversationToUpdate.openai_conversation_id },\n",
|
||||||
|
");\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v2.185.0
|
v2.187.0
|
||||||
@@ -1 +1 @@
|
|||||||
postgresql://postgres.exdkssurzmjnnhgtiama@aws-0-us-west-1.pooler.supabase.com:5432/postgres
|
postgresql://postgres.bxkskdxwppdlplrkidcz@aws-1-us-east-2.pooler.supabase.com:5432/postgres
|
||||||
@@ -1 +1 @@
|
|||||||
15.8.1.085
|
17.6.1.063
|
||||||
@@ -1 +1 @@
|
|||||||
exdkssurzmjnnhgtiama
|
bxkskdxwppdlplrkidcz
|
||||||
@@ -1 +1 @@
|
|||||||
v12.2.3
|
v14.1
|
||||||
@@ -1 +1 @@
|
|||||||
buckets-objects-grants-postgres
|
fix-optimized-search-function
|
||||||
@@ -1 +1 @@
|
|||||||
v1.33.0
|
v1.37.7
|
||||||
@@ -48,3 +48,14 @@ entrypoint = "./functions/generate-subject-suggestions/index.ts"
|
|||||||
# Specifies static files to be bundled with the function. Supports glob patterns.
|
# Specifies static files to be bundled with the function. Supports glob patterns.
|
||||||
# For example, if you want to serve static HTML pages in your function:
|
# For example, if you want to serve static HTML pages in your function:
|
||||||
# static_files = [ "./functions/generate-subject-suggestions/*.html" ]
|
# static_files = [ "./functions/generate-subject-suggestions/*.html" ]
|
||||||
|
|
||||||
|
[functions.openai-webhook-responses]
|
||||||
|
enabled = true
|
||||||
|
verify_jwt = true
|
||||||
|
import_map = "./functions/openai-webhook-responses/deno.json"
|
||||||
|
# Uncomment to specify a custom file path to the entrypoint.
|
||||||
|
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
|
||||||
|
entrypoint = "./functions/openai-webhook-responses/index.ts"
|
||||||
|
# Specifies static files to be bundled with the function. Supports glob patterns.
|
||||||
|
# For example, if you want to serve static HTML pages in your function:
|
||||||
|
# static_files = [ "./functions/openai-webhook-responses/*.html" ]
|
||||||
|
|||||||
+2481
File diff suppressed because one or more lines are too long
@@ -1209,11 +1209,24 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
|
append_conversacion_asignatura: {
|
||||||
|
Args: { p_append: Json; p_id: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
|
append_conversacion_plan: {
|
||||||
|
Args: { p_append: Json; p_id: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
unaccent: { Args: { "": string }; Returns: string }
|
unaccent: { Args: { "": string }; Returns: string }
|
||||||
unaccent_immutable: { Args: { "": string }; Returns: string }
|
unaccent_immutable: { Args: { "": string }; Returns: string }
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura: "borrador" | "revisada" | "aprobada" | "generando"
|
estado_asignatura:
|
||||||
|
| "borrador"
|
||||||
|
| "revisada"
|
||||||
|
| "aprobada"
|
||||||
|
| "generando"
|
||||||
|
| "fallida"
|
||||||
estado_conversacion: "ACTIVA" | "ARCHIVANDO" | "ARCHIVADA" | "ERROR"
|
estado_conversacion: "ACTIVA" | "ARCHIVANDO" | "ARCHIVADA" | "ERROR"
|
||||||
estado_tarea_revision: "PENDIENTE" | "COMPLETADA" | "OMITIDA"
|
estado_tarea_revision: "PENDIENTE" | "COMPLETADA" | "OMITIDA"
|
||||||
fuente_cambio: "HUMANO" | "IA"
|
fuente_cambio: "HUMANO" | "IA"
|
||||||
@@ -1387,7 +1400,13 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura: ["borrador", "revisada", "aprobada", "generando"],
|
estado_asignatura: [
|
||||||
|
"borrador",
|
||||||
|
"revisada",
|
||||||
|
"aprobada",
|
||||||
|
"generando",
|
||||||
|
"fallida",
|
||||||
|
],
|
||||||
estado_conversacion: ["ACTIVA", "ARCHIVANDO", "ARCHIVADA", "ERROR"],
|
estado_conversacion: ["ACTIVA", "ARCHIVANDO", "ARCHIVADA", "ERROR"],
|
||||||
estado_tarea_revision: ["PENDIENTE", "COMPLETADA", "OMITIDA"],
|
estado_tarea_revision: ["PENDIENTE", "COMPLETADA", "OMITIDA"],
|
||||||
fuente_cambio: ["HUMANO", "IA"],
|
fuente_cambio: ["HUMANO", "IA"],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
/// <reference lib="deno.window" />
|
/// <reference lib="deno.window" />
|
||||||
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";
|
|
||||||
// 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;
|
||||||
@@ -15,7 +14,6 @@ export type StructuredResponseSuccess<TOutput = unknown> = {
|
|||||||
responseId: string;
|
responseId: string;
|
||||||
conversationId?: string | null;
|
conversationId?: string | null;
|
||||||
references: {
|
references: {
|
||||||
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
|
||||||
@@ -24,7 +22,6 @@ export type StructuredResponseFailure = {
|
|||||||
ok: false;
|
ok: false;
|
||||||
code:
|
code:
|
||||||
| "MissingEnv"
|
| "MissingEnv"
|
||||||
| "StorageUploadFailed"
|
|
||||||
| "OpenAIFileUploadFailed"
|
| "OpenAIFileUploadFailed"
|
||||||
| "OpenAIRequestFailed";
|
| "OpenAIRequestFailed";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -34,40 +31,24 @@ export type StructuredResponseResult<TOutput = unknown> =
|
|||||||
| StructuredResponseSuccess<TOutput>
|
| StructuredResponseSuccess<TOutput>
|
||||||
| StructuredResponseFailure;
|
| StructuredResponseFailure;
|
||||||
export interface OpenAIServiceConfig {
|
export interface OpenAIServiceConfig {
|
||||||
supabaseUrl: string;
|
|
||||||
serviceRoleKey: string;
|
|
||||||
openAIApiKey: string;
|
openAIApiKey: string;
|
||||||
bucket?: string; // default: ai-storage
|
|
||||||
}
|
}
|
||||||
export class OpenAIService {
|
export class OpenAIService {
|
||||||
private readonly supabase: SupabaseClient;
|
|
||||||
private readonly openai: OpenAI;
|
private readonly openai: OpenAI;
|
||||||
private readonly bucket: string;
|
private constructor(openai: OpenAI) {
|
||||||
private constructor(
|
|
||||||
client: SupabaseClient,
|
|
||||||
openai: OpenAI,
|
|
||||||
bucket: string,
|
|
||||||
) {
|
|
||||||
this.supabase = client;
|
|
||||||
this.openai = openai;
|
this.openai = openai;
|
||||||
this.bucket = bucket;
|
|
||||||
}
|
}
|
||||||
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
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 openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||||
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
if (!openAIApiKey) {
|
||||||
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
code: "MissingEnv",
|
code: "MissingEnv",
|
||||||
message:
|
message: "Required env vars missing: OPENAI_API_KEY",
|
||||||
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
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(openai);
|
||||||
}
|
}
|
||||||
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({
|
||||||
@@ -80,9 +61,6 @@ export class OpenAIService {
|
|||||||
files?: File[],
|
files?: File[],
|
||||||
): Promise<StructuredResponseResult<TOutput>> {
|
): Promise<StructuredResponseResult<TOutput>> {
|
||||||
try {
|
try {
|
||||||
const uploadedToStorage = await this.uploadFilesToStorage(
|
|
||||||
files ?? [],
|
|
||||||
);
|
|
||||||
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
||||||
|
|
||||||
const newOptions = { ...options };
|
const newOptions = { ...options };
|
||||||
@@ -112,6 +90,11 @@ 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 isBackground =
|
||||||
|
(newOptions as unknown as { background?: boolean })
|
||||||
|
.background ===
|
||||||
|
true;
|
||||||
const { model, id: responseId } = openaiRaw;
|
const { model, id: responseId } = openaiRaw;
|
||||||
const usage = openaiRaw?.usage ?? null;
|
const usage = openaiRaw?.usage ?? null;
|
||||||
const conversationId = (
|
const conversationId = (
|
||||||
@@ -119,6 +102,22 @@ export class OpenAIService {
|
|||||||
conversation_id?: string | null;
|
conversation_id?: string | null;
|
||||||
}
|
}
|
||||||
).conversation_id ?? null;
|
).conversation_id ?? null;
|
||||||
|
|
||||||
|
if (isBackground) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
output: undefined,
|
||||||
|
outputText: undefined,
|
||||||
|
model: String(model),
|
||||||
|
usage,
|
||||||
|
responseId: String(responseId),
|
||||||
|
conversationId: conversationId
|
||||||
|
? String(conversationId)
|
||||||
|
: null,
|
||||||
|
references: { openaiFileIds },
|
||||||
|
openaiRaw,
|
||||||
|
};
|
||||||
|
}
|
||||||
// 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;
|
||||||
@@ -154,38 +153,18 @@ export class OpenAIService {
|
|||||||
usage,
|
usage,
|
||||||
responseId: String(responseId),
|
responseId: String(responseId),
|
||||||
conversationId: conversationId ? String(conversationId) : null,
|
conversationId: conversationId ? String(conversationId) : null,
|
||||||
references: { uploadedToStorage, openaiFileIds },
|
references: { openaiFileIds },
|
||||||
openaiRaw,
|
openaiRaw,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error;
|
const e = err as Error;
|
||||||
const message = e?.message ?? "Unknown error";
|
const message = e?.message ?? "Unknown error";
|
||||||
const code = message.includes("Supabase upload failed")
|
const code = message.includes("OpenAI file upload failed")
|
||||||
? "StorageUploadFailed"
|
|
||||||
: 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[]> {
|
|
||||||
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 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) {
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ export function sendError(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResponseMetadata extends Record<string, string | undefined> {
|
||||||
|
tabla?: string;
|
||||||
|
accion?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ type NivelType =
|
|||||||
type TipoCicloType =
|
type TipoCicloType =
|
||||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
||||||
|
|
||||||
addEventListener("beforeunload", (ev: any) => {
|
type BeforeUnloadWithDetail = Event & { detail?: { reason?: unknown } };
|
||||||
// ev.detail.reason te dirá si es "timeout", "memory_limit" o "idle"
|
|
||||||
console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason);
|
|
||||||
|
|
||||||
// Aquí puedes intentar un último log antes de que todo muera
|
// Re-registramos con tipo estricto (evita `any` en análisis)
|
||||||
|
addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => {
|
||||||
|
console.error("ALERTA: La función se va a apagar. Razón:", ev.detail?.reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.serve(async (req: Request): Promise<Response> => {
|
Deno.serve(async (req: Request): Promise<Response> => {
|
||||||
@@ -195,8 +195,98 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
? estructuraPlan.definicion as Record<string, unknown>
|
? estructuraPlan.definicion as Record<string, unknown>
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
const { data: estado } = await supabaseService
|
||||||
|
.from("estados_plan")
|
||||||
|
.select("id,clave,orden")
|
||||||
|
.eq("clave", "GENERANDO")
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!estado?.id) {
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"No se encontró el estado GENERANDO.",
|
||||||
|
"MISSING_STATE",
|
||||||
|
{ clave: "GENERANDO" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: carrera, error: carreraError } = await supabaseService
|
||||||
|
.from("carreras")
|
||||||
|
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
|
||||||
|
.eq("id", payload.datosBasicos.carreraId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (carreraError) {
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"No se pudo obtener la carrera.",
|
||||||
|
"SUPABASE_QUERY_FAILED",
|
||||||
|
carreraError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!carrera) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
"No se encontró la carrera.",
|
||||||
|
"NOT_FOUND",
|
||||||
|
{ table: "carreras", id: payload.datosBasicos.carreraId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
|
||||||
|
{
|
||||||
|
carrera_id: carrera.id as string,
|
||||||
|
estructura_id: estructuraPlan?.id as string,
|
||||||
|
nombre: payload.datosBasicos.nombrePlan,
|
||||||
|
nivel: payload.datosBasicos.nivel as NivelType,
|
||||||
|
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
|
||||||
|
numero_ciclos: payload.datosBasicos.numCiclos,
|
||||||
|
// IMPORTANTE: se inserta SIN `datos` (se actualiza vía webhook)
|
||||||
|
estado_actual_id: estado.id,
|
||||||
|
activo: true,
|
||||||
|
tipo_origen: "IA",
|
||||||
|
meta_origen: {
|
||||||
|
generado_por: "ai-generate-plan",
|
||||||
|
referencias: {
|
||||||
|
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
||||||
|
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
||||||
|
},
|
||||||
|
iaConfig: {
|
||||||
|
descripcionEnfoqueAcademico:
|
||||||
|
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
||||||
|
instruccionesAdicionalesIA:
|
||||||
|
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
|
||||||
|
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
||||||
|
},
|
||||||
|
} as unknown as Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: plan, error: planError } = await supabaseService
|
||||||
|
.from("planes_estudio")
|
||||||
|
.insert(planInsert)
|
||||||
|
.select(
|
||||||
|
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (planError) {
|
||||||
|
const maybeCode = (planError as { code?: string }).code;
|
||||||
|
const status = maybeCode ? 409 : 500;
|
||||||
|
throw new HttpError(
|
||||||
|
status,
|
||||||
|
"No se pudo guardar el plan de estudios.",
|
||||||
|
"SUPABASE_INSERT_FAILED",
|
||||||
|
{ ...planError, code: maybeCode },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const aiStructuredPayload: StructuredResponseOptions = {
|
const aiStructuredPayload: StructuredResponseOptions = {
|
||||||
model: AI_GENERATE_PLAN_MODELO,
|
model: AI_GENERATE_PLAN_MODELO,
|
||||||
|
background: true,
|
||||||
|
metadata: {
|
||||||
|
tabla: "planes_estudio",
|
||||||
|
accion: "crear",
|
||||||
|
id: String(plan.id),
|
||||||
|
},
|
||||||
input: [
|
input: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
@@ -244,126 +334,12 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
status,
|
status,
|
||||||
"No se pudo generar el plan con IA.",
|
"No se pudo iniciar la generación del plan con IA.",
|
||||||
"OPENAI_REQUEST_FAILED",
|
"OPENAI_REQUEST_FAILED",
|
||||||
aiResult,
|
aiResult,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer parsed output; fallback to parse outputText
|
|
||||||
let aiOutput = aiResult.output ?? null;
|
|
||||||
if (aiOutput == null && aiResult.outputText) {
|
|
||||||
try {
|
|
||||||
aiOutput = JSON.parse(aiResult.outputText);
|
|
||||||
} catch {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
"La respuesta de la IA no es JSON válido.",
|
|
||||||
"OPENAI_INVALID_JSON",
|
|
||||||
{ outputText: aiResult.outputText },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const aiOutputJson: Json = aiOutput as unknown as Json;
|
|
||||||
if (!aiOutput) {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
"La respuesta de la IA no contiene salida estructurada.",
|
|
||||||
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
|
||||||
{ outputText: aiResult.outputText ?? null },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
|
|
||||||
|
|
||||||
const { data: estado } = await supabaseService
|
|
||||||
.from("estados_plan")
|
|
||||||
.select("id,clave,orden")
|
|
||||||
.ilike("clave", "BORRADOR%")
|
|
||||||
.order("orden", { ascending: true })
|
|
||||||
.limit(1)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
const { data: carrera, error: carreraError } = await supabaseService
|
|
||||||
.from("carreras")
|
|
||||||
.select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)")
|
|
||||||
.eq("id", payload.datosBasicos.carreraId)
|
|
||||||
.maybeSingle();
|
|
||||||
if (carreraError) {
|
|
||||||
throw new HttpError(
|
|
||||||
500,
|
|
||||||
"No se pudo obtener la carrera.",
|
|
||||||
"SUPABASE_QUERY_FAILED",
|
|
||||||
carreraError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!carrera) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
"No se encontró la carrera.",
|
|
||||||
"NOT_FOUND",
|
|
||||||
{ table: "carreras", id: payload.datosBasicos.carreraId },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
|
|
||||||
{
|
|
||||||
carrera_id: carrera?.id as string,
|
|
||||||
estructura_id: estructuraPlan?.id as string,
|
|
||||||
nombre: payload.datosBasicos.nombrePlan,
|
|
||||||
nivel: payload.datosBasicos.nivel as NivelType,
|
|
||||||
tipo_ciclo: payload.datosBasicos.tipoCiclo as TipoCicloType,
|
|
||||||
numero_ciclos: payload.datosBasicos.numCiclos,
|
|
||||||
datos: aiOutputJson,
|
|
||||||
estado_actual_id: estado?.id ?? undefined,
|
|
||||||
activo: true,
|
|
||||||
tipo_origen: "IA",
|
|
||||||
meta_origen: {
|
|
||||||
generado_por: "ai_generate_plan",
|
|
||||||
ai_structured: {
|
|
||||||
responseId: aiResult.responseId ?? null,
|
|
||||||
conversationId: aiResult.conversationId ?? null,
|
|
||||||
model: aiResult.model,
|
|
||||||
usage: aiResult.usage ?? null,
|
|
||||||
},
|
|
||||||
referencias: {
|
|
||||||
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
|
||||||
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
|
||||||
},
|
|
||||||
iaConfig: {
|
|
||||||
descripcionEnfoqueAcademico:
|
|
||||||
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
|
||||||
instruccionesAdicionalesIA:
|
|
||||||
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
|
|
||||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
|
||||||
},
|
|
||||||
} as unknown as Json,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: plan, error: planError } = await supabaseService
|
|
||||||
.from("planes_estudio")
|
|
||||||
.insert(planInsert)
|
|
||||||
.select(
|
|
||||||
"id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos",
|
|
||||||
)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// TODO: interaccion con IA y cambio de estado
|
|
||||||
|
|
||||||
if (planError) {
|
|
||||||
const maybeCode = (planError as { code?: string }).code;
|
|
||||||
// Common cases:
|
|
||||||
// - foreign key / constraint violations -> 409
|
|
||||||
// - others -> 500
|
|
||||||
const status = maybeCode ? 409 : 500;
|
|
||||||
throw new HttpError(
|
|
||||||
status,
|
|
||||||
"No se pudo guardar el plan de estudios.",
|
|
||||||
"SUPABASE_INSERT_FAILED",
|
|
||||||
{ ...planError, code: maybeCode },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
|
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -18,38 +18,13 @@ addEventListener("beforeunload", (ev: BeforeUnloadWithDetail) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const DatosUpdateSchema = z
|
||||||
* JSON input signature (when `Content-Type: application/json`)
|
|
||||||
*
|
|
||||||
* Required:
|
|
||||||
* - `id` (uuid) - corresponde a la columna `id` de `asignaturas`
|
|
||||||
*
|
|
||||||
* Optional patch fields (any subset is allowed):
|
|
||||||
* - `nombre` (string)
|
|
||||||
* - `codigo` (string | null)
|
|
||||||
* - `tipo` (string | null) // must match DB enum values
|
|
||||||
* - `creditos` (number)
|
|
||||||
* - `horas_academicas` (number | null)
|
|
||||||
* - `horas_independientes` (number | null)
|
|
||||||
* - `numero_ciclo` (number | null)
|
|
||||||
* - `estructura_id` (uuid | null)
|
|
||||||
* - `plan_estudio_id` (uuid)
|
|
||||||
* - `linea_plan_id` (uuid | null)
|
|
||||||
* - `orden_celda` (number | null)
|
|
||||||
*
|
|
||||||
* IA config (optional):
|
|
||||||
* - `descripcionEnfoqueAcademico` (string)
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - This JSON flow does NOT accept `instruccionesAdicionalesIA`.
|
|
||||||
* - Missing optional fields are ignored (the existing DB values remain).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const JsonUpdateSchema = z
|
|
||||||
.object({
|
.object({
|
||||||
id: z.string().uuid("id debe ser un UUID"),
|
id: z.string().uuid("id debe ser un UUID").optional(),
|
||||||
|
|
||||||
// patch fields (all optional) — nombres coinciden con columnas DB
|
// campos de asignatura (nombres coinciden con columnas DB)
|
||||||
|
plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"),
|
||||||
|
estructura_id: z.string().uuid("estructura_id debe ser un UUID").optional(),
|
||||||
nombre: z.string().min(1).optional(),
|
nombre: z.string().min(1).optional(),
|
||||||
codigo: z.union([z.string().min(1), z.null()]).optional(),
|
codigo: z.union([z.string().min(1), z.null()]).optional(),
|
||||||
tipo: z.union([z.string().min(1), z.null()]).optional(),
|
tipo: z.union([z.string().min(1), z.null()]).optional(),
|
||||||
@@ -57,17 +32,70 @@ const JsonUpdateSchema = z
|
|||||||
horas_academicas: z.number().int().nonnegative().nullable().optional(),
|
horas_academicas: z.number().int().nonnegative().nullable().optional(),
|
||||||
horas_independientes: z.number().int().nonnegative().nullable().optional(),
|
horas_independientes: z.number().int().nonnegative().nullable().optional(),
|
||||||
numero_ciclo: z.number().int().positive().nullable().optional(),
|
numero_ciclo: z.number().int().positive().nullable().optional(),
|
||||||
estructura_id: z.string().uuid().nullable().optional(),
|
|
||||||
plan_estudio_id: z.string().uuid().optional(),
|
|
||||||
linea_plan_id: z.string().uuid().nullable().optional(),
|
linea_plan_id: z.string().uuid().nullable().optional(),
|
||||||
orden_celda: z.number().int().nonnegative().nullable().optional(),
|
orden_celda: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
|
||||||
// IA config (no instruccionesAdicionalesIA)
|
|
||||||
descripcionEnfoqueAcademico: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
type EdgeAIGenerateSubjectJsonUpdateInput = z.infer<typeof JsonUpdateSchema>;
|
const IAConfigSchema = z
|
||||||
|
.object({
|
||||||
|
descripcionEnfoqueAcademico: z.string().optional(),
|
||||||
|
instruccionesAdicionalesIA: z.string().optional(),
|
||||||
|
archivosAdjuntos: z.array(z.string().min(1)).optional().default([]),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default({});
|
||||||
|
|
||||||
|
const UnifiedJsonSchema = z
|
||||||
|
.object({
|
||||||
|
datosUpdate: DatosUpdateSchema,
|
||||||
|
iaConfig: IAConfigSchema,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
// Si no hay id, es un insert => necesitamos campos mínimos
|
||||||
|
if (!val.datosUpdate.id) {
|
||||||
|
if (!val.datosUpdate.estructura_id) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["datosUpdate", "estructura_id"],
|
||||||
|
message: "estructura_id es requerido para crear",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!val.datosUpdate.nombre) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["datosUpdate", "nombre"],
|
||||||
|
message: "nombre es requerido para crear",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (val.datosUpdate.creditos === undefined) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["datosUpdate", "creditos"],
|
||||||
|
message: "creditos es requerido para crear",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type EdgeAIGenerateSubjectUnifiedInput = z.infer<typeof UnifiedJsonSchema>;
|
||||||
|
|
||||||
|
type AsignaturaBaseSeleccionada = {
|
||||||
|
id: string;
|
||||||
|
plan_estudio_id: string;
|
||||||
|
estructura_id: string | null;
|
||||||
|
nombre: string;
|
||||||
|
codigo: string | null;
|
||||||
|
tipo: Database["public"]["Tables"]["asignaturas"]["Row"]["tipo"];
|
||||||
|
creditos: number;
|
||||||
|
horas_academicas: number | null;
|
||||||
|
horas_independientes: number | null;
|
||||||
|
numero_ciclo: number | null;
|
||||||
|
linea_plan_id: string | null;
|
||||||
|
orden_celda: number | null;
|
||||||
|
estado: Database["public"]["Enums"]["estado_asignatura"];
|
||||||
|
};
|
||||||
|
|
||||||
function withColumnDefsAndRefs(
|
function withColumnDefsAndRefs(
|
||||||
schemaDef: Record<string, unknown>,
|
schemaDef: Record<string, unknown>,
|
||||||
@@ -96,28 +124,6 @@ function withColumnDefsAndRefs(
|
|||||||
return nextSchema;
|
return nextSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitAiOutputStringsAndColumns(
|
|
||||||
aiOutput: unknown,
|
|
||||||
): { aiOutputJson: Json; columnasGeneradas: Record<string, unknown> } {
|
|
||||||
if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) {
|
|
||||||
return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = aiOutput as Record<string, unknown>;
|
|
||||||
const stringsOnly: Record<string, string> = {};
|
|
||||||
const columnasGeneradas: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(record)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
stringsOnly[key] = value;
|
|
||||||
} else {
|
|
||||||
columnasGeneradas[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { aiOutputJson: stringsOnly as unknown as Json, columnasGeneradas };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatZodIssues(issues: z.ZodIssue[]): string {
|
function formatZodIssues(issues: z.ZodIssue[]): string {
|
||||||
return issues
|
return issues
|
||||||
.map((issue, i) => {
|
.map((issue, i) => {
|
||||||
@@ -171,9 +177,8 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
||||||
const isMultipart = contentType.startsWith("multipart/form-data");
|
|
||||||
const isJson = contentType.includes("application/json");
|
const isJson = contentType.includes("application/json");
|
||||||
if (!isMultipart && !isJson) {
|
if (!isJson) {
|
||||||
console.error(
|
console.error(
|
||||||
`[${
|
`[${
|
||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
@@ -183,7 +188,7 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
415,
|
415,
|
||||||
"Content-Type no soportado.",
|
"Content-Type no soportado.",
|
||||||
"UNSUPPORTED_MEDIA_TYPE",
|
"UNSUPPORTED_MEDIA_TYPE",
|
||||||
{ contentType },
|
{ contentType, expected: "application/json" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,340 +242,224 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
) ?? "gpt-5-nano";
|
) ?? "gpt-5-nano";
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// JSON update flow
|
// Unified JSON create/update flow (background)
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
if (isJson) {
|
let rawBody: unknown;
|
||||||
let rawBody: unknown;
|
try {
|
||||||
try {
|
rawBody = await req.json();
|
||||||
rawBody = await req.json();
|
} catch (e) {
|
||||||
} catch (e) {
|
throw new HttpError(
|
||||||
throw new HttpError(
|
400,
|
||||||
400,
|
"Body JSON inválido.",
|
||||||
"Body JSON inválido.",
|
"INVALID_JSON",
|
||||||
"INVALID_JSON",
|
{ cause: e },
|
||||||
{ cause: e },
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = JsonUpdateSchema.safeParse(rawBody);
|
const parsedBody = UnifiedJsonSchema.safeParse(rawBody);
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
422,
|
422,
|
||||||
formatZodIssues(parsedBody.error.issues),
|
formatZodIssues(parsedBody.error.issues),
|
||||||
"VALIDATION_ERROR",
|
"VALIDATION_ERROR",
|
||||||
parsedBody.error,
|
parsedBody.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: EdgeAIGenerateSubjectJsonUpdateInput = parsedBody.data;
|
const payload: EdgeAIGenerateSubjectUnifiedInput = parsedBody.data;
|
||||||
|
const { datosUpdate, iaConfig } = payload;
|
||||||
|
const isUpdate = Boolean(datosUpdate.id);
|
||||||
|
|
||||||
const { data: existingAsignatura, error: existingAsignaturaError } =
|
const svc = OpenAIService.fromEnv();
|
||||||
await supabaseService
|
if (!(svc instanceof OpenAIService)) {
|
||||||
.from("asignaturas")
|
throw new HttpError(
|
||||||
.select(
|
500,
|
||||||
"id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda",
|
"Configuración del servidor incompleta.",
|
||||||
)
|
"OPENAI_MISCONFIGURED",
|
||||||
.eq("id", payload.id)
|
svc,
|
||||||
.single();
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (existingAsignaturaError) {
|
// Resolve fields (merge for update)
|
||||||
const maybeCode = (existingAsignaturaError as { code?: string }).code;
|
const selectCols =
|
||||||
|
"id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda,estado";
|
||||||
|
|
||||||
|
let asignaturaBase: AsignaturaBaseSeleccionada | null = null;
|
||||||
|
|
||||||
|
if (isUpdate) {
|
||||||
|
const { data, error } = await supabaseService
|
||||||
|
.from("asignaturas")
|
||||||
|
.select(selectCols)
|
||||||
|
.eq("id", datosUpdate.id as string)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
const maybeCode = (error as { code?: string }).code;
|
||||||
if (maybeCode === "PGRST116") {
|
if (maybeCode === "PGRST116") {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
404,
|
404,
|
||||||
"No se encontró la asignatura.",
|
"No se encontró la asignatura.",
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
{ table: "asignaturas", id: payload.id },
|
{ table: "asignaturas", id: datosUpdate.id },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
500,
|
500,
|
||||||
"No se pudo obtener la asignatura.",
|
"No se pudo obtener la asignatura.",
|
||||||
"SUPABASE_QUERY_FAILED",
|
"SUPABASE_QUERY_FAILED",
|
||||||
existingAsignaturaError,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
asignaturaBase = data as unknown as AsignaturaBaseSeleccionada;
|
||||||
const resolved = {
|
|
||||||
plan_estudio_id: payload.plan_estudio_id ??
|
|
||||||
existingAsignatura.plan_estudio_id,
|
|
||||||
estructura_id: payload.estructura_id ??
|
|
||||||
existingAsignatura.estructura_id,
|
|
||||||
nombre: payload.nombre ?? existingAsignatura.nombre,
|
|
||||||
codigo: payload.codigo ?? existingAsignatura.codigo,
|
|
||||||
tipo: payload.tipo ?? existingAsignatura.tipo,
|
|
||||||
creditos: payload.creditos ?? existingAsignatura.creditos,
|
|
||||||
horas_academicas: payload.horas_academicas ??
|
|
||||||
existingAsignatura.horas_academicas,
|
|
||||||
horas_independientes: payload.horas_independientes ??
|
|
||||||
existingAsignatura.horas_independientes,
|
|
||||||
numero_ciclo: payload.numero_ciclo ?? existingAsignatura.numero_ciclo,
|
|
||||||
linea_plan_id: payload.linea_plan_id ??
|
|
||||||
existingAsignatura.linea_plan_id,
|
|
||||||
orden_celda: payload.orden_celda ?? existingAsignatura.orden_celda,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!resolved.estructura_id) {
|
|
||||||
throw new HttpError(
|
|
||||||
422,
|
|
||||||
"estructura_id es requerido (no está presente ni en el JSON ni en la asignatura existente).",
|
|
||||||
"VALIDATION_ERROR",
|
|
||||||
{
|
|
||||||
id: payload.id,
|
|
||||||
existing_estructura_id: existingAsignatura.estructura_id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: estructura, error: estructuraError } = await supabaseService
|
|
||||||
.from("estructuras_asignatura")
|
|
||||||
.select("id,nombre,definicion,version")
|
|
||||||
.eq("id", resolved.estructura_id)
|
|
||||||
.single();
|
|
||||||
if (estructuraError) {
|
|
||||||
const maybeCode = (estructuraError as { code?: string }).code;
|
|
||||||
if (maybeCode === "PGRST116") {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
"No se encontró la estructura de la asignatura.",
|
|
||||||
"NOT_FOUND",
|
|
||||||
{ table: "estructuras_asignatura", id: resolved.estructura_id },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new HttpError(
|
|
||||||
500,
|
|
||||||
"No se pudo obtener la estructura de la asignatura.",
|
|
||||||
"SUPABASE_QUERY_FAILED",
|
|
||||||
estructuraError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt =
|
|
||||||
"Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.";
|
|
||||||
|
|
||||||
const userPrompt =
|
|
||||||
`Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` +
|
|
||||||
`- Nombre de la asignatura: ${resolved.nombre}\n` +
|
|
||||||
`- Código (clave de la asignatura): ${
|
|
||||||
resolved.codigo ?? "(no especificado)"
|
|
||||||
}\n` +
|
|
||||||
`- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` +
|
|
||||||
`- Número de créditos: ${resolved.creditos}\n` +
|
|
||||||
`- Horas académicas: ${
|
|
||||||
resolved.horas_academicas ?? "(no especificado)"
|
|
||||||
}\n` +
|
|
||||||
`- Horas independientes: ${
|
|
||||||
resolved.horas_independientes ?? "(no especificado)"
|
|
||||||
}\n` +
|
|
||||||
`- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${
|
|
||||||
payload.descripcionEnfoqueAcademico ?? "(ninguna)"
|
|
||||||
}\n\n` +
|
|
||||||
`REGLA ESTRICTA MATEMÁTICA:\n` +
|
|
||||||
`Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${
|
|
||||||
resolved.horas_academicas ?? 0
|
|
||||||
}). No te pases ni te falten horas.`;
|
|
||||||
|
|
||||||
const schemaDef: Record<string, unknown> =
|
|
||||||
typeof estructura?.definicion === "object" &&
|
|
||||||
estructura?.definicion !== null
|
|
||||||
? (estructura.definicion as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const schemaForAI = withColumnDefsAndRefs(schemaDef);
|
|
||||||
|
|
||||||
const aiStructuredPayload: StructuredResponseOptions = {
|
|
||||||
model: AI_GENERATE_SUBJECT_UPDATE_MODELO,
|
|
||||||
input: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content: userPrompt },
|
|
||||||
],
|
|
||||||
text: {
|
|
||||||
format: {
|
|
||||||
type: "json_schema",
|
|
||||||
name: "asignatura_contenido_tematico",
|
|
||||||
schema: schemaForAI,
|
|
||||||
strict: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const svc = OpenAIService.fromEnv();
|
|
||||||
if (!(svc instanceof OpenAIService)) {
|
|
||||||
throw new HttpError(
|
|
||||||
500,
|
|
||||||
"Configuración del servidor incompleta.",
|
|
||||||
"OPENAI_MISCONFIGURED",
|
|
||||||
svc,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiResult = await svc.createStructuredResponse(aiStructuredPayload);
|
|
||||||
if (!aiResult.ok) {
|
|
||||||
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
|
||||||
throw new HttpError(
|
|
||||||
status,
|
|
||||||
"No se pudo generar la asignatura con IA.",
|
|
||||||
"OPENAI_REQUEST_FAILED",
|
|
||||||
aiResult,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let aiOutput = aiResult.output ?? null;
|
|
||||||
if (aiOutput == null && aiResult.outputText) {
|
|
||||||
try {
|
|
||||||
aiOutput = JSON.parse(aiResult.outputText);
|
|
||||||
} catch {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
"La respuesta de la IA no es JSON válido.",
|
|
||||||
"OPENAI_INVALID_JSON",
|
|
||||||
{ outputText: aiResult.outputText },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!aiOutput) {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
"La respuesta de la IA no contiene salida estructurada.",
|
|
||||||
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
|
||||||
{ outputText: aiResult.outputText ?? null },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { aiOutputJson, columnasGeneradas } =
|
|
||||||
splitAiOutputStringsAndColumns(aiOutput);
|
|
||||||
|
|
||||||
const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] =
|
|
||||||
{
|
|
||||||
datos: aiOutputJson,
|
|
||||||
tipo_origen: "IA",
|
|
||||||
estado: "borrador",
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const value of Object.values(columnasGeneradas)) {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const xColumn = (value as Record<string, unknown>)["x-column"];
|
|
||||||
const xDef = (value as Record<string, unknown>)["x-definicion"];
|
|
||||||
if (typeof xColumn !== "string" || !xColumn.length) continue;
|
|
||||||
(updatePatch as unknown as Record<string, unknown>)[xColumn] =
|
|
||||||
xDef as unknown as Json;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply only provided JSON fields (do not overwrite if missing)
|
|
||||||
if (payload.plan_estudio_id !== undefined) {
|
|
||||||
updatePatch.plan_estudio_id = payload.plan_estudio_id;
|
|
||||||
}
|
|
||||||
if (payload.estructura_id !== undefined) {
|
|
||||||
updatePatch.estructura_id = payload.estructura_id;
|
|
||||||
}
|
|
||||||
if (payload.nombre !== undefined) {
|
|
||||||
updatePatch.nombre = payload.nombre;
|
|
||||||
}
|
|
||||||
if (payload.codigo !== undefined) {
|
|
||||||
updatePatch.codigo = payload.codigo;
|
|
||||||
}
|
|
||||||
if (payload.tipo !== undefined) {
|
|
||||||
updatePatch.tipo = payload.tipo as Database["public"]["Tables"][
|
|
||||||
"asignaturas"
|
|
||||||
]["Update"]["tipo"];
|
|
||||||
}
|
|
||||||
if (payload.creditos !== undefined) {
|
|
||||||
updatePatch.creditos = payload.creditos;
|
|
||||||
}
|
|
||||||
if (payload.horas_academicas !== undefined) {
|
|
||||||
updatePatch.horas_academicas = payload.horas_academicas;
|
|
||||||
}
|
|
||||||
if (payload.horas_independientes !== undefined) {
|
|
||||||
updatePatch.horas_independientes = payload.horas_independientes;
|
|
||||||
}
|
|
||||||
if (payload.numero_ciclo !== undefined) {
|
|
||||||
updatePatch.numero_ciclo = payload.numero_ciclo;
|
|
||||||
}
|
|
||||||
if (payload.linea_plan_id !== undefined) {
|
|
||||||
updatePatch.linea_plan_id = payload.linea_plan_id;
|
|
||||||
}
|
|
||||||
if (payload.orden_celda !== undefined) {
|
|
||||||
updatePatch.orden_celda = payload.orden_celda;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePatch.meta_origen = {
|
|
||||||
generado_por: "ai_generate_subject_update_json",
|
|
||||||
ai: {
|
|
||||||
responseId: aiResult.responseId ?? null,
|
|
||||||
conversationId: aiResult.conversationId ?? null,
|
|
||||||
model: aiResult.model,
|
|
||||||
usage: aiResult.usage ?? null,
|
|
||||||
},
|
|
||||||
iaConfig: {
|
|
||||||
descripcionEnfoqueAcademico: payload.descripcionEnfoqueAcademico ??
|
|
||||||
null,
|
|
||||||
},
|
|
||||||
} as unknown as Json;
|
|
||||||
|
|
||||||
const { data: asignatura, error: asignaturaError } = await supabaseService
|
|
||||||
.from("asignaturas")
|
|
||||||
.update(updatePatch)
|
|
||||||
.eq("id", payload.id)
|
|
||||||
.select(
|
|
||||||
"id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,datos,creado_en,actualizado_en,numero_ciclo,linea_plan_id,orden_celda",
|
|
||||||
)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (asignaturaError) {
|
|
||||||
const maybeCode = (asignaturaError as { code?: string }).code;
|
|
||||||
if (maybeCode === "PGRST116") {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
"No se encontró la asignatura.",
|
|
||||||
"NOT_FOUND",
|
|
||||||
{ table: "asignaturas", id: payload.id },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new HttpError(
|
|
||||||
500,
|
|
||||||
"No se pudo actualizar la asignatura.",
|
|
||||||
"SUPABASE_UPDATE_FAILED",
|
|
||||||
asignaturaError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[${
|
|
||||||
new Date().toISOString()
|
|
||||||
}][${functionName}]: JSON update processed successfully`,
|
|
||||||
);
|
|
||||||
return sendSuccess(asignatura);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
const resolved = {
|
||||||
// Multipart create flow (existing)
|
id: (datosUpdate.id ?? asignaturaBase?.id ?? null) as string | null,
|
||||||
// -----------------------------
|
plan_estudio_id: datosUpdate.plan_estudio_id ??
|
||||||
const formData = await req.formData();
|
asignaturaBase?.plan_estudio_id,
|
||||||
const validation = parseAndValidate(formData);
|
estructura_id: datosUpdate.estructura_id ??
|
||||||
if (!validation.success) {
|
asignaturaBase?.estructura_id ?? null,
|
||||||
console.error(
|
nombre: datosUpdate.nombre ?? asignaturaBase?.nombre ?? null,
|
||||||
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
codigo: datosUpdate.codigo ?? asignaturaBase?.codigo ?? null,
|
||||||
validation.errors,
|
tipo: datosUpdate.tipo ?? asignaturaBase?.tipo ?? null,
|
||||||
);
|
creditos: datosUpdate.creditos ?? asignaturaBase?.creditos ?? null,
|
||||||
const message = validation.errors
|
horas_academicas: datosUpdate.horas_academicas ??
|
||||||
.map((e, i) => `${i + 1}. ${e}`)
|
asignaturaBase?.horas_academicas ?? null,
|
||||||
.join("\n");
|
horas_independientes: datosUpdate.horas_independientes ??
|
||||||
|
asignaturaBase?.horas_independientes ?? null,
|
||||||
|
numero_ciclo: datosUpdate.numero_ciclo ?? asignaturaBase?.numero_ciclo ??
|
||||||
|
null,
|
||||||
|
linea_plan_id: datosUpdate.linea_plan_id ??
|
||||||
|
asignaturaBase?.linea_plan_id ?? null,
|
||||||
|
orden_celda: datosUpdate.orden_celda ?? asignaturaBase?.orden_celda ??
|
||||||
|
null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!resolved.estructura_id) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
422,
|
422,
|
||||||
message,
|
"estructura_id es requerido.",
|
||||||
"VALIDATION_ERROR",
|
"VALIDATION_ERROR",
|
||||||
{ errors: validation.errors },
|
{ estructura_id: resolved.estructura_id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!resolved.nombre) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
"nombre es requerido.",
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
{ nombre: resolved.nombre },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolved.creditos == null) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
"creditos es requerido.",
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
{ creditos: resolved.creditos },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = validation.data;
|
// Crear/actualizar stub en estado 'generando'
|
||||||
|
let asignaturaId: string;
|
||||||
|
if (isUpdate) {
|
||||||
|
const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] =
|
||||||
|
{
|
||||||
|
estado: "generando",
|
||||||
|
tipo_origen: "IA",
|
||||||
|
plan_estudio_id: resolved.plan_estudio_id,
|
||||||
|
estructura_id: resolved.estructura_id,
|
||||||
|
nombre: resolved.nombre,
|
||||||
|
codigo: resolved.codigo,
|
||||||
|
creditos: resolved.creditos,
|
||||||
|
horas_academicas: resolved.horas_academicas,
|
||||||
|
horas_independientes: resolved.horas_independientes,
|
||||||
|
numero_ciclo: resolved.numero_ciclo,
|
||||||
|
linea_plan_id: resolved.linea_plan_id,
|
||||||
|
orden_celda: resolved.orden_celda,
|
||||||
|
tipo: resolved
|
||||||
|
.tipo as Database["public"]["Tables"]["asignaturas"]["Update"][
|
||||||
|
"tipo"
|
||||||
|
],
|
||||||
|
meta_origen: {
|
||||||
|
generado_por: "ai_generate_subject_unified",
|
||||||
|
iaConfig: {
|
||||||
|
descripcionEnfoqueAcademico:
|
||||||
|
iaConfig.descripcionEnfoqueAcademico ?? null,
|
||||||
|
instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ??
|
||||||
|
null,
|
||||||
|
archivosAdjuntos: iaConfig.archivosAdjuntos ?? [],
|
||||||
|
},
|
||||||
|
} as unknown as Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = await supabaseService
|
||||||
|
.from("asignaturas")
|
||||||
|
.update(updatePatch)
|
||||||
|
.eq("id", resolved.id as string)
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"No se pudo actualizar la asignatura (stub).",
|
||||||
|
"SUPABASE_UPDATE_FAILED",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
asignaturaId = data.id;
|
||||||
|
} else {
|
||||||
|
const insertPatch: Database["public"]["Tables"]["asignaturas"]["Insert"] =
|
||||||
|
{
|
||||||
|
plan_estudio_id: resolved.plan_estudio_id as string,
|
||||||
|
estructura_id: resolved.estructura_id as string,
|
||||||
|
nombre: resolved.nombre as string,
|
||||||
|
codigo: resolved.codigo ?? null,
|
||||||
|
tipo:
|
||||||
|
(resolved.tipo ?? undefined) as Database["public"]["Tables"][
|
||||||
|
"asignaturas"
|
||||||
|
]["Insert"]["tipo"],
|
||||||
|
creditos: resolved.creditos as number,
|
||||||
|
horas_academicas: resolved.horas_academicas ?? null,
|
||||||
|
horas_independientes: resolved.horas_independientes ?? null,
|
||||||
|
numero_ciclo: resolved.numero_ciclo ?? null,
|
||||||
|
linea_plan_id: resolved.linea_plan_id ?? null,
|
||||||
|
orden_celda: resolved.orden_celda ?? null,
|
||||||
|
estado: "generando",
|
||||||
|
tipo_origen: "IA",
|
||||||
|
meta_origen: {
|
||||||
|
generado_por: "ai_generate_subject_unified",
|
||||||
|
iaConfig: {
|
||||||
|
descripcionEnfoqueAcademico:
|
||||||
|
iaConfig.descripcionEnfoqueAcademico ?? null,
|
||||||
|
instruccionesAdicionalesIA: iaConfig.instruccionesAdicionalesIA ??
|
||||||
|
null,
|
||||||
|
archivosAdjuntos: iaConfig.archivosAdjuntos ?? [],
|
||||||
|
},
|
||||||
|
} as unknown as Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = await supabaseService
|
||||||
|
.from("asignaturas")
|
||||||
|
.insert(insertPatch)
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
const maybeCode = (error as { code?: string }).code;
|
||||||
|
const status = maybeCode ? 409 : 500;
|
||||||
|
throw new HttpError(
|
||||||
|
status,
|
||||||
|
"No se pudo crear la asignatura (stub).",
|
||||||
|
"SUPABASE_INSERT_FAILED",
|
||||||
|
{ ...error, code: maybeCode },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
asignaturaId = data.id;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: estructura, error: estructuraError } = await supabaseService
|
const { data: estructura, error: estructuraError } = await supabaseService
|
||||||
.from("estructuras_asignatura")
|
.from("estructuras_asignatura")
|
||||||
.select("id,nombre,definicion,version")
|
.select("id,nombre,definicion,version")
|
||||||
.eq("id", payload.datosBasicos.estructuraId)
|
.eq("id", resolved.estructura_id)
|
||||||
.single();
|
.single();
|
||||||
if (estructuraError) {
|
if (estructuraError) {
|
||||||
const maybeCode = (estructuraError as { code?: string }).code;
|
const maybeCode = (estructuraError as { code?: string }).code;
|
||||||
@@ -579,10 +468,7 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
404,
|
404,
|
||||||
"No se encontró la estructura de la asignatura.",
|
"No se encontró la estructura de la asignatura.",
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
{
|
{ table: "estructuras_asignatura", id: resolved.estructura_id },
|
||||||
table: "estructuras_asignatura",
|
|
||||||
id: payload.datosBasicos.estructuraId,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
@@ -594,35 +480,39 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt =
|
const systemPrompt =
|
||||||
`Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.
|
`Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.`;
|
||||||
# REGLAS DE FORMATO PARA CAMPOS DE TEXTO (STRING)
|
|
||||||
1. **Prioridad:** Estas reglas aplican por defecto. Si el prompt del usuario solicita explícitamente un formato distinto, esa instrucción tiene prioridad sobre estas reglas.
|
const archivosAdjuntosTexto = (iaConfig.archivosAdjuntos ?? []).length
|
||||||
2. **Estilo Visual:** Redacta el contenido exclusivamente para visualización en texto plano (estilo 'white-space: pre-wrap').
|
? `\n- Rutas de archivos adjuntos (referencia): ${
|
||||||
3. **Estructura Vertical:** Utiliza saltos de línea explícitos para romper líneas y doble salto de línea para separar párrafos.
|
(iaConfig.archivosAdjuntos ?? []).join(", ")
|
||||||
4. **Indentación Estricta:** Usa exactamente 2 espacios para la indentación jerárquica. No uses tabuladores.
|
}`
|
||||||
5. **Listas:** Utiliza un guion seguido de un espacio ("- ") para los elementos de lista.
|
: "";
|
||||||
6. **Prohibiciones:** No incluyas etiquetas HTML, sintaxis Markdown ni caracteres de escape literales visibles en el texto final. Asegúrate de que el JSON final contenga saltos de línea válidos ('\n') y no texto escapado.`;
|
|
||||||
|
|
||||||
const userPrompt =
|
const userPrompt =
|
||||||
`Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` +
|
`Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` +
|
||||||
`- Nombre de la asignatura: ${payload.datosBasicos.nombre}\n` +
|
`- Nombre de la asignatura: ${resolved.nombre}\n` +
|
||||||
`- Código (clave de la asignatura): ${
|
`- Código (clave de la asignatura): ${
|
||||||
payload.datosBasicos.codigo ?? "(no especificado)"
|
resolved.codigo ?? "(no especificado)"
|
||||||
}\n` +
|
}\n` +
|
||||||
`- Tipo: ${payload.datosBasicos.tipo ?? "(no especificado)"}\n` +
|
`- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` +
|
||||||
`- Número de créditos: ${payload.datosBasicos.creditos}\n` +
|
`- Número de créditos: ${resolved.creditos}\n` +
|
||||||
`- Horas académicas: ${
|
`- Horas académicas: ${
|
||||||
payload.datosBasicos.horasAcademicas ?? "(no especificado)"
|
resolved.horas_academicas ?? "(no especificado)"
|
||||||
}\n` +
|
}\n` +
|
||||||
`- Horas independientes: ${
|
`- Horas independientes: ${
|
||||||
payload.datosBasicos.horasIndependientes ?? "(no especificado)"
|
resolved.horas_independientes ?? "(no especificado)"
|
||||||
}\n` +
|
}\n` +
|
||||||
`- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${
|
`- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${
|
||||||
payload.iaConfig?.descripcionEnfoqueAcademico ?? "(ninguna)"
|
iaConfig.descripcionEnfoqueAcademico ?? "(ninguna)"
|
||||||
}\n` +
|
}\n` +
|
||||||
`- Instrucciones adicionales (sobre el formato de la respuesta generada): ${
|
`- Instrucciones adicionales (sobre el formato de la respuesta generada): ${
|
||||||
payload.iaConfig?.instruccionesAdicionalesIA ?? "(ninguna)"
|
iaConfig.instruccionesAdicionalesIA ?? "(ninguna)"
|
||||||
}`;
|
}` +
|
||||||
|
archivosAdjuntosTexto +
|
||||||
|
`\n\nREGLA ESTRICTA MATEMÁTICA:\n` +
|
||||||
|
`Si generas el 'contenido_tematico', la suma total de las 'horasEstimadas' de todos los temas en todas las unidades DEBE coincidir exactamente con el total de Horas académicas indicadas arriba (${
|
||||||
|
resolved.horas_academicas ?? 0
|
||||||
|
}). No te pases ni te falten horas.`;
|
||||||
|
|
||||||
const schemaDef: Record<string, unknown> =
|
const schemaDef: Record<string, unknown> =
|
||||||
typeof estructura?.definicion === "object" &&
|
typeof estructura?.definicion === "object" &&
|
||||||
@@ -633,7 +523,15 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
const schemaForAI = withColumnDefsAndRefs(schemaDef);
|
const schemaForAI = withColumnDefsAndRefs(schemaDef);
|
||||||
|
|
||||||
const aiStructuredPayload: StructuredResponseOptions = {
|
const aiStructuredPayload: StructuredResponseOptions = {
|
||||||
model: AI_GENERATE_SUBJECT_INSERT_MODELO,
|
model: isUpdate
|
||||||
|
? AI_GENERATE_SUBJECT_UPDATE_MODELO
|
||||||
|
: AI_GENERATE_SUBJECT_INSERT_MODELO,
|
||||||
|
background: true,
|
||||||
|
metadata: {
|
||||||
|
tabla: "asignaturas",
|
||||||
|
accion: isUpdate ? "actualizar" : "crear",
|
||||||
|
id: asignaturaId,
|
||||||
|
},
|
||||||
input: [
|
input: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
@@ -648,126 +546,27 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const svc = OpenAIService.fromEnv();
|
const aiResult = await svc.createStructuredResponse(aiStructuredPayload);
|
||||||
if (!(svc instanceof OpenAIService)) {
|
|
||||||
throw new HttpError(
|
|
||||||
500,
|
|
||||||
"Configuración del servidor incompleta.",
|
|
||||||
"OPENAI_MISCONFIGURED",
|
|
||||||
svc,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiResult = await svc.createStructuredResponse(
|
|
||||||
aiStructuredPayload,
|
|
||||||
payload.archivosAdjuntos,
|
|
||||||
);
|
|
||||||
if (!aiResult.ok) {
|
if (!aiResult.ok) {
|
||||||
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
status,
|
status,
|
||||||
"No se pudo generar la asignatura con IA.",
|
"No se pudo iniciar la generación de la asignatura con IA.",
|
||||||
"OPENAI_REQUEST_FAILED",
|
"OPENAI_REQUEST_FAILED",
|
||||||
aiResult,
|
aiResult,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let aiOutput = aiResult.output ?? null;
|
|
||||||
if (aiOutput == null && aiResult.outputText) {
|
|
||||||
try {
|
|
||||||
aiOutput = JSON.parse(aiResult.outputText);
|
|
||||||
} catch {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
"La respuesta de la IA no es JSON válido.",
|
|
||||||
"OPENAI_INVALID_JSON",
|
|
||||||
{ outputText: aiResult.outputText },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!aiOutput) {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
"La respuesta de la IA no contiene salida estructurada.",
|
|
||||||
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
|
||||||
{ outputText: aiResult.outputText ?? null },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { aiOutputJson, columnasGeneradas } = splitAiOutputStringsAndColumns(
|
|
||||||
aiOutput,
|
|
||||||
);
|
|
||||||
|
|
||||||
const asignaturaInsert:
|
|
||||||
Database["public"]["Tables"]["asignaturas"]["Insert"] = {
|
|
||||||
plan_estudio_id: payload.plan_estudio_id,
|
|
||||||
estructura_id: payload.datosBasicos.estructuraId,
|
|
||||||
nombre: payload.datosBasicos.nombre,
|
|
||||||
codigo: payload.datosBasicos.codigo ?? null,
|
|
||||||
creditos: payload.datosBasicos.creditos,
|
|
||||||
horas_academicas: payload.datosBasicos.horasAcademicas ?? null,
|
|
||||||
horas_independientes: payload.datosBasicos.horasIndependientes ?? null,
|
|
||||||
tipo: (payload.datosBasicos.tipo ?? undefined) as Database["public"][
|
|
||||||
"Tables"
|
|
||||||
]["asignaturas"]["Insert"]["tipo"],
|
|
||||||
tipo_origen: "IA",
|
|
||||||
datos: aiOutputJson,
|
|
||||||
meta_origen: {
|
|
||||||
generado_por: "ai_generate_subject",
|
|
||||||
ai: {
|
|
||||||
responseId: aiResult.responseId ?? null,
|
|
||||||
conversationId: aiResult.conversationId ?? null,
|
|
||||||
model: aiResult.model,
|
|
||||||
usage: aiResult.usage ?? null,
|
|
||||||
},
|
|
||||||
referencias: {
|
|
||||||
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
|
||||||
repositoriosReferencia: payload.iaConfig?.repositoriosReferencia ??
|
|
||||||
null,
|
|
||||||
},
|
|
||||||
iaConfig: {
|
|
||||||
descripcionEnfoqueAcademico:
|
|
||||||
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
|
||||||
instruccionesAdicionalesIA:
|
|
||||||
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
|
|
||||||
},
|
|
||||||
} as unknown as Json,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const value of Object.values(columnasGeneradas)) {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
||||||
const xColumn = (value as Record<string, unknown>)["x-column"];
|
|
||||||
const xDef = (value as Record<string, unknown>)["x-definicion"];
|
|
||||||
if (typeof xColumn !== "string" || !xColumn.length) continue;
|
|
||||||
(asignaturaInsert as unknown as Record<string, unknown>)[xColumn] =
|
|
||||||
xDef as unknown as Json;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: asignatura, error: asignaturaError } = await supabaseService
|
|
||||||
.from("asignaturas")
|
|
||||||
.insert(asignaturaInsert)
|
|
||||||
.select(
|
|
||||||
"id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,creado_en,actualizado_en",
|
|
||||||
)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (asignaturaError) {
|
|
||||||
const maybeCode = (asignaturaError as { code?: string }).code;
|
|
||||||
const status = maybeCode ? 409 : 500;
|
|
||||||
throw new HttpError(
|
|
||||||
status,
|
|
||||||
"No se pudo guardar la asignatura.",
|
|
||||||
"SUPABASE_INSERT_FAILED",
|
|
||||||
{ ...asignaturaError, code: maybeCode },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${
|
`[${
|
||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Request processed successfully`,
|
}][${functionName}]: Subject generation started in background`,
|
||||||
);
|
);
|
||||||
return sendSuccess(asignatura);
|
return sendSuccess({
|
||||||
|
id: asignaturaId,
|
||||||
|
estado: "generando",
|
||||||
|
openai: { responseId: aiResult.responseId ?? null },
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -801,86 +600,6 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const jsonFromString = <T extends z.ZodTypeAny>(schema: T) =>
|
|
||||||
z.string().transform((str, ctx) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(str);
|
|
||||||
} catch {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "El formato no es un JSON válido",
|
|
||||||
});
|
|
||||||
return z.NEVER;
|
|
||||||
}
|
|
||||||
}).pipe(schema);
|
|
||||||
|
|
||||||
const jsonFromStringOptional = <T extends z.ZodTypeAny>(schema: T) =>
|
|
||||||
z.string().optional().default("{}").transform((str, ctx) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(str);
|
|
||||||
} catch {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "El formato no es un JSON válido",
|
|
||||||
});
|
|
||||||
return z.NEVER;
|
|
||||||
}
|
|
||||||
}).pipe(schema);
|
|
||||||
|
|
||||||
const DatosBasicosSchema = z.object({
|
|
||||||
nombre: z.string().min(1, "El nombre es requerido"),
|
|
||||||
codigo: z.string().optional().nullable(),
|
|
||||||
tipo: z.union([z.string().min(1), z.null()]).optional().transform((v) =>
|
|
||||||
v ?? null
|
|
||||||
),
|
|
||||||
creditos: z.number({ invalid_type_error: "Créditos debe ser numérico" })
|
|
||||||
.positive("Créditos debe ser >= 0"),
|
|
||||||
horasAcademicas: z.number().int().nonnegative().optional().nullable(),
|
|
||||||
horasIndependientes: z.number().int().nonnegative().optional().nullable(),
|
|
||||||
estructuraId: z.string().uuid("estructuraId debe ser un UUID"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const IAConfigSchema = z.object({
|
|
||||||
descripcionEnfoqueAcademico: z.string().optional(),
|
|
||||||
instruccionesAdicionalesIA: z.string().optional(),
|
|
||||||
archivosReferencia: z.array(z.string().uuid()).optional(),
|
|
||||||
repositoriosReferencia: z.array(z.string().uuid()).optional(),
|
|
||||||
}).partial();
|
|
||||||
|
|
||||||
const SolicitudSchema = z.object({
|
|
||||||
plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"),
|
|
||||||
datosBasicos: jsonFromString(DatosBasicosSchema),
|
|
||||||
iaConfig: jsonFromStringOptional(IAConfigSchema),
|
|
||||||
archivosAdjuntos: z.array(z.instanceof(File)).optional().default([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
type EdgeAIGenerateSubjectInput = z.infer<typeof SolicitudSchema>;
|
|
||||||
|
|
||||||
function parseAndValidate(
|
|
||||||
formData: FormData,
|
|
||||||
): { success: true; data: EdgeAIGenerateSubjectInput } | {
|
|
||||||
success: false;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const rawInput = {
|
|
||||||
plan_estudio_id: formData.get("plan_estudio_id"),
|
|
||||||
datosBasicos: formData.get("datosBasicos"),
|
|
||||||
iaConfig: formData.get("iaConfig"),
|
|
||||||
archivosAdjuntos: formData.getAll("archivosAdjuntos"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = SolicitudSchema.safeParse(rawInput);
|
|
||||||
if (!result.success) {
|
|
||||||
const errors = result.error.errors.map((issue) => {
|
|
||||||
const path = issue.path.length ? `${issue.path.join(".")}: ` : "";
|
|
||||||
return `${path}${issue.message}`;
|
|
||||||
});
|
|
||||||
return { success: false, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: result.data };
|
|
||||||
}
|
|
||||||
|
|
||||||
const definicionesDeEstructurasDeColumnas = {
|
const definicionesDeEstructurasDeColumnas = {
|
||||||
contenido_tematico: {
|
contenido_tematico: {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
import { Tables } from "../_shared/database.types.ts";
|
import { Tables } from "../_shared/database.types.ts";
|
||||||
|
|
||||||
export interface UploadedFile {
|
|
||||||
id: string; // Necesario para React (key)
|
|
||||||
file: File; // La fuente de verdad (contiene name, size, type)
|
|
||||||
preview?: string; // Opcional: si fueran imágenes
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AIGenerateSubjectInput = {
|
export type AIGenerateSubjectInput = {
|
||||||
plan_estudio_id: Tables<"asignaturas">["plan_estudio_id"];
|
datosUpdate: {
|
||||||
datosBasicos: {
|
id?: Tables<"asignaturas">["id"]; // si viene, es update; si no, es insert
|
||||||
nombre: Tables<"asignaturas">["nombre"];
|
plan_estudio_id: Tables<"asignaturas">["plan_estudio_id"];
|
||||||
|
estructura_id?: Tables<"asignaturas">["estructura_id"] | null;
|
||||||
|
|
||||||
|
nombre?: Tables<"asignaturas">["nombre"];
|
||||||
codigo?: Tables<"asignaturas">["codigo"];
|
codigo?: Tables<"asignaturas">["codigo"];
|
||||||
tipo: Tables<"asignaturas">["tipo"] | null;
|
tipo?: Tables<"asignaturas">["tipo"] | null;
|
||||||
creditos: Tables<"asignaturas">["creditos"] | null;
|
creditos?: Tables<"asignaturas">["creditos"] | null;
|
||||||
horasAcademicas?: Tables<"asignaturas">["horas_academicas"] | null;
|
horas_academicas?: Tables<"asignaturas">["horas_academicas"] | null;
|
||||||
horasIndependientes?: Tables<"asignaturas">["horas_independientes"] | null;
|
horas_independientes?: Tables<"asignaturas">["horas_independientes"] | null;
|
||||||
estructuraId: Tables<"asignaturas">["estructura_id"] | null;
|
numero_ciclo?: Tables<"asignaturas">["numero_ciclo"] | null;
|
||||||
|
linea_plan_id?: Tables<"asignaturas">["linea_plan_id"] | null;
|
||||||
|
orden_celda?: Tables<"asignaturas">["orden_celda"] | null;
|
||||||
};
|
};
|
||||||
// clonInterno?: {
|
|
||||||
// facultadId?: string
|
|
||||||
// carreraId?: string
|
|
||||||
// planOrigenId?: string
|
|
||||||
// asignaturaOrigenId?: string | null
|
|
||||||
// }
|
|
||||||
// clonTradicional?: {
|
|
||||||
// archivoWordAsignaturaId: string | null
|
|
||||||
// archivosAdicionalesIds: Array<string>
|
|
||||||
// }
|
|
||||||
iaConfig?: {
|
iaConfig?: {
|
||||||
descripcionEnfoqueAcademico: string;
|
descripcionEnfoqueAcademico?: string;
|
||||||
instruccionesAdicionalesIA: string;
|
instruccionesAdicionalesIA?: string;
|
||||||
archivosReferencia: Array<string>;
|
archivosAdjuntos?: string[]; // rutas (no File)
|
||||||
repositoriosReferencia?: Array<string>;
|
|
||||||
};
|
};
|
||||||
archivosAdjuntos?: Array<UploadedFile>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Configuration for private npm package dependencies
|
||||||
|
# For more information on using private registries with Edge Functions, see:
|
||||||
|
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { OpenAI } from "openai";
|
||||||
|
import { supabase } from "../supabase.ts";
|
||||||
|
import type { Json } from "../../_shared/database.types.ts";
|
||||||
|
import { ResponseMetadata } from "../../_shared/utils.ts";
|
||||||
|
|
||||||
|
function extractOutputText(response: OpenAI.Responses.Response): string {
|
||||||
|
const direct = (response as unknown as { output_text?: unknown }).output_text;
|
||||||
|
if (typeof direct === "string") return direct;
|
||||||
|
|
||||||
|
const output = (response as unknown as { output?: unknown }).output;
|
||||||
|
if (!Array.isArray(output)) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return output
|
||||||
|
.filter((item) => (item as { type?: unknown })?.type === "message")
|
||||||
|
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
|
||||||
|
.filter((c) => (c as { type?: unknown })?.type === "output_text")
|
||||||
|
.map((c) => String((c as { text?: unknown })?.text ?? ""))
|
||||||
|
.join("");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitAiOutputStringsAndColumns(
|
||||||
|
aiOutput: unknown,
|
||||||
|
): { aiOutputJson: Json; columnasGeneradas: Record<string, unknown> } {
|
||||||
|
if (!aiOutput || typeof aiOutput !== "object" || Array.isArray(aiOutput)) {
|
||||||
|
return { aiOutputJson: aiOutput as unknown as Json, columnasGeneradas: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = aiOutput as Record<string, unknown>;
|
||||||
|
const stringsOnly: Record<string, string> = {};
|
||||||
|
const columnasGeneradas: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
stringsOnly[key] = value;
|
||||||
|
} else {
|
||||||
|
columnasGeneradas[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { aiOutputJson: stringsOnly as unknown as Json, columnasGeneradas };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function marcarFalloAsignatura(
|
||||||
|
asignaturaId: string,
|
||||||
|
reason: string,
|
||||||
|
extra?: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { data: existing, error: existingError } = await supabase
|
||||||
|
.from("asignaturas")
|
||||||
|
.select("meta_origen")
|
||||||
|
.eq("id", asignaturaId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (existingError) {
|
||||||
|
console.warn("No se pudo leer meta_origen para marcar fallo", {
|
||||||
|
asignaturaId,
|
||||||
|
existingError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMeta = (existing?.meta_origen &&
|
||||||
|
typeof existing.meta_origen === "object" &&
|
||||||
|
!Array.isArray(existing.meta_origen))
|
||||||
|
? (existing.meta_origen as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const nextMeta: Record<string, unknown> = {
|
||||||
|
...baseMeta,
|
||||||
|
ai_error: {
|
||||||
|
reason,
|
||||||
|
extra: extra ?? null,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("asignaturas")
|
||||||
|
.update({ estado: "borrador", meta_origen: nextMeta as unknown as Json })
|
||||||
|
.eq("id", asignaturaId);
|
||||||
|
if (error) {
|
||||||
|
console.warn("No se pudo marcar fallo en asignatura", {
|
||||||
|
asignaturaId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Fallo inesperado marcando fallo en asignatura", {
|
||||||
|
asignaturaId,
|
||||||
|
e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCrearAsignaturaResponse(
|
||||||
|
response: OpenAI.Responses.Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const metadata = response.metadata as ResponseMetadata | null;
|
||||||
|
const asignaturaId = metadata?.id;
|
||||||
|
if (!asignaturaId) {
|
||||||
|
console.warn("No se recibió metadata.id para actualizar la asignatura");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputText = extractOutputText(response);
|
||||||
|
if (!outputText) {
|
||||||
|
console.warn("La respuesta no contiene output_text");
|
||||||
|
await marcarFalloAsignatura(asignaturaId, "MISSING_OUTPUT_TEXT", {
|
||||||
|
responseId: response.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let aiOutput: unknown;
|
||||||
|
try {
|
||||||
|
aiOutput = JSON.parse(outputText);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("No se pudo parsear JSON de la respuesta", e);
|
||||||
|
await marcarFalloAsignatura(asignaturaId, "INVALID_JSON", {
|
||||||
|
responseId: response.id,
|
||||||
|
outputText,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { aiOutputJson, columnasGeneradas } = splitAiOutputStringsAndColumns(
|
||||||
|
aiOutput,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: existing, error: existingError } = await supabase
|
||||||
|
.from("asignaturas")
|
||||||
|
.select("meta_origen")
|
||||||
|
.eq("id", asignaturaId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existingError) {
|
||||||
|
console.warn("No se pudo leer meta_origen existente", {
|
||||||
|
asignaturaId,
|
||||||
|
existingError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMeta = (existing?.meta_origen &&
|
||||||
|
typeof existing.meta_origen === "object" &&
|
||||||
|
!Array.isArray(existing.meta_origen))
|
||||||
|
? (existing.meta_origen as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const nextMeta: Record<string, unknown> = {
|
||||||
|
...baseMeta,
|
||||||
|
ai: {
|
||||||
|
...(typeof baseMeta.ai === "object" && baseMeta.ai &&
|
||||||
|
!Array.isArray(baseMeta.ai)
|
||||||
|
? (baseMeta.ai as Record<string, unknown>)
|
||||||
|
: {}),
|
||||||
|
responseId: response.id,
|
||||||
|
model: response.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePatch: Record<string, unknown> = {
|
||||||
|
datos: aiOutputJson,
|
||||||
|
estado: "borrador",
|
||||||
|
tipo_origen: "IA",
|
||||||
|
meta_origen: nextMeta as unknown as Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const value of Object.values(columnasGeneradas)) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const xColumn = (value as Record<string, unknown>)["x-column"];
|
||||||
|
const xDef = (value as Record<string, unknown>)["x-definicion"];
|
||||||
|
if (typeof xColumn !== "string" || !xColumn.length) continue;
|
||||||
|
updatePatch[xColumn] = xDef as unknown as Json;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("asignaturas")
|
||||||
|
.update(
|
||||||
|
updatePatch as unknown as {
|
||||||
|
[k: string]: unknown;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.eq("id", asignaturaId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.warn("No se pudo actualizar asignatura con datos", {
|
||||||
|
asignaturaId,
|
||||||
|
updateError,
|
||||||
|
});
|
||||||
|
await marcarFalloAsignatura(asignaturaId, "SUPABASE_UPDATE_FAILED", {
|
||||||
|
updateError,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Fallo inesperado procesando asignatura", {
|
||||||
|
asignaturaId,
|
||||||
|
e,
|
||||||
|
});
|
||||||
|
await marcarFalloAsignatura(asignaturaId, "UNEXPECTED", { e });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { OpenAI } from "openai";
|
||||||
|
import { ResponseMetadata } from "../../_shared/utils.ts";
|
||||||
|
import { handleCrearAsignaturaResponse } from "./crear.ts";
|
||||||
|
|
||||||
|
export async function handleAsignaturasResponse(
|
||||||
|
response: OpenAI.Responses.Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const metadata = response.metadata as ResponseMetadata | null;
|
||||||
|
const accion = metadata?.accion;
|
||||||
|
|
||||||
|
if (!accion) {
|
||||||
|
console.warn("No se recibió metadata.accion para asignaturas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (accion) {
|
||||||
|
case "crear":
|
||||||
|
case "actualizar":
|
||||||
|
await handleCrearAsignaturaResponse(response);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
console.warn("Acción no reconocida para asignaturas:", accion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
|
||||||
|
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts",
|
||||||
|
"express": "npm:express@5.2.1",
|
||||||
|
"openai": "npm:openai@6.16.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Follow this setup guide to integrate the Deno language server with your editor:
|
||||||
|
// https://deno.land/manual/getting_started/setup_your_environment
|
||||||
|
// This enables autocomplete, go to definition, etc.
|
||||||
|
|
||||||
|
// Setup type definitions for built-in Supabase Runtime APIs
|
||||||
|
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import { ResponseMetadata } from "../_shared/utils.ts";
|
||||||
|
import { handlePlanesEstudioResponse } from "./planes_estudio/index.ts";
|
||||||
|
import { handleAsignaturasResponse } from "./asignaturas/index.ts";
|
||||||
|
|
||||||
|
console.log("Starting OpenAI webhook responses function");
|
||||||
|
const client = new OpenAI({
|
||||||
|
webhookSecret: Deno.env.get("OPENAI_WEBHOOK_SECRET"),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleCompletedResponse(
|
||||||
|
event: OpenAI.Webhooks.ResponseCompletedWebhookEvent,
|
||||||
|
) {
|
||||||
|
const response_id = event.data.id;
|
||||||
|
const response = await client.responses.retrieve(response_id);
|
||||||
|
const metadata = response.metadata as ResponseMetadata | null;
|
||||||
|
|
||||||
|
if (!metadata || !metadata.tabla) {
|
||||||
|
console.warn("No se recibió metadata o tabla en la respuesta");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (metadata.tabla) {
|
||||||
|
case "planes_estudio":
|
||||||
|
await handlePlanesEstudioResponse(response);
|
||||||
|
break;
|
||||||
|
case "asignaturas":
|
||||||
|
await handleAsignaturasResponse(response);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("Tabla no reconocida:", metadata.tabla);
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = (response as unknown as { output_text?: unknown }).output_text;
|
||||||
|
if (typeof direct === "string" && direct.length) {
|
||||||
|
console.log("Response output:", direct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = (response as unknown as { output?: unknown }).output;
|
||||||
|
if (!Array.isArray(output)) {
|
||||||
|
console.log("Response output: (no output)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputText = output
|
||||||
|
.filter((item) => (item as { type?: unknown })?.type === "message")
|
||||||
|
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
|
||||||
|
.filter((c) => (c as { type?: unknown })?.type === "output_text")
|
||||||
|
.map((c) => String((c as { text?: unknown })?.text ?? ""))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
console.log("Response output:", outputText);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const functionName = url.pathname.split("/").pop();
|
||||||
|
console.log(
|
||||||
|
`[${new Date().toISOString()}][${functionName}]: Request received`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Opcional: Solo permitir POST
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// OpenAI requiere el body como texto crudo (string) para verificar la firma criptográfica
|
||||||
|
const payload = await req.text();
|
||||||
|
|
||||||
|
// Validar firma y parsear el evento. Deno maneja req.headers compatiblemente
|
||||||
|
const event = await client.webhooks.unwrap(payload, req.headers);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "response.completed": {
|
||||||
|
EdgeRuntime.waitUntil(handleCompletedResponse(event));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "response.cancelled": {
|
||||||
|
console.warn("Response cancelled:", event.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "response.failed": {
|
||||||
|
console.error("Response failed:", event.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "response.incomplete": {
|
||||||
|
console.warn("Response incomplete:", event.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`Unhandled event type: ${event.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${
|
||||||
|
new Date().toISOString()
|
||||||
|
}][${functionName}]: Request processed successfully`,
|
||||||
|
);
|
||||||
|
return new Response("OK", { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpenAI.InvalidWebhookSignatureError) {
|
||||||
|
console.error("Invalid signature:", error.message);
|
||||||
|
return new Response("Invalid signature", { status: 400 });
|
||||||
|
} else {
|
||||||
|
console.error("Internal Error:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { OpenAI } from "openai";
|
||||||
|
import { supabase } from "../supabase.ts";
|
||||||
|
import type { Json } from "../../_shared/database.types.ts";
|
||||||
|
import { ResponseMetadata } from "../../_shared/utils.ts";
|
||||||
|
|
||||||
|
async function getEstadoId(clave: string): Promise<string | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("estados_plan")
|
||||||
|
.select("id")
|
||||||
|
.eq("clave", clave)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) {
|
||||||
|
console.warn("No se pudo obtener estado:", clave, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function marcarFallido(planId: string): Promise<void> {
|
||||||
|
const fallId = await getEstadoId("FALLDA");
|
||||||
|
if (!fallId) {
|
||||||
|
console.warn("No existe estado FALLDA; no se pudo marcar fallo", {
|
||||||
|
planId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("planes_estudio")
|
||||||
|
.update({ estado_actual_id: fallId })
|
||||||
|
.eq("id", planId);
|
||||||
|
if (error) {
|
||||||
|
console.warn("No se pudo marcar plan como FALLDA", { planId, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOutputText(response: OpenAI.Responses.Response): string {
|
||||||
|
const direct = (response as unknown as { output_text?: unknown }).output_text;
|
||||||
|
if (typeof direct === "string") return direct;
|
||||||
|
|
||||||
|
const output = (response as unknown as { output?: unknown }).output;
|
||||||
|
if (!Array.isArray(output)) return "";
|
||||||
|
|
||||||
|
// Fallback similar al usado en index.ts
|
||||||
|
try {
|
||||||
|
return output
|
||||||
|
.filter((item) => (item as { type?: unknown })?.type === "message")
|
||||||
|
.flatMap((item) => (item as { content?: unknown })?.content ?? [])
|
||||||
|
.filter((c) => (c as { type?: unknown })?.type === "output_text")
|
||||||
|
.map((c) => String((c as { text?: unknown })?.text ?? ""))
|
||||||
|
.join("");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCrearPlanEstudio(
|
||||||
|
response: OpenAI.Responses.Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const metadata = response.metadata as ResponseMetadata | null;
|
||||||
|
const planId = metadata?.id;
|
||||||
|
if (!planId) {
|
||||||
|
console.warn("No se recibió metadata.id para actualizar el plan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const borradorId = await getEstadoId("BORRADOR");
|
||||||
|
if (!borradorId) {
|
||||||
|
console.warn("No existe estado BORRADOR");
|
||||||
|
await marcarFallido(planId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputText = extractOutputText(response);
|
||||||
|
if (!outputText) {
|
||||||
|
console.warn("La respuesta no contiene output_text");
|
||||||
|
await marcarFallido(planId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let datos: Json;
|
||||||
|
try {
|
||||||
|
datos = JSON.parse(outputText) as Json;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("No se pudo parsear JSON de la respuesta", e);
|
||||||
|
await marcarFallido(planId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("planes_estudio")
|
||||||
|
.update({ datos, estado_actual_id: borradorId })
|
||||||
|
.eq("id", planId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.warn("No se pudo actualizar el plan con datos", {
|
||||||
|
planId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
await marcarFallido(planId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Fallo inesperado procesando plan", { planId, e });
|
||||||
|
await marcarFallido(planId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ResponseMetadata } from "../../_shared/utils.ts";
|
||||||
|
import type { OpenAI } from "openai";
|
||||||
|
import { handleCrearPlanEstudio } from "./crear.ts";
|
||||||
|
|
||||||
|
export async function handlePlanesEstudioResponse(
|
||||||
|
response: OpenAI.Responses.Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const metadata = response.metadata as ResponseMetadata | null;
|
||||||
|
if (!metadata || !metadata.accion) {
|
||||||
|
console.warn("No se recibió acción en la metadata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (metadata.accion) {
|
||||||
|
case "crear":
|
||||||
|
await handleCrearPlanEstudio(response);
|
||||||
|
return;
|
||||||
|
case "actualizar":
|
||||||
|
// Lógica para actualizar un plan de estudio
|
||||||
|
return;
|
||||||
|
case "eliminar":
|
||||||
|
// Lógica para eliminar un plan de estudio
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
console.warn("Acción no reconocida:", metadata.accion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "../_shared/database.types.ts";
|
||||||
|
|
||||||
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||||
|
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||||
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || (!SUPABASE_SERVICE_ROLE_KEY && !SUPABASE_ANON_KEY)) {
|
||||||
|
console.warn("Missing Supabase environment variables");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseKey = (SUPABASE_SERVICE_ROLE_KEY ?? SUPABASE_ANON_KEY) as string;
|
||||||
|
|
||||||
|
const supabase = createClient<Database>(
|
||||||
|
SUPABASE_URL as string,
|
||||||
|
supabaseKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
export { supabase };
|
||||||
@@ -141,7 +141,7 @@ Deno.test("ai-structured (multipart + file)", async () => {
|
|||||||
assert(typeof data.output?.resumen === "string");
|
assert(typeof data.output?.resumen === "string");
|
||||||
assert(data.output.resumen.length > 0);
|
assert(data.output.resumen.length > 0);
|
||||||
|
|
||||||
// opcional: valida que registró upload(s) a storage
|
// opcional: valida que registró upload(s) a OpenAI files
|
||||||
assert(Array.isArray(data.references?.uploadedToStorage));
|
assert(Array.isArray(data.references?.openaiFileIds));
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -160,6 +160,6 @@ Deno.test({
|
|||||||
assert(typeof data.output?.resumen === "string");
|
assert(typeof data.output?.resumen === "string");
|
||||||
assert(data.output.resumen.length > 0);
|
assert(data.output.resumen.length > 0);
|
||||||
|
|
||||||
// opcional: valida que registró upload(s) a storage
|
// opcional: valida que registró upload(s) a OpenAI files
|
||||||
assert(Array.isArray(data.references?.uploadedToStorage));
|
assert(Array.isArray(data.references?.openaiFileIds));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
alter table "public"."asignaturas" alter column "estado" drop default;
|
||||||
|
|
||||||
|
alter type "public"."estado_asignatura" rename to "estado_asignatura__old_version_to_be_dropped";
|
||||||
|
|
||||||
|
create type "public"."estado_asignatura" as enum ('borrador', 'revisada', 'aprobada', 'generando', 'fallida');
|
||||||
|
|
||||||
|
alter table "public"."asignaturas" alter column estado type "public"."estado_asignatura" using estado::text::"public"."estado_asignatura";
|
||||||
|
|
||||||
|
alter table "public"."asignaturas" alter column "estado" set default 'borrador'::public.estado_asignatura;
|
||||||
|
|
||||||
|
drop type "public"."estado_asignatura__old_version_to_be_dropped";
|
||||||
+1888
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user