Generación existosa de múltiples asignaturas con IA

TODO: actualización automática de el estado de las asignaturas generadas
This commit is contained in:
2026-02-12 16:14:32 -06:00
parent 9c588cfd8f
commit d6c567195a
5 changed files with 180 additions and 44 deletions

View File

@@ -1,10 +1,12 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import type { AIGenerateSubjectInput } from '@/data' import type { AIGenerateSubjectInput, AIGenerateSubjectJsonInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { TablesInsert } from '@/types/supabase'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useGenerateSubjectAI } from '@/data' import { supabaseBrowser, useGenerateSubjectAI, qk } from '@/data'
export function WizardControls({ export function WizardControls({
wizard, wizard,
@@ -28,6 +30,7 @@ export function WizardControls({
isLastStep: boolean isLastStep: boolean
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient()
const generateSubjectAI = useGenerateSubjectAI() const generateSubjectAI = useGenerateSubjectAI()
const handleCreate = async () => { const handleCreate = async () => {
setWizard((w) => ({ setWizard((w) => ({
@@ -37,7 +40,7 @@ export function WizardControls({
})) }))
try { try {
if (wizard.tipoOrigen === 'IA') { if (wizard.tipoOrigen === 'IA' || wizard.tipoOrigen === 'IA_SIMPLE') {
const aiInput: AIGenerateSubjectInput = { const aiInput: AIGenerateSubjectInput = {
plan_estudio_id: wizard.plan_estudio_id, plan_estudio_id: wizard.plan_estudio_id,
datosBasicos: { datosBasicos: {
@@ -77,6 +80,82 @@ export function WizardControls({
}) })
return return
} }
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
const selected = wizard.sugerencias.filter((s) => s.selected)
if (selected.length === 0) {
throw new Error('Selecciona al menos una sugerencia.')
}
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
if (!wizard.estructuraId) {
throw new Error('Selecciona una estructura para continuar.')
}
const supabase = supabaseBrowser()
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
(s): TablesInsert<'asignaturas'> => ({
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.estructuraId,
estado: 'generando',
nombre: s.nombre,
codigo: s.codigo ?? null,
tipo: s.tipo ?? undefined,
creditos: s.creditos ?? 0,
horas_academicas: s.horasAcademicas ?? null,
horas_independientes: s.horasIndependientes ?? null,
linea_plan_id: s.linea_plan_id ?? null,
numero_ciclo: s.numero_ciclo ?? null,
}),
)
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(placeholders)
.select('id')
if (insertError) {
throw new Error(insertError.message)
}
const insertedIds = inserted.map((r) => r.id)
if (insertedIds.length !== selected.length) {
throw new Error('No se pudieron crear todas las asignaturas.')
}
// 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,
}
void generateSubjectAI.mutateAsync(payload).catch((e) => {
console.error('Error generando asignatura IA (multiple):', e)
})
})
// Invalidar la query del listado del plan (una vez) para que la lista
// muestre el estado actualizado y recargue cuando lleguen updates.
qc.invalidateQueries({
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
})
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas`,
resetScroll: false,
})
return
}
} catch (err: any) { } catch (err: any) {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,

View File

@@ -165,7 +165,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
'id,plan_estudio_id,horas_academicas, horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en', 'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })
@@ -189,7 +189,7 @@ export async function plans_history(
const { data, error, count } = await supabase const { data, error, count } = await supabase
.from('cambios_plan') .from('cambios_plan')
.select( .select(
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo', 'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id',
{ count: 'exact' }, // <--- Pedimos el conteo exacto { count: 'exact' }, // <--- Pedimos el conteo exacto
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
@@ -304,7 +304,7 @@ export async function ai_generate_plan(
archivosAdjuntos: undefined, // los manejamos aparte archivosAdjuntos: undefined, // los manejamos aparte
}), }),
) )
input.iaConfig.archivosAdjuntos.forEach((file, index) => { input.iaConfig.archivosAdjuntos.forEach((file) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file) edgeFunctionBody.append(`archivosAdjuntos`, file.file)
}) })

View File

@@ -41,7 +41,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
.from('asignaturas') .from('asignaturas')
.select( .select(
` `
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
planes_estudio( planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)) carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
@@ -138,6 +138,26 @@ export type AIGenerateSubjectInput = {
} }
} }
/**
* 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 type GenerateSubjectSuggestionsInput = { export type GenerateSubjectSuggestionsInput = {
plan_estudio_id: UUID plan_estudio_id: UUID
enfoque?: string enfoque?: string
@@ -173,8 +193,9 @@ export async function generate_subject_suggestions(
} }
export async function ai_generate_subject( export async function ai_generate_subject(
input: AIGenerateSubjectInput, input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
): Promise<any> { ): Promise<any> {
if ('datosBasicos' in input) {
const edgeFunctionBody = new FormData() const edgeFunctionBody = new FormData()
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id) edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos)) edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
@@ -196,6 +217,11 @@ export async function ai_generate_subject(
) )
} }
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
headers: { 'Content-Type': 'application/json' },
})
}
export async function subjects_persist_from_ai(payload: { export async function subjects_persist_from_ai(payload: {
planId: UUID planId: UUID
jsonAsignatura: any jsonAsignatura: any

View File

@@ -1,3 +1,4 @@
import { useQueryClient } from '@tanstack/react-query'
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router' import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
import { import {
Plus, Plus,
@@ -8,9 +9,10 @@ import {
BookOpen, BookOpen,
Loader2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan' import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan'
import type { Tables } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -30,13 +32,14 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { usePlanAsignaturas, usePlanLineas } from '@/data' import { qk, supabaseBrowser, usePlanAsignaturas, usePlanLineas } from '@/data'
// --- Configuración de Estilos --- // --- Configuración de Estilos ---
const statusConfig: Record< const statusConfig: Record<
AsignaturaStatus, AsignaturaStatus,
{ label: string; className: string } { label: string; className: string }
> = { > = {
generando: { label: 'Generando', className: 'bg-slate-100 text-slate-600' },
borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' }, borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' },
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' }, revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' }, aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
@@ -44,31 +47,31 @@ const statusConfig: Record<
const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> = const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> =
{ {
obligatoria: { OBLIGATORIA: {
label: 'Obligatoria', label: 'Obligatoria',
className: 'bg-blue-100 text-blue-700', className: 'bg-blue-100 text-blue-700',
}, },
optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' }, OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' }, TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' },
} }
// --- Mapeadores de API --- // --- Mapeadores de API ---
const mapAsignaturas = (asigApi: Array<any> = []): Array<Asignatura> => { const mapAsignaturas = (
asigApi: Array<Tables<'asignaturas'>> = [],
): Array<Asignatura> => {
return asigApi.map((asig) => ({ return asigApi.map((asig) => ({
id: asig.id, id: asig.id,
clave: asig.codigo, clave: asig.codigo ?? '',
nombre: asig.nombre, nombre: asig.nombre,
creditos: asig.creditos ?? 0, creditos: asig.creditos,
ciclo: asig.numero_ciclo ?? null, ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null, lineaCurricularId: asig.linea_plan_id ?? null,
tipo: tipo: asig.tipo,
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa', estado: asig.estado,
estado: 'borrador', // O el campo que venga de tu API hd: asig.horas_academicas ?? 0,
hd: Math.floor((asig.horas_semana ?? 0) / 2), hi: asig.horas_independientes ?? 0,
hi: Math.ceil((asig.horas_semana ?? 0) / 2), prerrequisitos: [],
prerrequisitos: Array.isArray(asig.prerrequisitos)
? asig.prerrequisitos
: [],
})) }))
} }
@@ -79,12 +82,38 @@ export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
function AsignaturasPage() { function AsignaturasPage() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient()
// 1. Fetch de datos reales // 1. Fetch de datos reales
const { data: asignaturasApi, isLoading: loadingAsig } = const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
useEffect(() => {
const supabase = supabaseBrowser()
const channel = supabase
.channel(`plan:${planId}:asignaturas:updates`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'asignaturas',
filter: `plan_estudio_id=eq.${planId}`,
},
() => {
queryClient.invalidateQueries({
queryKey: qk.planAsignaturas(planId),
})
},
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [planId, queryClient])
// 2. Estados de filtrado // 2. Estados de filtrado
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [filterTipo, setFilterTipo] = useState<string>('all') const [filterTipo, setFilterTipo] = useState<string>('all')

View File

@@ -1,3 +1,5 @@
import type { Tables } from './supabase'
export type PlanStatus = export type PlanStatus =
| 'borrador' | 'borrador'
| 'revision' | 'revision'
@@ -12,9 +14,9 @@ export type TipoPlan =
| 'Doctorado' | 'Doctorado'
| 'Especialidad' | 'Especialidad'
export type TipoAsignatura = 'obligatoria' | 'optativa' | 'troncal' export type TipoAsignatura = Tables<'asignaturas'>['tipo']
export type AsignaturaStatus = 'borrador' | 'revisada' | 'aprobada' export type AsignaturaStatus = Tables<'asignaturas'>['estado']
export interface Facultad { export interface Facultad {
id: string id: string