Harden LLM access: secrets only in server .env, no URL in repo.

Require LLM_BASE_URL and LLM_API_KEY for automatic generation, add per-user rate limits, stop publishing backend/LLM settings in docker-compose, and document secure deployment.
This commit is contained in:
Mireya Cueto Garrido
2026-06-04 13:24:40 +02:00
parent 182eae1e36
commit 4d2ced85a3
11 changed files with 487 additions and 169 deletions
+3 -2
View File
@@ -581,14 +581,15 @@ Todas con `Authorization: Bearer <token>`.
| Código | Ejemplo | | 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`). | | 422 | JSON del modelo inválido (`parse_error`). |
```json ```json
{ {
"error": { "error": {
"code": "llm_unavailable", "code": "llm_unavailable",
"message": "LLM_API_KEY is not configured" "message": "Automatic AI generation is not available"
} }
} }
``` ```
+374 -154
View File
@@ -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. <div align="center">
**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. </div>
## Stack <div align="center">
<strong>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.</strong>
</div>
---
## ![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 imagenpregunta 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 - FastAPI
- PostgreSQL
- SQLAlchemy - SQLAlchemy
- Cliente LLM para Sinbad2IA UJA (`POST /api/chat`, modelo `qwen3.5:35b`) - PostgreSQL
- Docker Compose con servicios `backend`, `frontend` y `db` - 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 ```bash
cp backend/.env.example backend/.env 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 ```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 <token>`). 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 ```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 Este proyecto es el resultado de la colaboración con la **Universidad de Jaén** (grupo Sinbad²).
Authorization: Bearer <access_token>
```
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 ### Direction
docker compose down -v * **Project Supervisor:** Luis Martínez López
docker compose up --build
```
## Flujo de Usuario ---
1. Registrarse o iniciar sesión. <p align="center">
2. Crear una plantilla de examen (queda asociada al usuario). Built with professional care and ❤️ for AI-assisted exam workflows at the University of Jaén.
3. Subir materiales de referencia (PDF, DOCX, TXT, PNG, JPG…) a la plantilla. </p>
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.
+7 -4
View File
@@ -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. # 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 GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
# --- LLM (Sinbad2IA UJA — sin clave) --- # --- LLM (solo servidor; NO commitear valores reales) ---
# URL base del servidor; el cliente llama a {LLM_BASE_URL}/api/chat # 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_BASE_URL=
LLM_API_KEY=
LLM_MODEL=qwen3.5:35b LLM_MODEL=qwen3.5:35b
LLM_TIMEOUT_SECONDS=180 LLM_TIMEOUT_SECONDS=180
# Opcional, solo si el servidor exige autenticación: # Límite por usuario (generación automática)
# LLM_API_KEY= LLM_GENERATE_RATE_LIMIT_REQUESTS=5
LLM_GENERATE_RATE_LIMIT_WINDOW_SECONDS=3600
+20
View File
@@ -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
+2 -1
View File
@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.api.dependencies import get_exam_service, get_llm_client 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.core.auth import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.exam import ( from app.schemas.exam import (
@@ -37,7 +38,7 @@ def build_prompt(
@router.post("/generate", response_model=ParsedQuestionsResponse) @router.post("/generate", response_model=ParsedQuestionsResponse)
async def generate_exam( async def generate_exam(
payload: GenerateExamRequest, 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)], service: Annotated[ExamService, Depends(get_exam_service)],
llm_client: Annotated[LLMClient, Depends(get_llm_client)], llm_client: Annotated[LLMClient, Depends(get_llm_client)],
) -> ParsedQuestionsResponse: ) -> ParsedQuestionsResponse:
+21 -1
View File
@@ -18,9 +18,22 @@ class Settings(BaseSettings):
rate_limit_window_seconds: int = Field(default=60, ge=1) rate_limit_window_seconds: int = Field(default=60, ge=1)
max_request_bytes: int = Field(default=1_048_576, ge=1_024) max_request_bytes: int = Field(default=1_048_576, ge=1_024)
llm_api_key: str | None = None 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_model: str = "qwen3.5:35b"
llm_timeout_seconds: int = Field(default=180, ge=5) 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_secret_key: str = Field(min_length=32)
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = Field(default=60 * 24, ge=5) jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
@@ -56,6 +69,13 @@ class Settings(BaseSettings):
def trusted_hosts_list(self) -> list[str]: def trusted_hosts_list(self) -> list[str]:
return [host.strip() for host in self.trusted_hosts.split(",") if host.strip()] 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 @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+5
View File
@@ -51,9 +51,14 @@ def error_payload(code: str, message: str, details: object | None = None) -> dic
def register_exception_handlers(app: FastAPI) -> None: def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError) @app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> ORJSONResponse: 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( return ORJSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content=error_payload(exc.code, exc.message), content=error_payload(exc.code, exc.message),
headers=headers,
) )
@app.exception_handler(StarletteHTTPException) @app.exception_handler(StarletteHTTPException)
+35
View File
@@ -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)
+3
View File
@@ -17,6 +17,9 @@ class LLMClient:
return f"{base}/api/chat" return f"{base}/api/chat"
async def generate(self, prompt: str) -> str: async def generate(self, prompt: str) -> str:
if not self.settings.llm_ready:
raise LLMUnavailableError("Automatic AI generation is not available")
payload = { payload = {
"model": self.settings.llm_model, "model": self.settings.llm_model,
"messages": [ "messages": [
+13
View File
@@ -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"
+4 -7
View File
@@ -11,11 +11,8 @@ services:
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-sinbad2.ujaen.es,localhost,127.0.0.1} TRUSTED_HOSTS: ${TRUSTED_HOSTS:-sinbad2.ujaen.es,localhost,127.0.0.1}
# Sobrescribe backend/.env con el origen público del frontend en despliegue. # 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} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069}
LLM_BASE_URL: expose:
LLM_MODEL: qwen3.5:35b - "8074"
LLM_TIMEOUT_SECONDS: "180"
ports:
- "${BACKEND_PORT:-8068}:8074"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -42,8 +39,8 @@ services:
POSTGRES_DB: genexamenes POSTGRES_DB: genexamenes
POSTGRES_USER: genexamenes POSTGRES_USER: genexamenes
POSTGRES_PASSWORD: genexamenes POSTGRES_PASSWORD: genexamenes
ports: expose:
- "5432:5432" - "5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck: