# OnAPB Genius Agent — Spec de Diseño

**Fecha:** 2026-04-09  
**Rama:** `feature/genius-agent`  
**Stack:** Laravel 12, Prism PHP (prism-php/prism), Google Gemini 1.5 Flash, MySQL, Alpine.js

---

## 1. Objetivo

Implementar un agente de IA conversacional ("OnAPB Genius") integrado en onapb.com con dos modos de operación según el rol del usuario:

- **Público** (no logueado, aficionado, jugador): asistente de navegación y consultas usando el `MANUAL_USUARIO.md` como contexto RAG simplificado. Sin tools. Sin persistencia entre sesiones.
- **Admin** (SuperAdmin rol=1 / GeneralAdmin rol=2): automatización de tareas mediante function calling (Tools). Historial persistente en MySQL, auto-purgado a los 30 días.

---

## 2. Decisiones de Arquitectura

| Decisión | Elección | Razón |
|---|---|---|
| SDK de AI | `prism-php/prism` | Soporte probado de Gemini 1.5 Flash + tools. Más estable que `laravel/ai` (nuevo). |
| Modelo | `gemini-1.5-flash` | Velocidad (2–5s), costo bajo, function calling. |
| Streaming | No — JSON completo | Hosting compartido Hostinger con límites PHP desconocidos. Evita problemas de output buffer. |
| Memoria admin | MySQL (`agent_threads`) | Sin Redis. JSON column para mensajes. Purge automático 30 días. |
| Memoria público | Session PHP | Stateless entre sesiones. Simple, sin overhead de BD. |
| Tools | Solo si admin_logged_in | Validado en `GeniusAgentService`, no en Gemini. |

---

## 3. Estructura de Archivos

```
app/
├── AI/
│   ├── Tools/
│   │   ├── CrearPartidoTool.php
│   │   ├── CargarPuntajeTool.php
│   │   ├── RedactarNoticiaTool.php
│   │   ├── ListarEquiposTool.php
│   │   └── ListarEventosTool.php
│   └── Prompts/
│       ├── SystemPromptAdmin.php
│       └── SystemPromptPublic.php
├── Services/
│   └── GeniusAgentService.php
├── Http/Controllers/
│   └── GeniusAgentController.php
├── Models/
│   └── AgentThread.php
└── Console/Commands/
    └── PurgeAgentThreads.php

database/migrations/
└── xxxx_create_agent_threads_table.php

resources/views/components/
└── genius-chat.blade.php

routes/web.php
└── POST /agent/chat  (throttle: 20/min por IP)
```

---

## 4. Schema de Base de Datos

```sql
CREATE TABLE agent_threads (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    thread_id   VARCHAR(36) NOT NULL UNIQUE,   -- UUID generado en frontend
    admin_id    INT NOT NULL,                  -- session('admin_id')
    messages    JSON NOT NULL,                 -- array [{role, content}]
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    expires_at  TIMESTAMP NOT NULL             -- created_at + 30 días
);
```

---

## 5. Tools Disponibles (Admin)

Cada tool implementa la interfaz Prism `Tool`. El agent loop permite que Gemini llame la misma tool N veces para operaciones batch (ej. cargar puntajes de todos los partidos de una jornada).

| Tool | Parámetros | Acción en BD |
|---|---|---|
| `CrearPartidoTool` | `id_equipo_local`, `id_equipo_visitante`, `fecha_evento`, `hora_inicio`, `hora_fin`, `sede`, `id_torneo` | `Evento::create(...)` |
| `CargarPuntajeTool` | `id_evento`, `marcador_local`, `marcador_visitante` | `Evento::find()->update(...)` |
| `RedactarNoticiaTool` | `titulo`, `contenido`, `id_torneo?`, `categoria?` | `Noticia::create(...)` |
| `ListarEquiposTool` | `id_torneo?`, `id_club?`, `grupo?` | `Equipo::query()` con join a pivot `torneo_equipo` filtrando por `grupo` (solo lectura) |
| `ListarEventosTool` | `fecha_desde?`, `fecha_hasta?`, `id_torneo?` | `Evento::query()->get()` (solo lectura) |

**Nota sobre `grupo`:** No es un modelo separado. Es una columna en la tabla pivot `torneo_equipo` (relación `Torneo::equipos()->withPivot('grupo')`). `ListarEquiposTool` filtra con `wherePivot('grupo', $grupo)`.

**Agregar una nueva tool en el futuro:**
1. Crear `app/AI/Tools/NuevaTool.php`
2. Registrarla en `GeniusAgentService::getAdminTools()`

---

## 6. Flujo de Datos

### Usuario público
```
POST /agent/chat { message }
→ session()->get('agent_messages', [])
→ GeniusAgentService::chatPublic(message, history)
  → System prompt: navegación + MANUAL_USUARIO.md completo como contexto (el archivo es pequeño; si crece >50KB usar solo las primeras 200 líneas)
  → Prism → Gemini (sin tools)
  → Respuesta de texto
→ session()->put('agent_messages', [...])
→ return JSON { reply }
```

### Admin
```
POST /agent/chat { message, thread_id? }
→ AgentThread::findOrCreate(thread_id, admin_id)
→ GeniusAgentService::chatAdmin(message, thread)
  → System prompt: automatización de tareas OnAPB
  → Prism → Gemini con tools
    → [si Gemini llama tool] → Tool::handle() → resultado
    → [Gemini puede llamar N tools] → agent loop
    → Respuesta final de texto
→ thread->appendMessages([...])
→ thread->save()
→ return JSON { reply, thread_id }
```

---

## 7. Seguridad

- **Tools bloqueadas a no-admins:** `GeniusAgentService` valida `session('admin_logged_in')` antes de incluir tools.
- **Rate limiting:** `throttle:20,1` en la ruta `/agent/chat`.
- **Validación de input:** `message` requerido, string, máx. 1000 caracteres.
- **Aislamiento de threads:** cada thread valida `admin_id = session('admin_id')`. No hay acceso cruzado entre admins.
- **API key:** solo en `.env` (`GEMINI_API_KEY`). Nunca en código ni en BD.

---

## 8. Manejo de Errores

| Caso | Comportamiento |
|---|---|
| Gemini timeout / 5xx | `catch RequestException` → JSON `{ error: "El agente no responde, reintentá en un momento." }` |
| Tool falla (ej. evento no existe) | Tool retorna `{ error: "..." }` → Gemini lo incorpora en su respuesta |
| Thread expirado / no encontrado | Se crea un nuevo thread automáticamente |
| API key inválida / quota excedida | Log en Laravel + respuesta genérica (sin exponer detalles de la API) |
| Timeout de Hostinger | `set_time_limit(120)` al inicio del controller action |

---

## 9. Purge de Threads

Comando `php artisan agent:purge-threads` que elimina `agent_threads` donde `expires_at < NOW()`.

**Schedule:** Se registra en `routes/console.php` para ejecutarse diariamente. En Hostinger sin queue worker, se puede configurar como cron en hPanel:

```
# Reemplazar /home/TU_USUARIO/public_html con la ruta real de tu cuenta Hostinger
0 3 * * * cd /home/TU_USUARIO/public_html && php artisan agent:purge-threads >> /dev/null 2>&1
```

---

## 10. Frontend — Chat Bubble

Componente Blade `genius-chat.blade.php` incluido al final de `resources/views/layouts/app.blade.php`:

- Botón flotante (bottom-right) con ícono de chat
- Panel slide-up con historial de mensajes de la sesión actual
- Alpine.js para estado local (open/closed, messages, loading spinner)
- Tailwind para estilos (sin dependencias adicionales)
- Rol-aware: el frontend no diferencia, la diferencia la hace el backend

---

## 11. Comandos de Deploy

### Instalación inicial (local y producción)

```bash
# 1. Instalar Prism PHP
composer require prism-php/prism

# 2. Publicar config de Prism (opcional)
php artisan vendor:publish --provider="EchoLabs\Prism\PrismServiceProvider"

# 3. Agregar en .env
GEMINI_API_KEY=tu_api_key_aqui

# 4. Ejecutar migration de agent_threads
php artisan migrate

# 5. Registrar el comando de purge (se hace automáticamente con el código)
# Verificar que está en routes/console.php

# 6. Limpiar cachés después del deploy
php artisan config:clear
php artisan cache:clear
php artisan view:clear
php artisan route:clear

# 7. Re-cachear para producción
php artisan config:cache
php artisan route:cache
php artisan view:cache
```

### Comandos de mantenimiento

```bash
# Purge manual de threads expirados
php artisan agent:purge-threads

# Ver logs del agente
php artisan pail --filter="GeniusAgent"

# Limpiar todos los threads (emergencia)
php artisan tinker --execute="App\Models\AgentThread::truncate();"
```

### Deploy en Hostinger (via SSH o File Manager)

```bash
# Subir archivos nuevos y correr:
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
```

---

## 12. Configuración `.env` requerida

```env
GEMINI_API_KEY=tu_api_key_de_google_ai_studio

# Opcional: ajustar timeout HTTP de Prism
PRISM_HTTP_TIMEOUT=30
```
