Generación de asignaturas funcional

This commit is contained in:
2026-02-26 16:20:21 -06:00
parent 4d1f102acb
commit d7d4eff523
8 changed files with 291 additions and 127 deletions

View File

@@ -26,7 +26,7 @@ export default function PasoSugerenciasForm({
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
}) {
const enfoque = wizard.iaMultiple?.enfoque ?? ''
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
const isLoading = wizard.iaMultiple?.isLoading ?? false
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
@@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({
Cantidad de sugerencias
</Label>
<Input
placeholder="Ej. 10"
placeholder="Ej. 5"
value={cantidadDeSugerencias}
type="number"
min={1}

View File

@@ -1,9 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { AIGenerateSubjectInput, AIGenerateSubjectJsonInput } from '@/data'
import type { AISubjectUnifiedInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { TablesInsert } from '@/types/supabase'
@@ -13,6 +13,7 @@ import {
useGenerateSubjectAI,
qk,
useCreateSubjectManual,
subjects_get_maybe,
} from '@/data'
export function WizardControls({
@@ -41,6 +42,101 @@ export function WizardControls({
const generateSubjectAI = useGenerateSubjectAI()
const createSubjectManual = useCreateSubjectManual()
const [isSpinningIA, setIsSpinningIA] = useState(false)
const [pollSubjectId, setPollSubjectId] = useState<string | null>(null)
const cancelledRef = useRef(false)
const pollStartedAtRef = useRef<number | null>(null)
useEffect(() => {
cancelledRef.current = false
return () => {
cancelledRef.current = true
}
}, [])
const subjectQuery = useQuery({
queryKey: pollSubjectId
? qk.asignaturaMaybe(pollSubjectId)
: ['asignaturas', 'detail-maybe', null],
queryFn: () => subjects_get_maybe(pollSubjectId as string),
enabled: Boolean(pollSubjectId),
refetchInterval: () => {
if (!pollSubjectId) return false
const startedAt = pollStartedAtRef.current ?? Date.now()
if (!pollStartedAtRef.current) pollStartedAtRef.current = startedAt
const elapsedMs = Date.now() - startedAt
return elapsedMs >= 6 * 60 * 1000 ? false : 3000
},
refetchIntervalInBackground: true,
staleTime: 0,
})
useEffect(() => {
if (!pollSubjectId) return
if (cancelledRef.current) return
const asig = subjectQuery.data
if (!asig) return
const estado = String(asig.estado).toLowerCase()
if (estado === 'generando') return
setPollSubjectId(null)
pollStartedAtRef.current = null
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
to: `/planes/${asig.plan_estudio_id}/asignaturas/${asig.id}`,
state: { showConfetti: true },
})
}, [pollSubjectId, subjectQuery.data, navigate, setWizard])
useEffect(() => {
if (!pollSubjectId) return
if (!subjectQuery.isError) return
setPollSubjectId(null)
pollStartedAtRef.current = null
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
(subjectQuery.error as any)?.message ??
'Error consultando el estado de la asignatura',
}))
}, [pollSubjectId, subjectQuery.isError, subjectQuery.error, setWizard])
const uploadAiAttachments = async (args: {
planId: string
files: Array<{ file: File }>
}): Promise<Array<string>> => {
const supabase = supabaseBrowser()
if (!args.files.length) return []
const runId = crypto.randomUUID()
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
const keys: Array<string> = []
for (const f of args.files) {
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
const { error } = await supabase.storage
.from('ai-storage')
.upload(key, f.file, {
contentType: f.file.type || undefined,
})
if (error) throw new Error(error.message)
keys.push(key)
}
return keys
}
const handleCreate = async () => {
setWizard((w) => ({
...w,
@@ -48,48 +144,89 @@ export function WizardControls({
errorMessage: null,
}))
let startedPolling = false
try {
if (wizard.tipoOrigen === 'IA_SIMPLE') {
const aiInput: AIGenerateSubjectInput = {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
if (!wizard.datosBasicos.estructuraId) {
throw new Error('Estructura inválida.')
}
if (!wizard.datosBasicos.nombre.trim()) {
throw new Error('Nombre inválido.')
}
if (wizard.datosBasicos.creditos == null) {
throw new Error('Créditos inválidos.')
}
console.log(`${new Date().toISOString()} - Insertando asignatura IA`)
const supabase = supabaseBrowser()
const placeholder: TablesInsert<'asignaturas'> = {
plan_estudio_id: wizard.plan_estudio_id,
datosBasicos: {
estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo,
tipo: wizard.datosBasicos.tipo!,
creditos: wizard.datosBasicos.creditos!,
horasIndependientes: wizard.datosBasicos.horasIndependientes,
horasAcademicas: wizard.datosBasicos.horasAcademicas,
estructuraId: wizard.datosBasicos.estructuraId!,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
estado: 'generando',
tipo_origen: 'IA',
}
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(placeholder)
.select('id,plan_estudio_id')
.single()
if (insertError) throw new Error(insertError.message)
const subjectId = inserted.id
setIsSpinningIA(true)
const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
file: x.file,
})),
})
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id: subjectId,
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? null,
creditos: wizard.datosBasicos.creditos,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes:
wizard.datosBasicos.horasIndependientes ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico:
wizard.iaConfig!.descripcionEnfoqueAcademico,
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
instruccionesAdicionalesIA:
wizard.iaConfig!.instruccionesAdicionalesIA,
archivosReferencia: wizard.iaConfig!.archivosReferencia,
repositoriosReferencia:
wizard.iaConfig!.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosAdjuntos,
},
}
console.log(
`${new Date().toISOString()} - Enviando a generar asignatura con IA`,
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
)
setIsSpinningIA(true)
const asignatura = await generateSubjectAI.mutateAsync(aiInput)
// await new Promise((resolve) => setTimeout(resolve, 20000)) // debug
setIsSpinningIA(false)
// console.log(
// `${new Date().toISOString()} - Asignatura IA generada`,
// asignatura,
// )
await generateSubjectAI.mutateAsync(payload as any)
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true },
})
// Inicia polling; el efecto navega cuando deje de estar "generando".
startedPolling = true
pollStartedAtRef.current = Date.now()
setPollSubjectId(subjectId)
return
}
@@ -108,6 +245,15 @@ export function WizardControls({
const supabase = supabaseBrowser()
setIsSpinningIA(true)
const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
file: x.file,
})),
})
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
(s): TablesInsert<'asignaturas'> => ({
plan_estudio_id: wizard.plan_estudio_id,
@@ -141,16 +287,33 @@ export function WizardControls({
// Disparar generación en paralelo (no bloquear navegación)
insertedIds.forEach((id, idx) => {
const s = selected[idx]
const payload: AIGenerateSubjectJsonInput = {
const creditosForEdge =
typeof s.creditos === 'number' && s.creditos > 0
? s.creditos
: undefined
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id,
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.estructuraId ?? undefined,
nombre: s.nombre,
codigo: s.codigo ?? null,
tipo: s.tipo ?? null,
creditos: creditosForEdge,
horas_academicas: s.horasAcademicas ?? null,
horas_independientes: s.horasIndependientes ?? null,
numero_ciclo: s.numero_ciclo ?? null,
linea_plan_id: s.linea_plan_id ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico: s.descripcion,
// (opcionales) parches directos si el edge los usa
estructura_id: wizard.estructuraId,
linea_plan_id: s.linea_plan_id,
numero_ciclo: s.numero_ciclo,
instruccionesAdicionalesIA:
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosAdjuntos,
},
}
void generateSubjectAI.mutateAsync(payload).catch((e) => {
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
console.error('Error generando asignatura IA (multiple):', e)
})
})
@@ -166,6 +329,8 @@ export function WizardControls({
resetScroll: false,
})
setIsSpinningIA(false)
return
}
@@ -195,16 +360,19 @@ export function WizardControls({
}
} catch (err: any) {
setIsSpinningIA(false)
setPollSubjectId(null)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura',
}))
} finally {
if (!startedPolling) {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
}
}
}
return (
<div className="flex grow items-center justify-between">

View File

@@ -45,6 +45,7 @@ export function WizardControls({
const [isSpinningIA, setIsSpinningIA] = useState(false)
const [pollPlanId, setPollPlanId] = useState<string | null>(null)
const cancelledRef = useRef(false)
const pollStartedAtRef = useRef<number | null>(null)
// const supabaseClient = supabaseBrowser()
// const persistPlanFromAI = usePersistPlanFromAI()
@@ -61,7 +62,15 @@ export function WizardControls({
: ['planes', 'detail-maybe', null],
queryFn: () => plans_get_maybe(pollPlanId as string),
enabled: Boolean(pollPlanId),
refetchInterval: pollPlanId ? 3000 : false,
refetchInterval: () => {
if (!pollPlanId) return false
const startedAt = pollStartedAtRef.current ?? Date.now()
if (!pollStartedAtRef.current) pollStartedAtRef.current = startedAt
const elapsedMs = Date.now() - startedAt
return elapsedMs >= 6 * 60 * 1000 ? false : 3000
},
refetchIntervalInBackground: true,
staleTime: 0,
})
@@ -80,6 +89,7 @@ export function WizardControls({
if (clave.startsWith('BORRADOR')) {
setPollPlanId(null)
pollStartedAtRef.current = null
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
@@ -92,6 +102,7 @@ export function WizardControls({
if (clave.startsWith('FALLID')) {
// Detenemos el polling primero para evitar loops.
setPollPlanId(null)
pollStartedAtRef.current = null
setIsSpinningIA(false)
deletePlan
@@ -113,6 +124,7 @@ export function WizardControls({
if (!pollPlanId) return
if (!planQuery.isError) return
setPollPlanId(null)
pollStartedAtRef.current = null
setIsSpinningIA(false)
setWizard((w) => ({
...w,
@@ -175,6 +187,7 @@ export function WizardControls({
}
// Inicia polling con React Query; el efecto navega o marca error.
pollStartedAtRef.current = Date.now()
setPollPlanId(String(planId))
return
}

View File

@@ -15,7 +15,6 @@ import type {
TipoAsignatura,
UUID,
} from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type {
AsignaturaSugerida,
DataAsignaturaSugerida,
@@ -178,54 +177,49 @@ export async function subjects_create_manual(
return requireData(data, 'No se pudo crear la asignatura.')
}
export type AIGenerateSubjectInput = {
plan_estudio_id: Asignatura['plan_estudio_id']
datosBasicos: {
nombre: Asignatura['nombre']
codigo?: Asignatura['codigo']
tipo: Asignatura['tipo'] | null
creditos: Asignatura['creditos'] | null
horasAcademicas?: Asignatura['horas_academicas'] | null
horasIndependientes?: Asignatura['horas_independientes'] | null
estructuraId: Asignatura['estructura_id'] | null
/**
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
* - Siempre incluye `datosUpdate.plan_estudio_id`.
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
* En el frontend, insertamos primero y usamos `id` para actualizar.
*/
export type AISubjectUnifiedInput = {
datosUpdate: Partial<{
id: string
plan_estudio_id: string
estructura_id: string
nombre: string
codigo: string | null
tipo: string | null
creditos: number
horas_academicas: number | null
horas_independientes: number | null
numero_ciclo: number | null
linea_plan_id: string | null
orden_celda: number | null
}> & {
plan_estudio_id: string
}
// clonInterno?: {
// facultadId?: string
// carreraId?: string
// planOrigenId?: string
// asignaturaOrigenId?: string | null
// }
// clonTradicional?: {
// archivoWordAsignaturaId: string | null
// archivosAdicionalesIds: Array<string>
// }
iaConfig?: {
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA: string
archivosReferencia: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
descripcionEnfoqueAcademico?: string
instruccionesAdicionalesIA?: string
archivosAdjuntos?: Array<string>
}
}
/**
* Edge (JSON): actualizar/llenar una asignatura existente por id.
* Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa).
*/
export type AIGenerateSubjectJsonInput = Partial<{
plan_estudio_id: Asignatura['plan_estudio_id']
nombre: Asignatura['nombre']
codigo: Asignatura['codigo']
tipo: Asignatura['tipo'] | null
creditos: Asignatura['creditos']
horas_academicas: Asignatura['horas_academicas'] | null
horas_independientes: Asignatura['horas_independientes'] | null
estructura_id: Asignatura['estructura_id'] | null
linea_plan_id: Asignatura['linea_plan_id'] | null
numero_ciclo: Asignatura['numero_ciclo'] | null
descripcionEnfoqueAcademico: string
}> & {
id: Asignatura['id']
export async function subjects_get_maybe(
subjectId: UUID,
): Promise<Asignatura | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.select('id,plan_estudio_id,estado')
.eq('id', subjectId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as Asignatura | null
}
export type GenerateSubjectSuggestionsInput = {
@@ -263,30 +257,8 @@ export async function generate_subject_suggestions(
}
export async function ai_generate_subject(
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
input: AISubjectUnifiedInput,
): Promise<any> {
if ('datosBasicos' in input) {
const edgeFunctionBody = new FormData()
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
edgeFunctionBody.append(
'iaConfig',
JSON.stringify({
...input.iaConfig,
archivosAdjuntos: undefined, // los manejamos aparte
}),
)
input.iaConfig?.archivosAdjuntos?.forEach((file) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
})
return invokeEdge<any>(
EDGE.ai_generate_subject,
edgeFunctionBody,
undefined,
supabaseBrowser(),
)
}
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
headers: { 'Content-Type': 'application/json' },
})

View File

@@ -85,7 +85,18 @@ export function usePlanAsignaturas(planId: UUID | null | undefined) {
const hayGenerando = data.some(
(a: any) => (a as { estado?: unknown }).estado === 'generando',
)
return hayGenerando ? 500 : false
const qAny = query as any
if (!hayGenerando) {
qAny.__generandoSince = null
return false
}
const startedAt = qAny.__generandoSince ?? Date.now()
if (!qAny.__generandoSince) qAny.__generandoSince = startedAt
const elapsedMs = Date.now() - startedAt
return elapsedMs >= 6 * 60 * 1000 ? false : 3000
},
refetchIntervalInBackground: true,
})

View File

@@ -23,6 +23,8 @@ export const qk = {
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
asignatura: (asignaturaId: string) =>
['asignaturas', 'detail', asignaturaId] as const,
asignaturaMaybe: (asignaturaId: string) =>
['asignaturas', 'detail-maybe', asignaturaId] as const,
asignaturaBibliografia: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'bibliografia'] as const,
asignaturaHistorial: (asignaturaId: string) =>

View File

@@ -2,6 +2,7 @@ import {
createFileRoute,
Outlet,
Link,
useLocation,
useParams,
useRouterState,
} from '@tanstack/react-router'
@@ -9,6 +10,7 @@ import { ArrowLeft, GraduationCap } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data'
export const Route = createFileRoute(
@@ -62,8 +64,7 @@ interface DatosPlan {
}
function AsignaturaLayout() {
const routerState = useRouterState()
const state = routerState.location.state as any
const location = useLocation()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
@@ -117,6 +118,14 @@ function AsignaturaLayout() {
select: (state) => state.location.pathname,
})
// Confetti al llegar desde creación IA
useEffect(() => {
if ((location.state as any)?.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title)
}
}, [location.state])
if (loadingAsig) {
return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
@@ -130,7 +139,7 @@ function AsignaturaLayout() {
return (
<div>
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes/$planId/asignaturas"

View File

@@ -1213,12 +1213,7 @@ export type Database = {
unaccent_immutable: { Args: { '': string }; Returns: string }
}
Enums: {
estado_asignatura:
| 'borrador'
| 'revisada'
| 'aprobada'
| 'generando'
| 'fallida'
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
fuente_cambio: 'HUMANO' | 'IA'
@@ -1392,13 +1387,7 @@ export const Constants = {
},
public: {
Enums: {
estado_asignatura: [
'borrador',
'revisada',
'aprobada',
'generando',
'fallida',
],
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
fuente_cambio: ['HUMANO', 'IA'],