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 ?? 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,
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 ?? 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 = {
id,
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,
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,
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,14 +360,17 @@ export function WizardControls({
}
} catch (err: any) {
setIsSpinningIA(false)
setPollSubjectId(null)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura',
}))
} finally {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
if (!startedPolling) {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
}
}
}

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
}