primera versión funcional de creación de plan con IA

This commit is contained in:
2026-01-20 17:04:39 -06:00
parent 9aad9aed00
commit 18f2bed3ea
8 changed files with 119 additions and 70 deletions

View File

@@ -12,6 +12,7 @@ export interface UploadedFile {
} }
interface FileDropzoneProps { interface FileDropzoneProps {
persistentFiles?: Array<UploadedFile>
onFilesChange?: (files: Array<UploadedFile>) => void onFilesChange?: (files: Array<UploadedFile>) => void
acceptedTypes?: string acceptedTypes?: string
maxFiles?: number maxFiles?: number
@@ -20,6 +21,7 @@ interface FileDropzoneProps {
} }
export function FileDropzone({ export function FileDropzone({
persistentFiles,
onFilesChange, onFilesChange,
acceptedTypes = '.doc,.docx,.pdf', acceptedTypes = '.doc,.docx,.pdf',
maxFiles = 5, maxFiles = 5,
@@ -27,7 +29,7 @@ export function FileDropzone({
description = 'o haz clic para seleccionar', description = 'o haz clic para seleccionar',
}: FileDropzoneProps) { }: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<Array<UploadedFile>>([]) const [files, setFiles] = useState<Array<UploadedFile>>(persistentFiles ?? [])
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange) const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
const addFiles = useCallback( const addFiles = useCallback(

View File

@@ -23,12 +23,10 @@ import {
export function PasoDetallesPanel({ export function PasoDetallesPanel({
wizard, wizard,
onChange, onChange,
onGenerarIA,
isLoading, isLoading,
}: { }: {
wizard: NewPlanWizardState wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>> onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
onGenerarIA: () => void
isLoading: boolean isLoading: boolean
}) { }) {
if (wizard.tipoOrigen === 'MANUAL') { if (wizard.tipoOrigen === 'MANUAL') {
@@ -87,6 +85,7 @@ export function PasoDetallesPanel({
<ReferenciasParaIA <ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []} selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []} selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) => onToggleArchivo={(id, checked) =>
onChange((w) => { onChange((w) => {
const prev = w.iaConfig?.archivosReferencia || [] const prev = w.iaConfig?.archivosReferencia || []
@@ -133,7 +132,7 @@ export function PasoDetallesPanel({
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante. Opcional: se pueden adjuntar recursos IA más adelante.
</div> </div>
<Button onClick={onGenerarIA} disabled={isLoading}> <Button disabled={isLoading}>
{isLoading ? 'Generando…' : 'Generar borrador con IA'} {isLoading ? 'Generando…' : 'Generar borrador con IA'}
</Button> </Button>
</div> </div>

View File

@@ -21,12 +21,14 @@ import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
const ReferenciasParaIA = ({ const ReferenciasParaIA = ({
selectedArchivoIds = [], selectedArchivoIds = [],
selectedRepositorioIds = [], selectedRepositorioIds = [],
uploadedFiles = [],
onToggleArchivo, onToggleArchivo,
onToggleRepositorio, onToggleRepositorio,
onFilesChange, onFilesChange,
}: { }: {
selectedArchivoIds?: Array<string> selectedArchivoIds?: Array<string>
selectedRepositorioIds?: Array<string> selectedRepositorioIds?: Array<string>
uploadedFiles?: Array<UploadedFile>
onToggleArchivo?: (id: string, checked: boolean) => void onToggleArchivo?: (id: string, checked: boolean) => void
onToggleRepositorio?: (id: string, checked: boolean) => void onToggleRepositorio?: (id: string, checked: boolean) => void
onFilesChange?: (files: Array<UploadedFile>) => void onFilesChange?: (files: Array<UploadedFile>) => void
@@ -163,6 +165,7 @@ const ReferenciasParaIA = ({
content: ( content: (
<div> <div>
<FileDropzone <FileDropzone
persistentFiles={uploadedFiles}
onFilesChange={onFilesChange} onFilesChange={onFilesChange}
title="Sube archivos de referencia" title="Sube archivos de referencia"
description="Documentos que serán usados como contexto para la generación" description="Documentos que serán usados como contexto para la generación"

View File

@@ -1,3 +1,4 @@
import type { UploadedFile } from './PasoDetallesPanel/FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { import {
@@ -166,7 +167,7 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
<div className="mt-2"> <div className="mt-2">
<div className="font-medium">Adjuntos</div> <div className="font-medium">Adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs"> <ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => ( {adjuntos.map((f: UploadedFile) => (
<li key={f.id}> <li key={f.id}>
<span className="text-foreground"> <span className="text-foreground">
{f.file.name} {f.file.name}

View File

@@ -18,7 +18,7 @@ import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/
const EDGE = { const EDGE = {
plans_create_manual: "plans_create_manual", plans_create_manual: "plans_create_manual",
ai_generate_plan: "ai_generate_plan", ai_generate_plan: "ai-generate-plan",
plans_persist_from_ai: "plans_persist_from_ai", plans_persist_from_ai: "plans_persist_from_ai",
plans_clone_from_existing: "plans_clone_from_existing", plans_clone_from_existing: "plans_clone_from_existing",
@@ -214,10 +214,10 @@ export type AIGeneratePlanInput = {
nivel: string; nivel: string;
tipoCiclo: TipoCiclo; tipoCiclo: TipoCiclo;
numCiclos: number; numCiclos: number;
estructuraPlanId: UUID;
}; };
iaConfig: { iaConfig: {
descripcionEnfoque: string; descripcionEnfoque: string;
poblacionObjetivo?: string;
notasAdicionales?: string; notasAdicionales?: string;
archivosReferencia?: Array<UUID>; archivosReferencia?: Array<UUID>;
repositoriosIds?: Array<UUID>; repositoriosIds?: Array<UUID>;
@@ -229,7 +229,27 @@ export type AIGeneratePlanInput = {
export async function ai_generate_plan( export async function ai_generate_plan(
input: AIGeneratePlanInput, input: AIGeneratePlanInput,
): Promise<any> { ): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_plan, input); console.log("input ai generate", input);
const edgeFunctionBody = new FormData();
edgeFunctionBody.append("datosBasicos", JSON.stringify(input.datosBasicos));
edgeFunctionBody.append(
"iaConfig",
JSON.stringify({
...input.iaConfig,
archivosAdjuntos: undefined, // los manejamos aparte
}),
);
input.iaConfig.archivosAdjuntos.forEach((file, index) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file);
});
return invokeEdge<any>(
EDGE.ai_generate_plan,
edgeFunctionBody,
undefined,
supabaseBrowser(),
);
} }
export async function plans_persist_from_ai( export async function plans_persist_from_ai(

View File

@@ -1,7 +1,8 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../types/database";
import { supabaseBrowser } from "./client"; import { supabaseBrowser } from "./client";
import type { Database } from "@/types/supabase";
import type { SupabaseClient } from "@supabase/supabase-js";
export type EdgeInvokeOptions = { export type EdgeInvokeOptions = {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>; headers?: Record<string, string>;
@@ -12,7 +13,7 @@ export class EdgeFunctionError extends Error {
message: string, message: string,
public readonly functionName: string, public readonly functionName: string,
public readonly status?: number, public readonly status?: number,
public readonly details?: unknown public readonly details?: unknown,
) { ) {
super(message); super(message);
this.name = "EdgeFunctionError"; this.name = "EdgeFunctionError";
@@ -21,9 +22,17 @@ export class EdgeFunctionError extends Error {
export async function invokeEdge<TOut>( export async function invokeEdge<TOut>(
functionName: string, functionName: string,
body?: unknown, body?:
| string
| File
| Blob
| ArrayBuffer
| FormData
| ReadableStream<Uint8Array<ArrayBufferLike>>
| Record<string, unknown>
| undefined,
opts: EdgeInvokeOptions = {}, opts: EdgeInvokeOptions = {},
client?: SupabaseClient<Database> client?: SupabaseClient<Database>,
): Promise<TOut> { ): Promise<TOut> {
const supabase = client ?? supabaseBrowser(); const supabase = client ?? supabaseBrowser();
@@ -34,12 +43,12 @@ export async function invokeEdge<TOut>(
}); });
if (error) { if (error) {
const anyErr = error as any; const anyErr = error;
throw new EdgeFunctionError( throw new EdgeFunctionError(
anyErr.message ?? "Error en Edge Function", anyErr.message ?? "Error en Edge Function",
functionName, functionName,
anyErr.status, anyErr.status,
anyErr anyErr,
); );
} }

View File

@@ -25,6 +25,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { useGeneratePlanAI } from '@/data/hooks/usePlans'
// Mock de permisos/rol // Mock de permisos/rol
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
@@ -47,6 +48,8 @@ const Wizard = defineStepper(
export default function NuevoPlanModalContainer() { export default function NuevoPlanModalContainer() {
const navigate = useNavigate() const navigate = useNavigate()
const role = auth_get_current_user_role() const role = auth_get_current_user_role()
const generatePlanAI = useGeneratePlanAI()
// const persistPlanFromAI = usePersistPlanFromAI()
const { const {
wizard, wizard,
@@ -54,7 +57,6 @@ export default function NuevoPlanModalContainer() {
canContinueDesdeModo, canContinueDesdeModo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,
generarPreviewIA,
} = useNuevoPlanWizard() } = useNuevoPlanWizard()
const handleClose = () => { const handleClose = () => {
@@ -62,15 +64,55 @@ export default function NuevoPlanModalContainer() {
} }
const crearPlan = async () => { const crearPlan = async () => {
setWizard((w: NewPlanWizardState) => ({ setWizard(
(w: NewPlanWizardState): NewPlanWizardState => ({
...w, ...w,
isLoading: true, isLoading: true,
errorMessage: null, errorMessage: null,
})) }),
)
try {
if (wizard.tipoOrigen === 'IA') {
const tipoCicloSafe = (wizard.datosBasicos.tipoCiclo ||
'Semestre') as any
const numCiclosSafe =
typeof wizard.datosBasicos.numCiclos === 'number'
? wizard.datosBasicos.numCiclos
: 1
const aiInput = {
datosBasicos: {
nombrePlan: wizard.datosBasicos.nombrePlan,
carreraId: wizard.datosBasicos.carreraId,
facultadId: wizard.datosBasicos.facultadId || undefined,
nivel: wizard.datosBasicos.nivel as string,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
estructuraPlanId: wizard.datosBasicos.estructuraPlanId as string,
},
iaConfig: {
descripcionEnfoque: wizard.iaConfig?.descripcionEnfoque || '',
notasAdicionales: wizard.iaConfig?.notasAdicionales || '',
archivosReferencia: wizard.iaConfig?.archivosReferencia || [],
repositoriosIds: wizard.iaConfig?.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig?.archivosAdjuntos || [],
},
}
const response = await generatePlanAI.mutateAsync(aiInput as any)
// const createdPlan = await persistPlanFromAI.mutateAsync({
// jsonPlan: generatedJson,
// })
// navigate({ to: `/planes/${createdPlan.id}` })
console.log('Plan generado por IA:', response)
return
}
// Fallback: comportamiento previo para otros modos (mock IDs)
await new Promise((r) => setTimeout(r, 900)) await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => { const nuevoId = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001' if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
if (wizard.tipoOrigen === 'IA') return 'plan_new_ai_001'
if ( if (
wizard.tipoOrigen === 'CLONADO_INTERNO' || wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL' wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
@@ -79,6 +121,15 @@ export default function NuevoPlanModalContainer() {
return 'plan_new_import_001' return 'plan_new_import_001'
})() })()
navigate({ to: `/planes/${nuevoId}` }) navigate({ to: `/planes/${nuevoId}` })
} catch (err: any) {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan con IA',
}))
} finally {
setWizard((w) => ({ ...w, isLoading: false }))
}
} }
return ( return (
@@ -164,7 +215,6 @@ export default function NuevoPlanModalContainer() {
<PasoDetallesPanel <PasoDetallesPanel
wizard={wizard} wizard={wizard}
onChange={setWizard} onChange={setWizard}
onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading} isLoading={wizard.isLoading}
/> />
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import type { NewPlanWizardState, PlanPreview } from "../types"; import type { NewPlanWizardState } from "../types";
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
export function useNuevoPlanWizard() { export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({ const [wizard, setWizard] = useState<NewPlanWizardState>({
@@ -81,45 +80,11 @@ export function useNuevoPlanWizard() {
return false; return false;
})(); })();
const generarPreviewIA = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
await new Promise((r) => setTimeout(r, 800));
// Ensure preview has the stricter types required by `PlanPreview`.
let tipoCicloSafe: TipoCiclo;
if (wizard.datosBasicos.tipoCiclo === "") {
tipoCicloSafe = "Semestre";
} else {
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
}
const numCiclosSafe: number =
typeof wizard.datosBasicos.numCiclos === "number"
? wizard.datosBasicos.numCiclos
: 1;
const preview: PlanPreview = {
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
numAsignaturasAprox: numCiclosSafe * 6,
secciones: [
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
],
};
setWizard((w: NewPlanWizardState) => ({
...w,
isLoading: false,
resumen: { previewPlan: preview },
}));
};
return { return {
wizard, wizard,
setWizard, setWizard,
canContinueDesdeModo, canContinueDesdeModo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,
generarPreviewIA,
}; };
} }