Merge pull request 'Generación de asignaturas con sugerencias' (#22) from acad-ia-2/issue/89-sugerencias-asignaturas into main
Reviewed-on: AlexRG/genesis-2#22
This commit was merged in pull request #22.
This commit is contained in:
+1318
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"@david/dax": "jsr:@david/dax@^0.44.2",
|
"@david/dax": "jsr:@david/dax@^0.44.2",
|
||||||
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
|
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
|
||||||
"openai": "npm:openai@6.16.0",
|
"openai": "npm:openai@6.16.0",
|
||||||
|
"openai/helpers/zod": "npm:openai@6.16.0/helpers/zod",
|
||||||
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
|
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
|
||||||
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
|
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,15 @@
|
|||||||
"jsr:@std/io@0.225": "0.225.3",
|
"jsr:@std/io@0.225": "0.225.3",
|
||||||
"jsr:@std/path@1": "1.1.4",
|
"jsr:@std/path@1": "1.1.4",
|
||||||
"jsr:@std/path@^1.1.4": "1.1.4",
|
"jsr:@std/path@^1.1.4": "1.1.4",
|
||||||
|
"jsr:@supabase/functions-js@2": "2.95.3",
|
||||||
"jsr:@zod/zod@^4.3.5": "4.3.5",
|
"jsr:@zod/zod@^4.3.5": "4.3.5",
|
||||||
|
"npm:@supabase/supabase-js@2": "2.90.1",
|
||||||
"npm:@supabase/supabase-js@^2.90.1": "2.90.1",
|
"npm:@supabase/supabase-js@^2.90.1": "2.90.1",
|
||||||
"npm:@types/bun@^1.3.5": "1.3.5",
|
"npm:@types/bun@^1.3.5": "1.3.5",
|
||||||
"npm:deno@^2.6.4": "2.6.4",
|
"npm:deno@^2.6.4": "2.6.4",
|
||||||
"npm:openai@^6.16.0": "6.16.0",
|
"npm:openai@6.16.0": "6.16.0_zod@3.25.76",
|
||||||
|
"npm:openai@^4.52.5": "4.104.0_zod@3.25.76",
|
||||||
|
"npm:openai@^6.16.0": "6.16.0_zod@3.25.76",
|
||||||
"npm:supabase@^2.72.6": "2.72.6",
|
"npm:supabase@^2.72.6": "2.72.6",
|
||||||
"npm:zod@3": "3.25.76"
|
"npm:zod@3": "3.25.76"
|
||||||
},
|
},
|
||||||
@@ -87,6 +91,12 @@
|
|||||||
"jsr:@std/internal"
|
"jsr:@std/internal"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@supabase/functions-js@2.95.3": {
|
||||||
|
"integrity": "cedb60df283f2d9dabf39655a8829f2c99d173e5e11a867c46e6987c59a1d582",
|
||||||
|
"dependencies": [
|
||||||
|
"npm:openai@^4.52.5"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@zod/zod@4.3.5": {
|
"@zod/zod@4.3.5": {
|
||||||
"integrity": "3d14553f025d6e3d1a836b9c56e366bbce27d942301e8c7c1ac4c365df5f8085"
|
"integrity": "3d14553f025d6e3d1a836b9c56e366bbce27d942301e8c7c1ac4c365df5f8085"
|
||||||
}
|
}
|
||||||
@@ -178,8 +188,15 @@
|
|||||||
"bun-types"
|
"bun-types"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@types/node@25.0.8": {
|
"@types/node-fetch@2.6.13": {
|
||||||
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
|
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/node",
|
||||||
|
"form-data"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@types/node@18.19.130": {
|
||||||
|
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"undici-types"
|
"undici-types"
|
||||||
]
|
]
|
||||||
@@ -193,9 +210,24 @@
|
|||||||
"@types/node"
|
"@types/node"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"abort-controller@3.0.0": {
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"dependencies": [
|
||||||
|
"event-target-shim"
|
||||||
|
]
|
||||||
|
},
|
||||||
"agent-base@7.1.4": {
|
"agent-base@7.1.4": {
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
|
||||||
},
|
},
|
||||||
|
"agentkeepalive@4.6.0": {
|
||||||
|
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"humanize-ms"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"asynckit@0.4.0": {
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
|
},
|
||||||
"bin-links@6.0.0": {
|
"bin-links@6.0.0": {
|
||||||
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
|
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -212,12 +244,25 @@
|
|||||||
"@types/node"
|
"@types/node"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"call-bind-apply-helpers@1.0.2": {
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"es-errors",
|
||||||
|
"function-bind"
|
||||||
|
]
|
||||||
|
},
|
||||||
"chownr@3.0.0": {
|
"chownr@3.0.0": {
|
||||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
|
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
|
||||||
},
|
},
|
||||||
"cmd-shim@8.0.0": {
|
"cmd-shim@8.0.0": {
|
||||||
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="
|
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="
|
||||||
},
|
},
|
||||||
|
"combined-stream@1.0.8": {
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"dependencies": [
|
||||||
|
"delayed-stream"
|
||||||
|
]
|
||||||
|
},
|
||||||
"data-uri-to-buffer@4.0.1": {
|
"data-uri-to-buffer@4.0.1": {
|
||||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
|
||||||
},
|
},
|
||||||
@@ -227,6 +272,9 @@
|
|||||||
"ms"
|
"ms"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"delayed-stream@1.0.0": {
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
|
||||||
|
},
|
||||||
"deno@2.6.4": {
|
"deno@2.6.4": {
|
||||||
"integrity": "sha512-/nHUtrwoqUsjVTBWGqz3SjAbtBSfqCLb38YdkwstrO5qpQd3RC20dxsGYPPANy9RqMFJMfDyARL7RtOcUfatpw==",
|
"integrity": "sha512-/nHUtrwoqUsjVTBWGqz3SjAbtBSfqCLb38YdkwstrO5qpQd3RC20dxsGYPPANy9RqMFJMfDyARL7RtOcUfatpw==",
|
||||||
"optionalDependencies": [
|
"optionalDependencies": [
|
||||||
@@ -240,11 +288,63 @@
|
|||||||
"scripts": true,
|
"scripts": true,
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
|
"dunder-proto@1.0.1": {
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dependencies": [
|
||||||
|
"call-bind-apply-helpers",
|
||||||
|
"es-errors",
|
||||||
|
"gopd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"es-define-property@1.0.1": {
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
|
||||||
|
},
|
||||||
|
"es-errors@1.3.0": {
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
|
||||||
|
},
|
||||||
|
"es-object-atoms@1.1.1": {
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"dependencies": [
|
||||||
|
"es-errors"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"es-set-tostringtag@2.1.0": {
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dependencies": [
|
||||||
|
"es-errors",
|
||||||
|
"get-intrinsic",
|
||||||
|
"has-tostringtag",
|
||||||
|
"hasown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"event-target-shim@5.0.1": {
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
|
||||||
|
},
|
||||||
"fetch-blob@3.2.0": {
|
"fetch-blob@3.2.0": {
|
||||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"node-domexception",
|
"node-domexception",
|
||||||
"web-streams-polyfill"
|
"web-streams-polyfill@3.3.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"form-data-encoder@1.7.2": {
|
||||||
|
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
|
||||||
|
},
|
||||||
|
"form-data@4.0.5": {
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"dependencies": [
|
||||||
|
"asynckit",
|
||||||
|
"combined-stream",
|
||||||
|
"es-set-tostringtag",
|
||||||
|
"hasown",
|
||||||
|
"mime-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formdata-node@4.4.1": {
|
||||||
|
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"node-domexception",
|
||||||
|
"web-streams-polyfill@4.0.0-beta.3"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formdata-polyfill@4.0.10": {
|
"formdata-polyfill@4.0.10": {
|
||||||
@@ -253,6 +353,49 @@
|
|||||||
"fetch-blob"
|
"fetch-blob"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"function-bind@1.1.2": {
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
|
||||||
|
},
|
||||||
|
"get-intrinsic@1.3.0": {
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"call-bind-apply-helpers",
|
||||||
|
"es-define-property",
|
||||||
|
"es-errors",
|
||||||
|
"es-object-atoms",
|
||||||
|
"function-bind",
|
||||||
|
"get-proto",
|
||||||
|
"gopd",
|
||||||
|
"has-symbols",
|
||||||
|
"hasown",
|
||||||
|
"math-intrinsics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"get-proto@1.0.1": {
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dependencies": [
|
||||||
|
"dunder-proto",
|
||||||
|
"es-object-atoms"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gopd@1.2.0": {
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
|
||||||
|
},
|
||||||
|
"has-symbols@1.1.0": {
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
|
||||||
|
},
|
||||||
|
"has-tostringtag@1.0.2": {
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dependencies": [
|
||||||
|
"has-symbols"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hasown@2.0.2": {
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"function-bind"
|
||||||
|
]
|
||||||
|
},
|
||||||
"https-proxy-agent@7.0.6": {
|
"https-proxy-agent@7.0.6": {
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -260,12 +403,30 @@
|
|||||||
"debug"
|
"debug"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"humanize-ms@1.2.1": {
|
||||||
|
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"ms"
|
||||||
|
]
|
||||||
|
},
|
||||||
"iceberg-js@0.8.1": {
|
"iceberg-js@0.8.1": {
|
||||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
|
||||||
},
|
},
|
||||||
"imurmurhash@0.1.4": {
|
"imurmurhash@0.1.4": {
|
||||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
|
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
|
||||||
},
|
},
|
||||||
|
"math-intrinsics@1.1.0": {
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
|
||||||
|
},
|
||||||
|
"mime-db@1.52.0": {
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
||||||
|
},
|
||||||
|
"mime-types@2.1.35": {
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"dependencies": [
|
||||||
|
"mime-db"
|
||||||
|
]
|
||||||
|
},
|
||||||
"minipass@7.1.2": {
|
"minipass@7.1.2": {
|
||||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
|
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
|
||||||
},
|
},
|
||||||
@@ -282,6 +443,12 @@
|
|||||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
"deprecated": true
|
"deprecated": true
|
||||||
},
|
},
|
||||||
|
"node-fetch@2.7.0": {
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"dependencies": [
|
||||||
|
"whatwg-url"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node-fetch@3.3.2": {
|
"node-fetch@3.3.2": {
|
||||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -293,8 +460,31 @@
|
|||||||
"npm-normalize-package-bin@5.0.0": {
|
"npm-normalize-package-bin@5.0.0": {
|
||||||
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="
|
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="
|
||||||
},
|
},
|
||||||
"openai@6.16.0": {
|
"openai@4.104.0_zod@3.25.76": {
|
||||||
|
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/node",
|
||||||
|
"@types/node-fetch",
|
||||||
|
"abort-controller",
|
||||||
|
"agentkeepalive",
|
||||||
|
"form-data-encoder",
|
||||||
|
"formdata-node",
|
||||||
|
"node-fetch@2.7.0",
|
||||||
|
"zod"
|
||||||
|
],
|
||||||
|
"optionalPeers": [
|
||||||
|
"zod"
|
||||||
|
],
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
|
"openai@6.16.0_zod@3.25.76": {
|
||||||
"integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
|
"integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
|
||||||
|
"dependencies": [
|
||||||
|
"zod"
|
||||||
|
],
|
||||||
|
"optionalPeers": [
|
||||||
|
"zod"
|
||||||
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"proc-log@6.1.0": {
|
"proc-log@6.1.0": {
|
||||||
@@ -311,7 +501,7 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"bin-links",
|
"bin-links",
|
||||||
"https-proxy-agent",
|
"https-proxy-agent",
|
||||||
"node-fetch",
|
"node-fetch@3.3.2",
|
||||||
"tar"
|
"tar"
|
||||||
],
|
],
|
||||||
"scripts": true,
|
"scripts": true,
|
||||||
@@ -325,17 +515,34 @@
|
|||||||
"minipass",
|
"minipass",
|
||||||
"minizlib",
|
"minizlib",
|
||||||
"yallist"
|
"yallist"
|
||||||
]
|
],
|
||||||
|
"deprecated": true
|
||||||
|
},
|
||||||
|
"tr46@0.0.3": {
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||||
},
|
},
|
||||||
"tslib@2.8.1": {
|
"tslib@2.8.1": {
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||||
},
|
},
|
||||||
"undici-types@7.16.0": {
|
"undici-types@5.26.5": {
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
||||||
},
|
},
|
||||||
"web-streams-polyfill@3.3.3": {
|
"web-streams-polyfill@3.3.3": {
|
||||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
|
||||||
},
|
},
|
||||||
|
"web-streams-polyfill@4.0.0-beta.3": {
|
||||||
|
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="
|
||||||
|
},
|
||||||
|
"webidl-conversions@3.0.1": {
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||||
|
},
|
||||||
|
"whatwg-url@5.0.0": {
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"dependencies": [
|
||||||
|
"tr46",
|
||||||
|
"webidl-conversions"
|
||||||
|
]
|
||||||
|
},
|
||||||
"write-file-atomic@7.0.0": {
|
"write-file-atomic@7.0.0": {
|
||||||
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
|
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -353,6 +560,21 @@
|
|||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"remote": {
|
||||||
|
"https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
|
||||||
|
"https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e"
|
||||||
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@david/dax@~0.44.2",
|
"jsr:@david/dax@~0.44.2",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Ejecutar estos dos comandos para la generación de tipos de supabase:
|
||||||
|
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
dx supabase gen types typescript --local | Out-File -Encoding utf8 -FilePath "supabase\functions\_shared\database.types.ts"
|
||||||
@@ -37,3 +37,14 @@ entrypoint = "./functions/ai-generate-subject/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/ai-generate-subject/*.html" ]
|
# static_files = [ "./functions/ai-generate-subject/*.html" ]
|
||||||
|
|
||||||
|
[functions.generate-subject-suggestions]
|
||||||
|
enabled = true
|
||||||
|
verify_jwt = true
|
||||||
|
import_map = "./functions/generate-subject-suggestions/deno.json"
|
||||||
|
# Uncomment to specify a custom file path to the entrypoint.
|
||||||
|
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
|
||||||
|
entrypoint = "./functions/generate-subject-suggestions/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/generate-subject-suggestions/*.html" ]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
|
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts"
|
||||||
"openai": "npm:openai@6.16.0",
|
|
||||||
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
|
|
||||||
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,66 @@ import {
|
|||||||
type StructuredResponseOptions,
|
type StructuredResponseOptions,
|
||||||
} from "../_shared/openai-service.ts";
|
} from "../_shared/openai-service.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
||||||
|
id: z.string().uuid("id debe ser un UUID"),
|
||||||
|
|
||||||
|
// patch fields (all optional) — nombres coinciden con columnas DB
|
||||||
|
nombre: z.string().min(1).optional(),
|
||||||
|
codigo: z.union([z.string().min(1), z.null()]).optional(),
|
||||||
|
tipo: z.union([z.string().min(1), z.null()]).optional(),
|
||||||
|
creditos: z.number().positive().optional(),
|
||||||
|
horas_academicas: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
horas_independientes: z.number().int().nonnegative().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(),
|
||||||
|
orden_celda: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
|
||||||
|
// IA config (no instruccionesAdicionalesIA)
|
||||||
|
descripcionEnfoqueAcademico: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
type EdgeAIGenerateSubjectJsonUpdateInput = z.infer<typeof JsonUpdateSchema>;
|
||||||
|
|
||||||
|
function formatZodIssues(issues: z.ZodIssue[]): string {
|
||||||
|
return issues
|
||||||
|
.map((issue, i) => {
|
||||||
|
const path = issue.path.length ? issue.path.join(".") : "(root)";
|
||||||
|
return `${i + 1}. ${path}: ${issue.message}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
Deno.serve(async (req: Request): Promise<Response> => {
|
Deno.serve(async (req: Request): Promise<Response> => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const functionName = url.pathname.split("/").pop();
|
const functionName = url.pathname.split("/").pop();
|
||||||
@@ -53,7 +113,9 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
||||||
if (!contentType.startsWith("multipart/form-data")) {
|
const isMultipart = contentType.startsWith("multipart/form-data");
|
||||||
|
const isJson = contentType.includes("application/json");
|
||||||
|
if (!isMultipart && !isJson) {
|
||||||
console.error(
|
console.error(
|
||||||
`[${
|
`[${
|
||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
@@ -108,6 +170,299 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|||||||
SERVICE_ROLE_KEY,
|
SERVICE_ROLE_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// JSON update flow
|
||||||
|
// -----------------------------
|
||||||
|
if (isJson) {
|
||||||
|
let rawBody: unknown;
|
||||||
|
try {
|
||||||
|
rawBody = await req.json();
|
||||||
|
} catch (e) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
"Body JSON inválido.",
|
||||||
|
"INVALID_JSON",
|
||||||
|
{ cause: e },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = JsonUpdateSchema.safeParse(rawBody);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
formatZodIssues(parsedBody.error.issues),
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
parsedBody.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: EdgeAIGenerateSubjectJsonUpdateInput = parsedBody.data;
|
||||||
|
|
||||||
|
const { data: existingAsignatura, error: existingAsignaturaError } =
|
||||||
|
await supabaseService
|
||||||
|
.from("asignaturas")
|
||||||
|
.select(
|
||||||
|
"id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda",
|
||||||
|
)
|
||||||
|
.eq("id", payload.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingAsignaturaError) {
|
||||||
|
const maybeCode = (existingAsignaturaError 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 obtener la asignatura.",
|
||||||
|
"SUPABASE_QUERY_FAILED",
|
||||||
|
existingAsignaturaError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const schemaDef: Record<string, unknown> =
|
||||||
|
typeof estructura?.definicion === "object" &&
|
||||||
|
estructura?.definicion !== null
|
||||||
|
? (estructura.definicion as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const aiStructuredPayload: StructuredResponseOptions = {
|
||||||
|
model: "gpt-5-nano",
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userPrompt },
|
||||||
|
],
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
name: "asignatura_contenido_tematico",
|
||||||
|
schema: schemaDef,
|
||||||
|
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: Json = aiOutput as unknown as Json;
|
||||||
|
|
||||||
|
const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] =
|
||||||
|
{
|
||||||
|
datos: aiOutputJson,
|
||||||
|
tipo_origen: "IA",
|
||||||
|
estado: "borrador",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Multipart create flow (existing)
|
||||||
|
// -----------------------------
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const validation = parseAndValidate(formData);
|
const validation = parseAndValidate(formData);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
|
|||||||
@@ -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,9 @@
|
|||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"zod": "npm:zod@4.3.5",
|
||||||
|
"openai": "npm:openai@6.16.0",
|
||||||
|
"openai/helpers/zod": "npm:openai@6.16.0/helpers/zod",
|
||||||
|
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
|
||||||
|
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database, Tables } from "../_shared/database.types.ts";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodTextFormat } from "openai/helpers/zod";
|
||||||
|
import {
|
||||||
|
OpenAIService,
|
||||||
|
type StructuredResponseOptions,
|
||||||
|
} from "../_shared/openai-service.ts";
|
||||||
|
|
||||||
|
export type DataAsignaturaSugerida = {
|
||||||
|
nombre: Tables<"asignaturas">["nombre"];
|
||||||
|
codigo?: Tables<"asignaturas">["codigo"];
|
||||||
|
tipo: Tables<"asignaturas">["tipo"] | null;
|
||||||
|
creditos: Tables<"asignaturas">["creditos"] | null;
|
||||||
|
horasAcademicas?: number | null;
|
||||||
|
horasIndependientes?: number | null;
|
||||||
|
descripcion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AsignaturaSugeridaItemSchema: z.ZodType<DataAsignaturaSugerida> = z
|
||||||
|
.object({
|
||||||
|
nombre: z.string().describe("Nombre de la asignatura a crear"),
|
||||||
|
codigo: z.string().optional().nullable().describe(
|
||||||
|
"Código o clave de la asignatura. Un string único que la identifique, como 'MAT101' o 'FIS202'. Opcional, pero recomendado para evitar confusiones.",
|
||||||
|
),
|
||||||
|
tipo: z.enum(["TRONCAL", "OBLIGATORIA", "OPTATIVA", "OTRA"]).nullable(),
|
||||||
|
creditos: z.number().nullable(),
|
||||||
|
horasAcademicas: z.number().optional().nullable(),
|
||||||
|
horasIndependientes: z.number().optional().nullable(),
|
||||||
|
descripcion: z.string().max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RequestSchema = z.object({
|
||||||
|
plan_estudio_id: z.string().uuid(),
|
||||||
|
// numero_de_ciclo: z.number().int().positive(), // ya no se usa
|
||||||
|
enfoque: z.string().trim().min(1).optional(),
|
||||||
|
cantidad_de_sugerencias: z.number().int().positive().max(15),
|
||||||
|
sugerencias_conservadas: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
nombre: z.string().trim().min(1),
|
||||||
|
descripcion: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeName(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{Diacritic}/gu, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZodIssues(issues: z.ZodIssue[]): string {
|
||||||
|
return issues
|
||||||
|
.map((issue, i) => {
|
||||||
|
const path = issue.path.length ? issue.path.join(".") : "(root)";
|
||||||
|
return `${i + 1}. ${path}: ${issue.message}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request): Promise<Response> => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const functionName = url.pathname.split("/").pop();
|
||||||
|
console.log(
|
||||||
|
`[${new Date().toISOString()}][${functionName}]: Request received`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, { status: 204, headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const method = req.method;
|
||||||
|
if (method !== "POST") {
|
||||||
|
throw new HttpError(
|
||||||
|
405,
|
||||||
|
"Método no permitido.",
|
||||||
|
"METHOD_NOT_ALLOWED",
|
||||||
|
{ method },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaderRaw = req.headers.get("Authorization") ??
|
||||||
|
req.headers.get("authorization");
|
||||||
|
if (!authHeaderRaw) {
|
||||||
|
throw new HttpError(
|
||||||
|
401,
|
||||||
|
"No autorizado.",
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
{ reason: "missing_authorization_header" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
throw new HttpError(
|
||||||
|
415,
|
||||||
|
"Content-Type no soportado.",
|
||||||
|
"UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
{ contentType },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawBody: unknown;
|
||||||
|
try {
|
||||||
|
rawBody = await req.json();
|
||||||
|
} catch (e) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
"Body JSON inválido.",
|
||||||
|
"INVALID_JSON",
|
||||||
|
{ cause: e },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = RequestSchema.safeParse(rawBody);
|
||||||
|
if (!validated.success) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
formatZodIssues(validated.error.issues),
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
validated.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
plan_estudio_id,
|
||||||
|
// numero_de_ciclo,
|
||||||
|
enfoque,
|
||||||
|
cantidad_de_sugerencias,
|
||||||
|
sugerencias_conservadas,
|
||||||
|
} = validated.data;
|
||||||
|
|
||||||
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||||
|
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"Configuración del servidor incompleta.",
|
||||||
|
"MISSING_ENV",
|
||||||
|
{
|
||||||
|
missing: [
|
||||||
|
!SUPABASE_URL ? "SUPABASE_URL" : null,
|
||||||
|
!SERVICE_ROLE_KEY ? "SUPABASE_SERVICE_ROLE_KEY" : null,
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseService = createClient<Database>(
|
||||||
|
SUPABASE_URL,
|
||||||
|
SERVICE_ROLE_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: plan, error: planError } = await supabaseService
|
||||||
|
.from("planes_estudio")
|
||||||
|
.select("id,nombre,nivel,tipo_ciclo,numero_ciclos,datos")
|
||||||
|
.eq("id", plan_estudio_id)
|
||||||
|
.single();
|
||||||
|
if (planError) {
|
||||||
|
const maybeCode = (planError as { code?: string }).code;
|
||||||
|
if (maybeCode === "PGRST116") {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
"No se encontró el plan de estudio.",
|
||||||
|
"NOT_FOUND",
|
||||||
|
{ table: "planes_estudio", id: plan_estudio_id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"No se pudo obtener el plan de estudio.",
|
||||||
|
"SUPABASE_QUERY_FAILED",
|
||||||
|
planError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: asignaturas, error: asignaturasError } = await supabaseService
|
||||||
|
.from("asignaturas")
|
||||||
|
.select("id,nombre,numero_ciclo,codigo,tipo,creditos")
|
||||||
|
.eq("plan_estudio_id", plan_estudio_id);
|
||||||
|
if (asignaturasError) {
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"No se pudieron obtener las asignaturas del plan de estudio.",
|
||||||
|
"SUPABASE_QUERY_FAILED",
|
||||||
|
asignaturasError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNames = new Set(
|
||||||
|
(asignaturas ?? [])
|
||||||
|
.map((a) => normalizeName(a.nombre))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const conservedNames = new Set(
|
||||||
|
(sugerencias_conservadas ?? [])
|
||||||
|
.map((s) => normalizeName(s.nombre))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const forbiddenNames = Array.from(
|
||||||
|
new Set([...existingNames, ...conservedNames]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const asignaturasResumen = (asignaturas ?? [])
|
||||||
|
.map((a) => {
|
||||||
|
const ciclo = a.numero_ciclo == null ? "(sin ciclo)" : a.numero_ciclo;
|
||||||
|
const codigo = a.codigo ? ` - ${a.codigo}` : "";
|
||||||
|
return `- [ciclo ${ciclo}] ${a.nombre}${codigo}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const sugerenciasConservadasResumen = (sugerencias_conservadas ?? [])
|
||||||
|
.map((s) => `- ${s.nombre}: ${s.descripcion}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const systemPrompt =
|
||||||
|
"Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el esquema proporcionado.";
|
||||||
|
|
||||||
|
const userPrompt =
|
||||||
|
`Necesito sugerencias NUEVAS de asignaturas para un plan de estudios.\n\n` +
|
||||||
|
`Plan de estudio:\n` +
|
||||||
|
`- id: ${plan.id}\n` +
|
||||||
|
`- nombre: ${plan.nombre}\n` +
|
||||||
|
`- nivel: ${plan.nivel}\n` +
|
||||||
|
`- tipo_ciclo: ${plan.tipo_ciclo}\n` +
|
||||||
|
`- numero_ciclos: ${plan.numero_ciclos}\n\n` +
|
||||||
|
`Datos del plan (JSON):\n${JSON.stringify(plan.datos)}\n\n` +
|
||||||
|
`Asignaturas existentes en el plan (NO repetir):\n${
|
||||||
|
asignaturasResumen || "(ninguna)"
|
||||||
|
}\n\n` +
|
||||||
|
`Sugerencias conservadas por el usuario (NO repetir):\n${
|
||||||
|
sugerenciasConservadasResumen || "(ninguna)"
|
||||||
|
}\n\n` +
|
||||||
|
`Enfoque (opcional): ${enfoque ?? "(ninguno)"}\n\n` +
|
||||||
|
`Requisitos estrictos:\n` +
|
||||||
|
`1) Genera EXACTAMENTE ${cantidad_de_sugerencias} sugerencias.\n` +
|
||||||
|
`2) No repitas nombres que ya existan en el plan ni los nombres de las sugerencias conservadas.\n` +
|
||||||
|
`3) Tampoco repitas nombres entre tus nuevas sugerencias.\n` +
|
||||||
|
`4) Evita nombres demasiado similares (diferencias solo por mayúsculas, tildes, signos o palabras triviales).\n` +
|
||||||
|
`5) Cada sugerencia debe incluir un nombre y una descripción clara y útil (sin pasarse del límite de 200 caracteres).\n\n` +
|
||||||
|
`Lista de nombres prohibidos (normalizados):\n` +
|
||||||
|
forbiddenNames.map((n) => `- ${n}`).join("\n");
|
||||||
|
|
||||||
|
const AsignaturaSugeridaSchema = z
|
||||||
|
.object({
|
||||||
|
sugerencias: z
|
||||||
|
.array(AsignaturaSugeridaItemSchema)
|
||||||
|
.length(cantidad_de_sugerencias)
|
||||||
|
.describe(
|
||||||
|
`Arreglo de ${cantidad_de_sugerencias} sugerencias de asignatura`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.describe(
|
||||||
|
`Respuesta estructurada con ${cantidad_de_sugerencias} sugerencias de asignatura`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = OpenAIService.fromEnv();
|
||||||
|
if (!(svc instanceof OpenAIService)) {
|
||||||
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"Configuración del servidor incompleta.",
|
||||||
|
"OPENAI_MISCONFIGURED",
|
||||||
|
svc,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: StructuredResponseOptions = {
|
||||||
|
model: "gpt-5-mini",
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "input_text",
|
||||||
|
text: userPrompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
text: {
|
||||||
|
format: zodTextFormat(
|
||||||
|
AsignaturaSugeridaSchema,
|
||||||
|
"estructura_asignaturas",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiResult = await svc.createStructuredResponse<
|
||||||
|
typeof AsignaturaSugeridaSchema._output
|
||||||
|
>(
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
if (!aiResult.ok) {
|
||||||
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
||||||
|
throw new HttpError(
|
||||||
|
status,
|
||||||
|
"No se pudieron generar sugerencias con IA.",
|
||||||
|
"OPENAI_REQUEST_FAILED",
|
||||||
|
aiResult,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = aiResult.output ?? null;
|
||||||
|
if (output == null && aiResult.outputText) {
|
||||||
|
try {
|
||||||
|
output = 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 (output == null) {
|
||||||
|
throw new HttpError(
|
||||||
|
502,
|
||||||
|
"La respuesta de la IA no contiene salida estructurada.",
|
||||||
|
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
||||||
|
{ outputText: aiResult.outputText ?? null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = AsignaturaSugeridaSchema.safeParse(output);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new HttpError(
|
||||||
|
502,
|
||||||
|
"La salida estructurada no coincide con el esquema esperado.",
|
||||||
|
"OPENAI_SCHEMA_MISMATCH",
|
||||||
|
parsed.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${
|
||||||
|
new Date().toISOString()
|
||||||
|
}][${functionName}]: Request processed successfully`,
|
||||||
|
);
|
||||||
|
return sendSuccess(parsed.data.sugerencias);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
console.error(
|
||||||
|
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
|
||||||
|
{
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
internalDetails: error.internalDetails || "N/A",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return sendError(error.status, error.message, error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unexpectedError = error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error));
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`[${
|
||||||
|
new Date().toISOString()
|
||||||
|
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
|
||||||
|
unexpectedError.stack || unexpectedError.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sendError(
|
||||||
|
500,
|
||||||
|
"Ocurrió un error inesperado en el servidor.",
|
||||||
|
"INTERNAL_SERVER_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// ...existing code...
|
||||||
|
import type { DataAsignaturaSugerida } from "./index.ts";
|
||||||
|
|
||||||
|
export const sugerencias: DataAsignaturaSugerida[][] = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
nombre: "Transferencia de Calor",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 5,
|
||||||
|
descripcion:
|
||||||
|
"Conducción, convección y radiación; diseño de aletas e intercambiadores; análisis transitorio y numérico en equipos térmicos.",
|
||||||
|
horasAcademicas: 43,
|
||||||
|
horasIndependientes: 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Mecánica de Fluidos II",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 4.5,
|
||||||
|
descripcion:
|
||||||
|
"Flujo turbulento, capas límite, turbomáquinas y redes de tuberías; medición y control de caudal; CFD básico para aplicaciones mecánicas.",
|
||||||
|
horasAcademicas: 43,
|
||||||
|
horasIndependientes: 34,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Diseño de Elementos de Máquinas I",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 5,
|
||||||
|
descripcion:
|
||||||
|
"Selección y dimensionamiento de ejes, tornillos, resortes, engranajes y uniones; criterios de falla estática y a fatiga; normas y materiales.",
|
||||||
|
horasAcademicas: 43,
|
||||||
|
horasIndependientes: 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Dinámica de Máquinas",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 4.5,
|
||||||
|
descripcion:
|
||||||
|
"Modelado y análisis de mecanismos, cinemática y dinámica multicuerpo; fuerzas inerciales, equilibrado, resortes y amortiguadores, trenes de leva.",
|
||||||
|
horasAcademicas: 43,
|
||||||
|
horasIndependientes: 34,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Vibraciones Mecánicas",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 4,
|
||||||
|
descripcion:
|
||||||
|
"Vibraciones libres y forzadas en 1 y múltiples grados; amortiguamiento, resonancia, aislamiento y balanceo; análisis modal y mediciones.",
|
||||||
|
horasAcademicas: 33,
|
||||||
|
horasIndependientes: 33,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Control Automático para Sistemas Mecánicos",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 4,
|
||||||
|
descripcion:
|
||||||
|
"Modelado LTI, funciones de transferencia y espacio de estados; diseño PID y de compensadores; sensores y actuadores en sistemas mecánicos.",
|
||||||
|
horasAcademicas: 33,
|
||||||
|
horasIndependientes: 33,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
nombre: "Estructuras de Datos y Algoritmos",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 6,
|
||||||
|
descripcion:
|
||||||
|
"Diseño, análisis e implementación de estructuras de datos (listas, pilas, colas, árboles, grafos, tablas hash) y algoritmos fundamentales; complejidad temporal y espacial; aplicación a resolución eficiente de problemas.",
|
||||||
|
horasAcademicas: 60,
|
||||||
|
horasIndependientes: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Programación Orientada a Objetos",
|
||||||
|
tipo: "OBLIGATORIA",
|
||||||
|
creditos: 5,
|
||||||
|
descripcion:
|
||||||
|
"Principios y prácticas de la programación orientada a objetos: clases, objetos, herencia, polimorfismo, diseño basado en patrones, pruebas unitarias y gestión de proyectos simples en lenguajes modernos.",
|
||||||
|
horasAcademicas: 50,
|
||||||
|
horasIndependientes: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Matemáticas Discretas",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 4,
|
||||||
|
descripcion:
|
||||||
|
"Conceptos fundamentales: lógica proposicional y de predicados, teoría de conjuntos, relaciones y funciones, combinatoria, teoría de grafos y demostraciones formales aplicadas a la computación.",
|
||||||
|
horasAcademicas: 40,
|
||||||
|
horasIndependientes: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Circuitos Digitales y Lógica",
|
||||||
|
tipo: "OBLIGATORIA",
|
||||||
|
creditos: 4,
|
||||||
|
descripcion:
|
||||||
|
"Análisis y diseño de circuitos digitales: álgebra booleana, puertas lógicas, minimización, diseño secuencial y combinacional, máquinas de estados y uso de herramientas de simulación.",
|
||||||
|
horasAcademicas: 40,
|
||||||
|
horasIndependientes: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Señales y Sistemas Básicos",
|
||||||
|
tipo: "TRONCAL",
|
||||||
|
creditos: 4,
|
||||||
|
descripcion:
|
||||||
|
"Introducción a señales y sistemas: representación de señales, sistemas lineales invariantes en el tiempo, transformadas (Fourier, Laplace) y aplicaciones básicas en procesamiento y comunicaciones.",
|
||||||
|
horasAcademicas: 40,
|
||||||
|
horasIndependientes: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Laboratorio de Programación y Prácticas de Ingeniería",
|
||||||
|
tipo: "OPTATIVA",
|
||||||
|
creditos: 3,
|
||||||
|
descripcion:
|
||||||
|
"Asignatura práctica orientada a aplicar técnicas de programación, integración de software y herramientas de desarrollo; proyectos cortos que consolidan conceptos de algoritmos y diseño de software.",
|
||||||
|
horasAcademicas: 30,
|
||||||
|
horasIndependientes: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
// ...existing code...
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
create type "public"."estado_asignatura" as enum ('borrador', 'revisada', 'aprobada', 'generando');
|
||||||
|
|
||||||
|
alter table "public"."asignaturas" add column "estado" public.estado_asignatura not null default 'generando'::public.estado_asignatura;
|
||||||
|
|
||||||
|
set check_function_bodies = off;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$declare
|
||||||
|
k text;
|
||||||
|
old_val jsonb;
|
||||||
|
new_val jsonb;
|
||||||
|
|
||||||
|
v_response_id text;
|
||||||
|
begin
|
||||||
|
v_response_id := nullif(new.meta_origen->>'response_id','');
|
||||||
|
|
||||||
|
-- INSERT -> CREACION
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id,
|
||||||
|
cambiado_por,
|
||||||
|
tipo,
|
||||||
|
campo,
|
||||||
|
valor_anterior,
|
||||||
|
valor_nuevo,
|
||||||
|
response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.creado_por,
|
||||||
|
'CREACION'::public.tipo_cambio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
to_jsonb(new),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- DELETE (opcional): si no lo quieres, bórralo
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id,
|
||||||
|
cambiado_por,
|
||||||
|
tipo,
|
||||||
|
campo,
|
||||||
|
valor_anterior,
|
||||||
|
valor_nuevo,
|
||||||
|
response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
old.id,
|
||||||
|
old.actualizado_por,
|
||||||
|
'OTRO'::public.tipo_cambio,
|
||||||
|
'DELETE',
|
||||||
|
to_jsonb(old),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return old;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- UPDATE ----------------------------------------------------------
|
||||||
|
-- 1) Transición de estado
|
||||||
|
if (new.estado_actual_id is distinct from old.estado_actual_id) then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.actualizado_por,
|
||||||
|
'TRANSICION_ESTADO'::public.tipo_cambio,
|
||||||
|
'estado_actual_id',
|
||||||
|
to_jsonb(old.estado_actual_id),
|
||||||
|
to_jsonb(new.estado_actual_id),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
|
||||||
|
if (new.datos is distinct from old.datos) then
|
||||||
|
for k in
|
||||||
|
select distinct key
|
||||||
|
from (
|
||||||
|
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
|
||||||
|
union all
|
||||||
|
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
|
||||||
|
) t
|
||||||
|
loop
|
||||||
|
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
|
||||||
|
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
|
||||||
|
|
||||||
|
if (old_val is distinct from new_val) then
|
||||||
|
insert into public.cambios_plan (
|
||||||
|
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.actualizado_por,
|
||||||
|
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
|
||||||
|
k,
|
||||||
|
old_val,
|
||||||
|
new_val,
|
||||||
|
v_response_id
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- 3) Cambios en columnas "normales" (uno por columna)
|
||||||
|
if (new.nombre is distinct from old.nombre) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.nivel is distinct from old.nivel) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.numero_ciclos is distinct from old.numero_ciclos) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.activo is distinct from old.activo) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.carrera_id is distinct from old.carrera_id) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.estructura_id is distinct from old.estructura_id) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (new.tipo_origen is distinct from old.tipo_origen) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
|
||||||
|
if (new.conversation_id is distinct from old.conversation_id) then
|
||||||
|
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
|
||||||
|
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- 🔥 consumirlo para que NO se guarde en planes_estudio
|
||||||
|
if v_response_id is not null then
|
||||||
|
new.meta_origen := new.meta_origen - 'response_id';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;$function$
|
||||||
|
;
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
alter publication supabase_realtime drop table asignaturas;
|
||||||
Reference in New Issue
Block a user