diff --git a/GUIA_API_Y_FLUJO.md b/GUIA_API_Y_FLUJO.md index f717f78..45c2503 100644 --- a/GUIA_API_Y_FLUJO.md +++ b/GUIA_API_Y_FLUJO.md @@ -581,14 +581,15 @@ Todas con `Authorization: Bearer `. | Código | Ejemplo | |--------|---------| -| 503 | `LLM_API_KEY` no configurada (`llm_unavailable`). | +| 503 | LLM no configurado en el servidor (`llm_unavailable`: faltan `LLM_BASE_URL` y/o `LLM_API_KEY` en `.env`). | +| 429 | Demasiadas generaciones automáticas por usuario (`llm_rate_limited`). | | 422 | JSON del modelo inválido (`parse_error`). | ```json { "error": { "code": "llm_unavailable", - "message": "LLM_API_KEY is not configured" + "message": "Automatic AI generation is not available" } } ``` diff --git a/README.md b/README.md index 82a00ef..54e43ca 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,405 @@ -# GenExamenes IA +# GenExámenes IA -Backend para generar exámenes con IA, procesar la salida de un LLM y exportar preguntas a Moodle XML. +
-**Guía detallada de flujo, endpoints, ejemplos y errores:** [GUIA_API_Y_FLUJO.md](GUIA_API_Y_FLUJO.md) +![FastAPI](https://img.shields.io/badge/FastAPI-109989?style=for-the-badge&logo=fastapi&logoColor=white) +![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white) +![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) +![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-0db7ed?style=for-the-badge&logo=docker&logoColor=white) +![Moodle](https://img.shields.io/badge/Moodle_XML-FF7800?style=for-the-badge) +![LLM](https://img.shields.io/badge/Sinbad2IA-LLM-6C63FF?style=for-the-badge) -El proyecto está centrado en backend. La carpeta `frontend` se mantiene vacía a nivel de aplicación, aunque existe un servicio en Docker Compose para reservar el despliegue futuro. +
-## Stack +
+ Plataforma full-stack para diseñar exámenes con IA, gestionar materiales de contexto y exportar bancos de preguntas a Moodle XML, TXT y JSON. +
+--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Overview: What is this project meant + +`GenExámenes IA` está pensado para flujos docentes en los que hay que crear evaluaciones estructuradas, apoyarse en un LLM y exportar el resultado a Moodle. + +Capacidades principales: + +- Registro e inicio de sesión (email/contraseña y Google opcional) +- Creación de plantillas de examen (tipos de pregunta, dificultad, barajar, feedback) +- Subida de **Material IA** (PDF, DOCX, TXT, MD…) con extracción de texto para contexto +- Subida de **imágenes** para preguntas visuales (embebidas en Moodle XML) +- Generación de preguntas: **automática** (LLM en servidor), **solo prompt** o **importar JSON/TXT** +- Revisión, vinculación imagen–pregunta y exportación **Moodle XML**, TXT o JSON +- Cupo de almacenamiento por examen y controles de seguridad en producción + +> [!NOTE] +> El despliegue en Sinbad2 (UJA) sigue el patrón **orcid2sword**: HTTPS en Apache, contenedores en HTTP en puertos internos (`8069` frontend, `8068` backend). Ver [deploy/DESPLIEGUE_SINBAD2.md](deploy/DESPLIEGUE_SINBAD2.md). + +**URL pública (producción):** `https://sinbad2.ujaen.es/generadorexamenesllm/` + +**Guía ampliada de API, flujo y errores:** [GUIA_API_Y_FLUJO.md](GUIA_API_Y_FLUJO.md) + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Tech Stack + +### Backend - FastAPI -- PostgreSQL - SQLAlchemy -- Cliente LLM para Sinbad2IA UJA (`POST /api/chat`, modelo `qwen3.5:35b`) -- Docker Compose con servicios `backend`, `frontend` y `db` +- PostgreSQL +- python-jose (JWT) +- bcrypt (contraseñas) +- httpx (cliente LLM Sinbad2IA) +- pypdf / python-docx / Pillow / pytesseract (extracción de materiales) -## Puesta en Marcha +### Frontend +- React 18 +- Vite 5 +- React Router 6 +- Axios +- CSS propio (UI responsive, menú móvil) -Copia el ejemplo de variables dentro de la carpeta del backend: +### Infrastructure +- Docker / Docker Compose +- Nginx (contenedor frontend: SPA + proxy `/auth` y `/exam`) +- Apache reverse proxy (HTTPS en Sinbad2, fuera del repo) + +### IA +- Sinbad2IA UJA — `POST {LLM_BASE_URL}/api/chat` (modelo por defecto `qwen3.5:35b`) + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Quick Start + +Desde la raíz del proyecto: ```bash cp backend/.env.example backend/.env +# Edita backend/.env: LLM_BASE_URL y LLM_API_KEY (solo en el servidor, no en Git) +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build ``` -Después levanta los servicios: +URLs por defecto con Docker Compose: + +| Servicio | URL local | +| :--------- | :--------------------------------- | +| Frontend | `http://localhost:8069` | +| Frontend (ruta pública) | `http://localhost:8069/generadorexamenesllm/` | +| Backend | `http://localhost:8068` | +| PostgreSQL | `localhost:5432` | + +Desarrollo del frontend sin Docker (solo UI): ```bash -docker compose up --build +cd frontend +npm install +npm run dev ``` -La API queda disponible en: +Vite escucha en `http://localhost:8075` (configurable en `vite.config.js`). + +> [!IMPORTANT] +> Las rutas bajo `/exam` requieren JWT de usuario (`Authorization: Bearer `). El material y las imágenes son **opcionales** para generar; basta el campo «Tema / instrucciones para la IA» (mínimo 5 caracteres). + +> [!IMPORTANT] +> **Seguridad del LLM:** la URL y la clave del servicio de IA **no** están en el repositorio. `POST /exam/generate` solo funciona si el servidor tiene `LLM_BASE_URL` y `LLM_API_KEY` en `backend/.env`. El backend no se publica en el host en producción (solo nginx en `:8069`). Quien clone el repo **no** puede llamar al LLM sin esas variables en el despliegue. + +> [!TIP] +> Si la generación automática devuelve 503 (*Automatic AI generation is not available*), faltan variables LLM en el servidor o el servicio no es alcanzable desde el contenedor. Usa **Solo prompt** → LLM externo → **Pegar respuesta IA**. + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Environment Configuration + +**Backend** +- Archivo principal: `backend/.env` +- Referencia: `backend/.env.example` + +**Frontend** +- Build Docker / ejemplo: `frontend/.env.example` +- Variables inyectadas en build: `VITE_APP_BASE_PATH`, `VITE_API_URL`, `VITE_GOOGLE_CLIENT_ID` + +Variables backend importantes: + +| Variable | Descripción | +| :------- | :---------- | +| `JWT_SECRET_KEY` | Secreto JWT (≥ 32 caracteres) | +| `JWT_EXPIRE_MINUTES` | Duración del token | +| `GOOGLE_CLIENT_ID` | OAuth Google (`POST /auth/google`) | +| `DATABASE_URL` | PostgreSQL | +| `ALLOWED_ORIGINS` | Orígenes CORS (HTTPS Sinbad2) | +| `TRUSTED_HOSTS` | Hosts permitidos (`TrustedHostMiddleware`) | +| `PUBLIC_BASE_URL` | URL pública con prefijo de despliegue | +| `LLM_BASE_URL` | URL interna del LLM (**solo** en `.env` del servidor, no en el repo) | +| `LLM_API_KEY` | Clave para autorizar llamadas al LLM (**obligatoria** para generación automática) | +| `LLM_MODEL` | Modelo (p. ej. `qwen3.5:35b`) | +| `LLM_TIMEOUT_SECONDS` | Timeout llamada LLM (180 s por defecto) | +| `LLM_GENERATE_RATE_LIMIT_*` | Cuota de `POST /exam/generate` por usuario | +| `MAX_STORAGE_BYTES_PER_TEMPLATE` | Cupo materiales + imágenes por examen | + +Variables frontend importantes: + +| Variable | Descripción | +| :------- | :---------- | +| `VITE_APP_BASE_PATH` | Prefijo SPA (`/generadorexamenesllm/` en producción) | +| `VITE_API_URL` | Vacío en producción: API en el mismo origen vía nginx | +| `VITE_GOOGLE_CLIENT_ID` | Debe coincidir con `GOOGLE_CLIENT_ID` del backend | + +> [!WARNING] +> No subas secretos reales al repositorio. Rota `JWT_SECRET_KEY` y credenciales de Google antes de producción. + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) API Endpoints + +Base backend (Docker local): `http://localhost:8068` + +Todas las rutas `/exam/*` requieren **Bearer JWT** salvo que se indique lo contrario. + +| Módulo | Método | Endpoint | Auth | Notas | +| :----- | :----: | :------- | :--- | :---- | +| Health | `GET` | `/health` | Ninguna | Liveness | +| Auth | `POST` | `/auth/register` | Ninguna | Email + contraseña | +| Auth | `POST` | `/auth/login` | Ninguna | Devuelve `access_token` | +| Auth | `POST` | `/auth/google` | Ninguna | Body: `{ "id_token": "..." }` | +| Auth | `GET` | `/auth/me` | Bearer | Usuario actual | +| Plantillas | `POST` | `/exam/templates` | Bearer | Crear examen | +| Plantillas | `GET` | `/exam/templates` | Bearer | Listar del usuario | +| Plantillas | `GET` | `/exam/templates/{id}` | Bearer | Detalle | +| Plantillas | `GET` | `/exam/templates/{id}/storage` | Bearer | Uso de almacenamiento | +| Materiales | `POST` | `/exam/templates/{id}/materials` | Bearer | `multipart/form-data` | +| Materiales | `GET` | `/exam/templates/{id}/materials` | Bearer | Listado | +| Imágenes | `POST` | `/exam/templates/{id}/images` | Bearer | Imagen para preguntas | +| Imágenes | `GET` | `/exam/images/{image_id}/content` | Bearer | Contenido binario | +| Preguntas | `GET` | `/exam/templates/{id}/questions` | Bearer | Listado | +| Preguntas | `PATCH` | `/exam/questions/{id}/image` | Bearer | Vincular imagen | +| IA | `POST` | `/exam/prompts/{template_id}` | Bearer | Construir prompt | +| IA | `POST` | `/exam/generate` | Bearer | LLM + guardar preguntas | +| IA | `POST` | `/exam/parse` | Bearer | Importar JSON/TXT externo | +| Export | `GET` | `/exam/export/xml/{template_id}` | Bearer | Moodle XML | +| Export | `GET` | `/exam/export/txt/{template_id}` | Bearer | Texto plano | +| Export | `GET` | `/exam/export/json/{template_id}` | Bearer | JSON | +| Historial | `GET` | `/exam/history` | Bearer | Resumen de exámenes | + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Request Examples + +### Health +```bash +curl http://localhost:8068/health +``` + +### Registro e inicio de sesión +```bash +curl -X POST "http://localhost:8068/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"docente@ujaen.es\",\"password\":\"MiClaveSegura1\",\"password_confirm\":\"MiClaveSegura1\"}" + +curl -X POST "http://localhost:8068/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"docente@ujaen.es\",\"password\":\"MiClaveSegura1\"}" +``` + +### Crear plantilla de examen +```bash +curl -X POST "http://localhost:8068/exam/templates" \ + -H "Authorization: Bearer YOUR_JWT" \ + -H "Content-Type: application/json" \ + -d "{ + \"title\": \"Examen Tema 3\", + \"subject\": \"Sistemas Operativos\", + \"educational_level\": \"Grado\", + \"language\": \"es\", + \"settings\": { + \"question_types\": [{\"type\": \"multichoice\", \"count\": 5, \"score\": 1, \"penalty\": 0}], + \"shuffle_questions\": true, + \"shuffle_answers\": true, + \"include_feedback\": true + }, + \"difficulty_profile\": {\"easy\": 2, \"medium\": 3, \"hard\": 0, \"very_hard\": 0} + }" +``` + +### Generar preguntas (solo con tema, sin materiales) +```bash +curl -X POST "http://localhost:8068/exam/generate" \ + -H "Authorization: Bearer YOUR_JWT" \ + -H "Content-Type: application/json" \ + -d "{ + \"template_id\": \"TEMPLATE_UUID\", + \"topic_prompt\": \"Genera preguntas sobre aritmética y trigonometría\", + \"material_ids\": null + }" +``` + +### Exportar Moodle XML +```bash +curl "http://localhost:8068/exam/export/xml/TEMPLATE_UUID" \ + -H "Authorization: Bearer YOUR_JWT" \ + -o examen_moodle.xml +``` + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Security Controls + +Controles implementados en backend: + +- CORS con lista de orígenes permitidos +- filtrado de hosts de confianza (`TrustedHostMiddleware`) +- cabeceras de seguridad (HSTS en producción) +- límite de tamaño de petición +- rate limiting por cliente +- **LLM solo desde el servidor:** credenciales en `.env`, sin URL en el código público +- **`POST /exam/generate`:** exige JWT + `LLM_API_KEY` configurada + límite por usuario +- backend **no expuesto** en `docker-compose.yml` (solo red interna Docker; Apache → frontend) +- contraseñas con bcrypt +- JWT por usuario; cada plantilla pertenece a un único usuario +- sanitización de prompts y texto persistido +- contenedores sin privilegios innecesarios + +> [!WARNING] +> En producción, fuerza HTTPS detrás de Apache y mantén `ALLOWED_ORIGINS` y `TRUSTED_HOSTS` alineados con `https://sinbad2.ujaen.es`. El servicio LLM de la UJA debería aceptar peticiones **solo** desde la red del backend y exigir `LLM_API_KEY` (firewall + clave en el propio Sinbad2IA). + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Frontend Details + +SPA React con rutas protegidas y cliente API centralizado (`frontend/src/api/client.js`). + +### Rutas principales + +| Ruta | Página | +| :--- | :----- | +| `/login` | Inicio de sesión | +| `/registro` | Alta de usuario | +| `/` | Panel «Mis exámenes» | +| `/plantillas/nueva` | Asistente «Nuevo examen» | +| `/plantillas/:templateId` | Detalle con pestañas: Resumen, Material IA, Imágenes, Generar, Preguntas, Exportar | + +### Pestaña Generar (tres modos) + +- **Generación automática:** `POST /exam/generate` — llama al LLM del servidor. +- **Solo prompt:** `POST /exam/prompts/{id}` — copiar prompt a un LLM externo. +- **Pegar respuesta IA:** `POST /exam/parse` — importar JSON o TXT. + +### Cliente API (`frontend/src/api/`) + +- `client.js` — Axios, JWT en `localStorage`, errores normalizados (`ApiError`) +- `auth.js`, `templates.js`, `materials.js`, `images.js`, `generation.js`, `exports.js`, `questions.js` + +### Nginx en Docker (`frontend/nginx.conf`) + +- Sirve la SPA bajo `/` y `/generadorexamenesllm/` +- Proxy de `/auth/` y `/exam/` al backend (`backend:8074` en red interna) +- Redirección de raíz sin barra final al login + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Project Structure ```text -http://sinbad2.ujaen.es:8068 +03_GeneradorExamenes/ +├── backend/ +│ ├── app/ +│ │ ├── api/ +│ │ │ ├── routes/ +│ │ │ │ ├── auth.py +│ │ │ │ ├── templates.py +│ │ │ │ ├── generation.py +│ │ │ │ ├── materials.py +│ │ │ │ ├── images.py +│ │ │ │ ├── questions.py +│ │ │ │ ├── exports.py +│ │ │ │ └── history.py +│ │ │ └── dependencies.py +│ │ ├── core/ +│ │ │ ├── config.py +│ │ │ ├── auth.py +│ │ │ ├── errors.py +│ │ │ ├── middleware.py +│ │ │ └── security_headers.py +│ │ ├── db/ +│ │ ├── models/ +│ │ ├── schemas/ +│ │ ├── services/ +│ │ │ ├── exam_service.py +│ │ │ ├── llm.py +│ │ │ ├── prompt_builder.py +│ │ │ ├── parser.py +│ │ │ ├── material_service.py +│ │ │ ├── image_service.py +│ │ │ └── moodle_exporter.py +│ │ └── main.py +│ ├── .env.example +│ ├── Dockerfile +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── api/ +│ │ ├── components/ +│ │ ├── context/ +│ │ ├── pages/ +│ │ │ ├── DashboardPage.jsx +│ │ │ ├── CreateTemplatePage.jsx +│ │ │ ├── TemplateDetailPage.jsx +│ │ │ └── template/ +│ │ │ ├── GenerateTab.jsx +│ │ │ ├── MaterialsTab.jsx +│ │ │ └── ExportTab.jsx +│ │ ├── App.jsx +│ │ └── main.jsx +│ ├── nginx.conf +│ ├── .env.example +│ ├── package.json +│ └── vite.config.js +├── deploy/ +│ ├── DESPLIEGUE_SINBAD2.md +│ └── apache-reverse-proxy.conf +├── docs/ +│ └── Manual_de_Usuario_GenExamenes_IA.docx +├── scripts/ +│ └── generar_manual_usuario.py +├── docker-compose.yml +├── GUIA_API_Y_FLUJO.md +└── README.md ``` -## Configuración +--- -El archivo de entorno debe estar en `backend/.env`. +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Production Checklist -Variables principales: +- [ ] `ENVIRONMENT=production` +- [ ] Rotar `JWT_SECRET_KEY` y `GOOGLE_CLIENT_ID` de desarrollo +- [ ] `ALLOWED_ORIGINS` con orígenes HTTPS reales +- [ ] `TRUSTED_HOSTS` con `sinbad2.ujaen.es` +- [ ] `PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm` +- [ ] Apache `ProxyPass` a puerto **8069** (frontend), no al backend directo +- [ ] Reglas Apache **antes** de WordPress en Sinbad2 +- [ ] `LLM_BASE_URL` y `LLM_API_KEY` solo en `backend/.env` del servidor (no en Git) +- [ ] Comprobar conectividad backend → LLM (red interna UJA) +- [ ] Despliegue con `docker compose up` **sin** `docker-compose.dev.yml` (backend no publicado en host) +- [ ] Volumen `uploads_data` y backups de PostgreSQL +- [ ] Manual de usuario actualizado en `docs/` -- `JWT_SECRET_KEY`: secreto para firmar tokens JWT (mínimo 32 caracteres). -- `JWT_EXPIRE_MINUTES`: duración del token de acceso. -- `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`). -- `DATABASE_URL`: conexión PostgreSQL usada por el backend. -- `LLM_BASE_URL`: URL base del servidor (por defecto ``; el cliente usa `/api/chat`). -- `LLM_MODEL`: modelo (por defecto `qwen3.5:35b`). -- `LLM_TIMEOUT_SECONDS`: tiempo máximo de espera (por defecto 180 s). -- `LLM_API_KEY`: opcional, solo si el servidor exige autenticación. -- `ALLOWED_ORIGINS`: orígenes permitidos por CORS. -- `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes). +--- -Todas las rutas bajo `/exam` requieren autenticación de usuario con: +## ![github](https://www.readmecodegen.com/api/social-icon?name=github&size=20&color=%238b5cf6) Authors and Team -```http -Authorization: Bearer -``` +Este proyecto es el resultado de la colaboración con la **Universidad de Jaén** (grupo Sinbad²). -Si ya tenías una base de datos creada antes de añadir usuarios, recrea el volumen: +| Rol | Developer | GitHub | +| :--- | :--- | :--- | +| **Frontend** | Alexis López Moral | [@AlexisLopez-Dev](https://github.com/AlexisLopez-Dev) | +| **Backend** | Mireya Cueto Garrido | [@MireyaCueto](https://github.com/MireyaCueto) | -```bash -docker compose down -v -docker compose up --build -``` +### Direction +* **Project Supervisor:** Luis Martínez López -## Flujo de Usuario +--- -1. Registrarse o iniciar sesión. -2. Crear una plantilla de examen (queda asociada al usuario). -3. Subir materiales de referencia (PDF, DOCX, TXT, PNG, JPG…) a la plantilla. -4. Generar un prompt guiado para el LLM (incluye el texto extraído de los ficheros). -5. Generar preguntas automáticamente con el LLM o parsear una salida externa en JSON/TXT. -6. Guardar las preguntas validadas en PostgreSQL. -7. Consultar el historial de exámenes creados. -8. Exportar el examen a Moodle XML, TXT o JSON. - -## Endpoints - -`GET /health` - -Comprueba que la API está levantada. - -`POST /auth/register` - -Registra un usuario con email y contraseña. - -`POST /auth/login` - -Devuelve un token JWT para usar en las rutas protegidas. - -`POST /auth/google` - -Recibe el `id_token` de Google (Sign in with Google en el frontend), verifica la cuenta y devuelve el mismo JWT de la API. - -`GET /auth/me` - -Devuelve los datos del usuario autenticado. - -`GET /exam/history` - -Lista el historial de exámenes del usuario (plantillas, preguntas y exportaciones). - -`POST /exam/templates/{template_id}/materials` - -Sube un fichero (`multipart/form-data`, campo `file`). Formatos: PDF, DOCX, TXT, MD, PNG, JPG, WEBP. Extrae texto y lo guarda como contexto. - -`GET /exam/templates/{template_id}/materials` - -Lista los materiales subidos a una plantilla. - -`DELETE /exam/templates/{template_id}/materials/{material_id}` - -Elimina un material. - -`POST /exam/templates/{template_id}/images` - -Sube una imagen para preguntas visuales (`file`, opcional `caption`). No se usa OCR: la imagen se muestra en el examen y se embebe en el XML de Moodle. - -`GET /exam/templates/{template_id}/images` - -Lista las imágenes de la plantilla. - -`GET /exam/images/{image_id}/content` - -Devuelve la imagen (requiere JWT). Para previsualizar en el frontend o en Moodle tras importar. - -`DELETE /exam/templates/{template_id}/images/{image_id}` - -Elimina una imagen. - -`PATCH /exam/questions/{question_id}/image` - -Vincula o desvincula una imagen a una pregunta existente (`{"image_id": "uuid"}` o `null`). - -`POST /exam/templates` - -Crea una plantilla con materia, nivel educativo, tipos de pregunta, puntuación, penalización y dificultad. - -`GET /exam/templates` - -Lista las plantillas del usuario autenticado. - -`GET /exam/templates/{template_id}` - -Obtiene una plantilla concreta. - -`GET /exam/templates/{template_id}/storage` - -Muestra cuánto espacio usa el examen (materiales + imágenes) y el límite configurado. - -`POST /exam/prompts/{template_id}` - -Genera un prompt estructurado para IA. - -`POST /exam/generate` - -Llama al LLM configurado, parsea la respuesta y guarda las preguntas. - -`POST /exam/parse` - -Procesa una salida externa de IA en formato `json` o `txt`. - -`GET /exam/export/xml/{template_id}` - -Exporta las preguntas en Moodle XML. - -`GET /exam/export/txt/{template_id}` - -Exporta las preguntas en texto plano. - -`GET /exam/export/json/{template_id}` - -Exporta las preguntas en JSON. - -## Seguridad - -- Registro e inicio de sesión con contraseña hasheada (bcrypt). -- Autenticación JWT por usuario. -- Cada examen pertenece a un único usuario; no se puede acceder al de otro. -- Rate limiting por cliente. -- Límite de tamaño de petición. -- Validación de entrada con Pydantic. -- Manejo uniforme de errores HTTP. -- Sanitización básica de prompts y respuestas antes de persistir/exportar. +

+ Built with professional care and ❤️ for AI-assisted exam workflows at the University of Jaén. +

diff --git a/backend/.env.example b/backend/.env.example index 401f518..667b38b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -48,10 +48,13 @@ JWT_EXPIRE_MINUTES=1440 # El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google. GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com -# --- LLM (Sinbad2IA UJA — sin clave) --- -# URL base del servidor; el cliente llama a {LLM_BASE_URL}/api/chat +# --- LLM (solo servidor; NO commitear valores reales) --- +# Obligatorias para POST /exam/generate. Sin ellas, la generación automática queda desactivada. +# La URL no debe aparecer en el repositorio: configúrala solo en el .env del servidor. LLM_BASE_URL= +LLM_API_KEY= LLM_MODEL=qwen3.5:35b LLM_TIMEOUT_SECONDS=180 -# Opcional, solo si el servidor exige autenticación: -# LLM_API_KEY= +# Límite por usuario (generación automática) +LLM_GENERATE_RATE_LIMIT_REQUESTS=5 +LLM_GENERATE_RATE_LIMIT_WINDOW_SECONDS=3600 diff --git a/backend/app/api/llm_guard.py b/backend/app/api/llm_guard.py new file mode 100644 index 0000000..ef97e5b --- /dev/null +++ b/backend/app/api/llm_guard.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi import Depends + +from app.core.auth import get_current_user +from app.core.config import Settings, get_settings +from app.core.errors import LLMUnavailableError +from app.core.llm_rate_limit import enforce_llm_rate_limit +from app.models.user import User + + +def require_llm_generation( + settings: Annotated[Settings, Depends(get_settings)], + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + """Solo permite generación automática si el LLM está configurado por entorno (no en el repo).""" + if not settings.llm_ready: + raise LLMUnavailableError("Automatic AI generation is not available") + enforce_llm_rate_limit(current_user.id, settings) + return current_user diff --git a/backend/app/api/routes/generation.py b/backend/app/api/routes/generation.py index e7a63c2..2e90760 100644 --- a/backend/app/api/routes/generation.py +++ b/backend/app/api/routes/generation.py @@ -4,6 +4,7 @@ from typing import Annotated from fastapi import APIRouter, Depends from app.api.dependencies import get_exam_service, get_llm_client +from app.api.llm_guard import require_llm_generation from app.core.auth import get_current_user from app.models.user import User from app.schemas.exam import ( @@ -37,7 +38,7 @@ def build_prompt( @router.post("/generate", response_model=ParsedQuestionsResponse) async def generate_exam( payload: GenerateExamRequest, - current_user: Annotated[User, Depends(get_current_user)], + current_user: Annotated[User, Depends(require_llm_generation)], service: Annotated[ExamService, Depends(get_exam_service)], llm_client: Annotated[LLMClient, Depends(get_llm_client)], ) -> ParsedQuestionsResponse: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 28c713f..8d3cc8f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -18,9 +18,22 @@ class Settings(BaseSettings): rate_limit_window_seconds: int = Field(default=60, ge=1) max_request_bytes: int = Field(default=1_048_576, ge=1_024) llm_api_key: str | None = None - llm_base_url: str = "" + llm_base_url: str = Field( + default="", + description="URL base del LLM (solo servidor). No incluir en el repositorio.", + ) llm_model: str = "qwen3.5:35b" llm_timeout_seconds: int = Field(default=180, ge=5) + llm_generate_rate_limit_requests: int = Field( + default=5, + ge=1, + description="Máximo de POST /exam/generate por usuario y ventana.", + ) + llm_generate_rate_limit_window_seconds: int = Field( + default=3600, + ge=60, + description="Ventana en segundos para el límite de generación con LLM.", + ) jwt_secret_key: str = Field(min_length=32) jwt_algorithm: str = "HS256" jwt_expire_minutes: int = Field(default=60 * 24, ge=5) @@ -56,6 +69,13 @@ class Settings(BaseSettings): def trusted_hosts_list(self) -> list[str]: return [host.strip() for host in self.trusted_hosts.split(",") if host.strip()] + @property + def llm_ready(self) -> bool: + """True solo si URL y clave del LLM están definidas en el entorno del servidor.""" + return bool(self.llm_base_url.strip()) and bool( + self.llm_api_key and self.llm_api_key.strip() + ) + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 6afce1a..c899b0f 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -51,9 +51,14 @@ def error_payload(code: str, message: str, details: object | None = None) -> dic def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(AppError) async def app_error_handler(_: Request, exc: AppError) -> ORJSONResponse: + headers: dict[str, str] | None = None + retry_after = getattr(exc, "retry_after", None) + if retry_after is not None: + headers = {"Retry-After": str(retry_after)} return ORJSONResponse( status_code=exc.status_code, content=error_payload(exc.code, exc.message), + headers=headers, ) @app.exception_handler(StarletteHTTPException) diff --git a/backend/app/core/llm_rate_limit.py b/backend/app/core/llm_rate_limit.py new file mode 100644 index 0000000..bcd0291 --- /dev/null +++ b/backend/app/core/llm_rate_limit.py @@ -0,0 +1,35 @@ +import time +from collections import defaultdict, deque +from threading import Lock +from uuid import UUID + +from app.core.config import Settings +from app.core.errors import AppError + +_lock = Lock() +_buckets: dict[str, deque[float]] = defaultdict(deque) + + +class LLMRateLimitError(AppError): + def __init__(self, retry_after: int) -> None: + super().__init__( + message="Too many AI generation requests. Try again later.", + status_code=429, + code="llm_rate_limited", + ) + self.retry_after = retry_after + + +def enforce_llm_rate_limit(user_id: UUID, settings: Settings) -> None: + key = str(user_id) + now = time.monotonic() + limit = settings.llm_generate_rate_limit_requests + window = settings.llm_generate_rate_limit_window_seconds + + with _lock: + bucket = _buckets[key] + while bucket and now - bucket[0] > window: + bucket.popleft() + if len(bucket) >= limit: + raise LLMRateLimitError(retry_after=window) + bucket.append(now) diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 01b5cd5..31c1f1d 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -17,6 +17,9 @@ class LLMClient: return f"{base}/api/chat" async def generate(self, prompt: str) -> str: + if not self.settings.llm_ready: + raise LLMUnavailableError("Automatic AI generation is not available") + payload = { "model": self.settings.llm_model, "messages": [ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..cc2f57e --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,13 @@ +# Desarrollo local: publica backend y PostgreSQL solo en loopback. +# Uso: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build +# +# Configura LLM_BASE_URL y LLM_API_KEY en backend/.env (no subir al repositorio). + +services: + backend: + ports: + - "127.0.0.1:8068:8074" + + db: + ports: + - "127.0.0.1:5432:5432" diff --git a/docker-compose.yml b/docker-compose.yml index 942ba7f..3cb526b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,11 +11,8 @@ services: TRUSTED_HOSTS: ${TRUSTED_HOSTS:-sinbad2.ujaen.es,localhost,127.0.0.1} # Sobrescribe backend/.env con el origen público del frontend en despliegue. ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069} - LLM_BASE_URL: - LLM_MODEL: qwen3.5:35b - LLM_TIMEOUT_SECONDS: "180" - ports: - - "${BACKEND_PORT:-8068}:8074" + expose: + - "8074" depends_on: db: condition: service_healthy @@ -42,8 +39,8 @@ services: POSTGRES_DB: genexamenes POSTGRES_USER: genexamenes POSTGRES_PASSWORD: genexamenes - ports: - - "5432:5432" + expose: + - "5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: