Generación de asignaturas funcional
This commit is contained in:
@@ -26,7 +26,7 @@ export default function PasoSugerenciasForm({
|
|||||||
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
||||||
}) {
|
}) {
|
||||||
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
||||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
|
||||||
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
||||||
|
|
||||||
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
||||||
@@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({
|
|||||||
Cantidad de sugerencias
|
Cantidad de sugerencias
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Ej. 10"
|
placeholder="Ej. 5"
|
||||||
value={cantidadDeSugerencias}
|
value={cantidadDeSugerencias}
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { Loader2 } from 'lucide-react'
|
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 { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
import type { TablesInsert } from '@/types/supabase'
|
import type { TablesInsert } from '@/types/supabase'
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useGenerateSubjectAI,
|
useGenerateSubjectAI,
|
||||||
qk,
|
qk,
|
||||||
useCreateSubjectManual,
|
useCreateSubjectManual,
|
||||||
|
subjects_get_maybe,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
@@ -41,6 +42,101 @@ export function WizardControls({
|
|||||||
const generateSubjectAI = useGenerateSubjectAI()
|
const generateSubjectAI = useGenerateSubjectAI()
|
||||||
const createSubjectManual = useCreateSubjectManual()
|
const createSubjectManual = useCreateSubjectManual()
|
||||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
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 () => {
|
const handleCreate = async () => {
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
@@ -48,48 +144,89 @@ export function WizardControls({
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
let startedPolling = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
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,
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
datosBasicos: {
|
estructura_id: wizard.datosBasicos.estructuraId,
|
||||||
nombre: wizard.datosBasicos.nombre,
|
nombre: wizard.datosBasicos.nombre,
|
||||||
codigo: wizard.datosBasicos.codigo,
|
codigo: wizard.datosBasicos.codigo ?? null,
|
||||||
tipo: wizard.datosBasicos.tipo!,
|
tipo: wizard.datosBasicos.tipo ?? undefined,
|
||||||
creditos: wizard.datosBasicos.creditos!,
|
creditos: wizard.datosBasicos.creditos,
|
||||||
horasIndependientes: wizard.datosBasicos.horasIndependientes,
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||||
horasAcademicas: wizard.datosBasicos.horasAcademicas,
|
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
||||||
estructuraId: wizard.datosBasicos.estructuraId!,
|
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: {
|
iaConfig: {
|
||||||
descripcionEnfoqueAcademico:
|
descripcionEnfoqueAcademico:
|
||||||
wizard.iaConfig!.descripcionEnfoqueAcademico,
|
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
|
||||||
instruccionesAdicionalesIA:
|
instruccionesAdicionalesIA:
|
||||||
wizard.iaConfig!.instruccionesAdicionalesIA,
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||||
archivosReferencia: wizard.iaConfig!.archivosReferencia,
|
archivosAdjuntos,
|
||||||
repositoriosReferencia:
|
|
||||||
wizard.iaConfig!.repositoriosReferencia || [],
|
|
||||||
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${new Date().toISOString()} - Enviando a generar asignatura con IA`,
|
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
|
||||||
)
|
)
|
||||||
|
|
||||||
setIsSpinningIA(true)
|
await generateSubjectAI.mutateAsync(payload as any)
|
||||||
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,
|
|
||||||
// )
|
|
||||||
|
|
||||||
navigate({
|
// Inicia polling; el efecto navega cuando deje de estar "generando".
|
||||||
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
|
startedPolling = true
|
||||||
state: { showConfetti: true },
|
pollStartedAtRef.current = Date.now()
|
||||||
})
|
setPollSubjectId(subjectId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +245,15 @@ export function WizardControls({
|
|||||||
|
|
||||||
const supabase = supabaseBrowser()
|
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(
|
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
|
||||||
(s): TablesInsert<'asignaturas'> => ({
|
(s): TablesInsert<'asignaturas'> => ({
|
||||||
plan_estudio_id: wizard.plan_estudio_id,
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
@@ -141,16 +287,33 @@ export function WizardControls({
|
|||||||
// Disparar generación en paralelo (no bloquear navegación)
|
// Disparar generación en paralelo (no bloquear navegación)
|
||||||
insertedIds.forEach((id, idx) => {
|
insertedIds.forEach((id, idx) => {
|
||||||
const s = selected[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,
|
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,
|
descripcionEnfoqueAcademico: s.descripcion,
|
||||||
// (opcionales) parches directos si el edge los usa
|
instruccionesAdicionalesIA:
|
||||||
estructura_id: wizard.estructuraId,
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||||
linea_plan_id: s.linea_plan_id,
|
archivosAdjuntos,
|
||||||
numero_ciclo: s.numero_ciclo,
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
void generateSubjectAI.mutateAsync(payload).catch((e) => {
|
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
|
||||||
console.error('Error generando asignatura IA (multiple):', e)
|
console.error('Error generando asignatura IA (multiple):', e)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -166,6 +329,8 @@ export function WizardControls({
|
|||||||
resetScroll: false,
|
resetScroll: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,16 +360,19 @@ export function WizardControls({
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
|
setPollSubjectId(null)
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: err?.message ?? 'Error creando la asignatura',
|
errorMessage: err?.message ?? 'Error creando la asignatura',
|
||||||
}))
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!startedPolling) {
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
setWizard((w) => ({ ...w, isLoading: false }))
|
setWizard((w) => ({ ...w, isLoading: false }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex grow items-center justify-between">
|
<div className="flex grow items-center justify-between">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function WizardControls({
|
|||||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||||
const [pollPlanId, setPollPlanId] = useState<string | null>(null)
|
const [pollPlanId, setPollPlanId] = useState<string | null>(null)
|
||||||
const cancelledRef = useRef(false)
|
const cancelledRef = useRef(false)
|
||||||
|
const pollStartedAtRef = useRef<number | null>(null)
|
||||||
// const supabaseClient = supabaseBrowser()
|
// const supabaseClient = supabaseBrowser()
|
||||||
// const persistPlanFromAI = usePersistPlanFromAI()
|
// const persistPlanFromAI = usePersistPlanFromAI()
|
||||||
|
|
||||||
@@ -61,7 +62,15 @@ export function WizardControls({
|
|||||||
: ['planes', 'detail-maybe', null],
|
: ['planes', 'detail-maybe', null],
|
||||||
queryFn: () => plans_get_maybe(pollPlanId as string),
|
queryFn: () => plans_get_maybe(pollPlanId as string),
|
||||||
enabled: Boolean(pollPlanId),
|
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,
|
refetchIntervalInBackground: true,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
@@ -80,6 +89,7 @@ export function WizardControls({
|
|||||||
|
|
||||||
if (clave.startsWith('BORRADOR')) {
|
if (clave.startsWith('BORRADOR')) {
|
||||||
setPollPlanId(null)
|
setPollPlanId(null)
|
||||||
|
pollStartedAtRef.current = null
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
setWizard((w) => ({ ...w, isLoading: false }))
|
setWizard((w) => ({ ...w, isLoading: false }))
|
||||||
navigate({
|
navigate({
|
||||||
@@ -92,6 +102,7 @@ export function WizardControls({
|
|||||||
if (clave.startsWith('FALLID')) {
|
if (clave.startsWith('FALLID')) {
|
||||||
// Detenemos el polling primero para evitar loops.
|
// Detenemos el polling primero para evitar loops.
|
||||||
setPollPlanId(null)
|
setPollPlanId(null)
|
||||||
|
pollStartedAtRef.current = null
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
|
|
||||||
deletePlan
|
deletePlan
|
||||||
@@ -113,6 +124,7 @@ export function WizardControls({
|
|||||||
if (!pollPlanId) return
|
if (!pollPlanId) return
|
||||||
if (!planQuery.isError) return
|
if (!planQuery.isError) return
|
||||||
setPollPlanId(null)
|
setPollPlanId(null)
|
||||||
|
pollStartedAtRef.current = null
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
@@ -175,6 +187,7 @@ export function WizardControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inicia polling con React Query; el efecto navega o marca error.
|
// Inicia polling con React Query; el efecto navega o marca error.
|
||||||
|
pollStartedAtRef.current = Date.now()
|
||||||
setPollPlanId(String(planId))
|
setPollPlanId(String(planId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
TipoAsignatura,
|
TipoAsignatura,
|
||||||
UUID,
|
UUID,
|
||||||
} from '../types/domain'
|
} from '../types/domain'
|
||||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
|
||||||
import type {
|
import type {
|
||||||
AsignaturaSugerida,
|
AsignaturaSugerida,
|
||||||
DataAsignaturaSugerida,
|
DataAsignaturaSugerida,
|
||||||
@@ -178,54 +177,49 @@ export async function subjects_create_manual(
|
|||||||
return requireData(data, 'No se pudo crear la asignatura.')
|
return requireData(data, 'No se pudo crear la asignatura.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AIGenerateSubjectInput = {
|
/**
|
||||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
||||||
datosBasicos: {
|
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
||||||
nombre: Asignatura['nombre']
|
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
||||||
codigo?: Asignatura['codigo']
|
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
||||||
tipo: Asignatura['tipo'] | null
|
*/
|
||||||
creditos: Asignatura['creditos'] | null
|
export type AISubjectUnifiedInput = {
|
||||||
horasAcademicas?: Asignatura['horas_academicas'] | null
|
datosUpdate: Partial<{
|
||||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
id: string
|
||||||
estructuraId: Asignatura['estructura_id'] | null
|
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?: {
|
iaConfig?: {
|
||||||
descripcionEnfoqueAcademico: string
|
descripcionEnfoqueAcademico?: string
|
||||||
instruccionesAdicionalesIA: string
|
instruccionesAdicionalesIA?: string
|
||||||
archivosReferencia: Array<string>
|
archivosAdjuntos?: Array<string>
|
||||||
repositoriosReferencia?: Array<string>
|
|
||||||
archivosAdjuntos?: Array<UploadedFile>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function subjects_get_maybe(
|
||||||
* Edge (JSON): actualizar/llenar una asignatura existente por id.
|
subjectId: UUID,
|
||||||
* Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa).
|
): Promise<Asignatura | null> {
|
||||||
*/
|
const supabase = supabaseBrowser()
|
||||||
export type AIGenerateSubjectJsonInput = Partial<{
|
|
||||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
const { data, error } = await supabase
|
||||||
nombre: Asignatura['nombre']
|
.from('asignaturas')
|
||||||
codigo: Asignatura['codigo']
|
.select('id,plan_estudio_id,estado')
|
||||||
tipo: Asignatura['tipo'] | null
|
.eq('id', subjectId)
|
||||||
creditos: Asignatura['creditos']
|
.maybeSingle()
|
||||||
horas_academicas: Asignatura['horas_academicas'] | null
|
|
||||||
horas_independientes: Asignatura['horas_independientes'] | null
|
throwIfError(error)
|
||||||
estructura_id: Asignatura['estructura_id'] | null
|
return (data ?? null) as unknown as Asignatura | 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 = {
|
||||||
@@ -263,30 +257,8 @@ export async function generate_subject_suggestions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function ai_generate_subject(
|
export async function ai_generate_subject(
|
||||||
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
|
input: AISubjectUnifiedInput,
|
||||||
): Promise<any> {
|
): 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, {
|
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,7 +85,18 @@ export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
|||||||
const hayGenerando = data.some(
|
const hayGenerando = data.some(
|
||||||
(a: any) => (a as { estado?: unknown }).estado === 'generando',
|
(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,
|
refetchIntervalInBackground: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const qk = {
|
|||||||
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
||||||
asignatura: (asignaturaId: string) =>
|
asignatura: (asignaturaId: string) =>
|
||||||
['asignaturas', 'detail', asignaturaId] as const,
|
['asignaturas', 'detail', asignaturaId] as const,
|
||||||
|
asignaturaMaybe: (asignaturaId: string) =>
|
||||||
|
['asignaturas', 'detail-maybe', asignaturaId] as const,
|
||||||
asignaturaBibliografia: (asignaturaId: string) =>
|
asignaturaBibliografia: (asignaturaId: string) =>
|
||||||
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
||||||
asignaturaHistorial: (asignaturaId: string) =>
|
asignaturaHistorial: (asignaturaId: string) =>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
createFileRoute,
|
createFileRoute,
|
||||||
Outlet,
|
Outlet,
|
||||||
Link,
|
Link,
|
||||||
|
useLocation,
|
||||||
useParams,
|
useParams,
|
||||||
useRouterState,
|
useRouterState,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
@@ -9,6 +10,7 @@ import { ArrowLeft, GraduationCap } from 'lucide-react'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { lateralConfetti } from '@/components/ui/lateral-confetti'
|
||||||
import { useSubject, useUpdateAsignatura } from '@/data'
|
import { useSubject, useUpdateAsignatura } from '@/data'
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -62,8 +64,7 @@ interface DatosPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AsignaturaLayout() {
|
function AsignaturaLayout() {
|
||||||
const routerState = useRouterState()
|
const location = useLocation()
|
||||||
const state = routerState.location.state as any
|
|
||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
@@ -117,6 +118,14 @@ function AsignaturaLayout() {
|
|||||||
select: (state) => state.location.pathname,
|
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) {
|
if (loadingAsig) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
|
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
|
||||||
@@ -130,7 +139,7 @@ function AsignaturaLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||||
<Link
|
<Link
|
||||||
to="/planes/$planId/asignaturas"
|
to="/planes/$planId/asignaturas"
|
||||||
|
|||||||
@@ -1213,12 +1213,7 @@ export type Database = {
|
|||||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura:
|
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
|
||||||
| 'borrador'
|
|
||||||
| 'revisada'
|
|
||||||
| 'aprobada'
|
|
||||||
| 'generando'
|
|
||||||
| 'fallida'
|
|
||||||
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
||||||
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
||||||
fuente_cambio: 'HUMANO' | 'IA'
|
fuente_cambio: 'HUMANO' | 'IA'
|
||||||
@@ -1392,13 +1387,7 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura: [
|
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
|
||||||
'borrador',
|
|
||||||
'revisada',
|
|
||||||
'aprobada',
|
|
||||||
'generando',
|
|
||||||
'fallida',
|
|
||||||
],
|
|
||||||
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
||||||
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
||||||
fuente_cambio: ['HUMANO', 'IA'],
|
fuente_cambio: ['HUMANO', 'IA'],
|
||||||
|
|||||||
Reference in New Issue
Block a user