# OnAPB Genius Agent — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Agregar un agente de IA conversacional (Prism PHP + Gemini 1.5 Flash) con tools de function-calling para admins y asistente de navegación para usuarios públicos.
**Architecture:** `GeniusAgentService` orquesta las llamadas a Prism con lista de tools dinámica (vacía para público, 5 tools para admin). Conversaciones de admin persisten en MySQL `agent_threads`; conversaciones públicas viven en sesión PHP. Sin streaming — respuesta JSON completa para compatibilidad con Hostinger.
**Tech Stack:** PHP 8.2, Laravel 12, `prism-php/prism`, Google Gemini 1.5 Flash, MySQL, Bootstrap 5, vanilla JS.
---
## Mapa de Archivos
### Creados
| Archivo | Responsabilidad |
|---|---|
| `app/AI/Tools/ListarEquiposTool.php` | Invokable: consulta equipos con filtro torneo/grupo |
| `app/AI/Tools/ListarEventosTool.php` | Invokable: consulta partidos con filtro fecha/torneo |
| `app/AI/Tools/CrearPartidoTool.php` | Invokable: inserta un Evento en BD |
| `app/AI/Tools/CargarPuntajeTool.php` | Invokable: actualiza marcadores de un Evento |
| `app/AI/Tools/RedactarNoticiaTool.php` | Invokable: inserta una Noticia en BD |
| `app/AI/Prompts/SystemPromptAdmin.php` | Retorna string del system prompt para admins |
| `app/AI/Prompts/SystemPromptPublic.php` | Retorna string del system prompt público + manual RAG |
| `app/Services/GeniusAgentService.php` | Orquesta Prism: selecciona modo, ejecuta, persiste historial |
| `app/Http/Controllers/GeniusAgentController.php` | Recibe POST /agent/chat, valida, llama service, retorna JSON |
| `app/Models/AgentThread.php` | Eloquent model para historial admin |
| `app/Console/Commands/PurgeAgentThreads.php` | Artisan command: elimina threads expirados |
| `database/migrations/xxxx_create_agent_threads_table.php` | Tabla agent_threads |
| `resources/views/components/genius-chat.blade.php` | Widget chat bubble (Bootstrap + vanilla JS) |
### Modificados
| Archivo | Cambio |
|---|---|
| `.env` / `.env.example` | Agregar `GEMINI_API_KEY` |
| `routes/web.php` | `POST /agent/chat` con throttle |
| `routes/console.php` | Schedule daily purge |
| `resources/views/layouts/app.blade.php` | Include del componente chat |
### Tests
| Archivo | Qué prueba |
|---|---|
| `tests/Unit/AI/Tools/ListarEquiposToolTest.php` | Filtrado por torneo y grupo |
| `tests/Unit/AI/Tools/ListarEventosToolTest.php` | Filtrado por fecha e id_torneo |
| `tests/Unit/AI/Tools/CrearPartidoToolTest.php` | Inserta Evento correctamente |
| `tests/Unit/AI/Tools/CargarPuntajeToolTest.php` | Actualiza marcadores; falla gracefully si no existe |
| `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php` | Inserta Noticia correctamente |
| `tests/Feature/GeniusAgentControllerTest.php` | HTTP layer: validación, throttle, error handling |
---
## Tarea 1: Instalar Prism PHP y configurar Gemini
**Archivos:**
- Modify: `composer.json` (via composer require)
- Modify: `.env`
- Modify: `.env.example`
- [ ] **Paso 1: Instalar el paquete**
```bash
composer require prism-php/prism
```
Resultado esperado: `prism-php/prism` aparece en `composer.json` bajo `require`.
- [ ] **Paso 2: Publicar config de Prism**
```bash
php artisan vendor:publish --provider="EchoLabs\Prism\PrismServiceProvider" --tag="prism-config"
```
Resultado esperado: archivo `config/prism.php` creado.
- [ ] **Paso 3: Agregar API key en `.env`**
Agregar al final de `.env`:
```env
GEMINI_API_KEY=tu_api_key_de_google_ai_studio
```
Agregar al final de `.env.example`:
```env
GEMINI_API_KEY=
```
- [ ] **Paso 4: Verificar que config/prism.php tiene la sección de Gemini**
Abrir `config/prism.php` y confirmar que existe algo similar a:
```php
'gemini' => [
'api_key' => env('GEMINI_API_KEY', ''),
],
```
Si no existe, agregar ese bloque dentro de `'providers'`.
- [ ] **Paso 5: Limpiar config cache**
```bash
php artisan config:clear
```
- [ ] **Paso 6: Commit**
```bash
git add composer.json composer.lock config/prism.php .env.example
git commit -m "chore: install prism-php/prism and configure Gemini provider"
```
---
## Tarea 2: Migration y Modelo AgentThread
**Archivos:**
- Create: `database/migrations/xxxx_create_agent_threads_table.php`
- Create: `app/Models/AgentThread.php`
- [ ] **Paso 1: Crear la migration**
```bash
php artisan make:migration create_agent_threads_table
```
Abrir el archivo generado y reemplazar el contenido del método `up()`:
```php
public function up(): void
{
Schema::create('agent_threads', function (Blueprint $table) {
$table->id();
$table->string('thread_id', 36)->unique();
$table->integer('admin_id');
$table->json('messages');
$table->timestamps();
$table->timestamp('expires_at');
});
}
public function down(): void
{
Schema::dropIfExists('agent_threads');
}
```
- [ ] **Paso 2: Crear el modelo `app/Models/AgentThread.php`**
```php
'array',
'expires_at' => 'datetime',
];
public static function findOrCreateForAdmin(?string $threadId, int $adminId): static
{
if ($threadId) {
$thread = static::where('thread_id', $threadId)
->where('admin_id', $adminId)
->first();
if ($thread) {
return $thread;
}
}
return static::create([
'thread_id' => (string) Str::uuid(),
'admin_id' => $adminId,
'messages' => [],
'expires_at' => now()->addDays(30),
]);
}
}
```
- [ ] **Paso 3: Ejecutar la migration**
```bash
php artisan migrate
```
Resultado esperado: `Migrating: xxxx_create_agent_threads_table` → `Migrated`.
- [ ] **Paso 4: Verificar el modelo con tinker**
```bash
php artisan tinker --execute="App\Models\AgentThread::create(['thread_id'=>'test-uuid','admin_id'=>1,'messages'=>[],'expires_at'=>now()->addDays(30)]); echo App\Models\AgentThread::count();"
```
Resultado esperado: `1`
- [ ] **Paso 5: Commit**
```bash
git add database/migrations/ app/Models/AgentThread.php
git commit -m "feat: add agent_threads migration and AgentThread model"
```
---
## Tarea 3: Tools de solo lectura — ListarEquipos y ListarEventos
**Archivos:**
- Create: `app/AI/Tools/ListarEquiposTool.php`
- Create: `app/AI/Tools/ListarEventosTool.php`
- Create: `tests/Unit/AI/Tools/ListarEquiposToolTest.php`
- Create: `tests/Unit/AI/Tools/ListarEventosToolTest.php`
- [ ] **Paso 1: Crear directorios**
```bash
mkdir -p app/AI/Tools app/AI/Prompts
mkdir -p tests/Unit/AI/Tools
```
- [ ] **Paso 2: Escribir el test de ListarEquiposTool**
Crear `tests/Unit/AI/Tools/ListarEquiposToolTest.php`:
```php
1, 'nombre' => 'Club Test']);
Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']);
$tool = new ListarEquiposTool();
$result = json_decode($tool(), true);
$this->assertCount(1, $result);
$this->assertEquals('Primera', $result[0]['categoria']);
}
public function test_filtra_por_torneo_y_grupo(): void
{
Club::create(['id_club' => 1, 'nombre' => 'Club A']);
Club::create(['id_club' => 2, 'nombre' => 'Club B']);
$eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']);
$eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']);
$torneo = Torneo::create(['nombre' => 'Torneo 2025', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]);
\DB::table('torneo_equipo')->insert([
['id_torneo' => $torneo->id, 'id_equipo' => $eq1->id_equipo, 'grupo' => 'A'],
['id_torneo' => $torneo->id, 'id_equipo' => $eq2->id_equipo, 'grupo' => 'B'],
]);
$tool = new ListarEquiposTool();
$result = json_decode($tool(id_torneo: $torneo->id, grupo: 'A'), true);
$this->assertCount(1, $result);
$this->assertEquals($eq1->id_equipo, $result[0]['id_equipo']);
}
}
```
- [ ] **Paso 3: Ejecutar el test (debe fallar)**
```bash
php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php
```
Resultado esperado: `FAIL` — clase `ListarEquiposTool` no existe.
- [ ] **Paso 4: Implementar `app/AI/Tools/ListarEquiposTool.php`**
```php
join('torneo_equipo', 'equipos.id_equipo', '=', 'torneo_equipo.id_equipo')
->where('torneo_equipo.id_torneo', $id_torneo);
if ($grupo !== null) {
$query->where('torneo_equipo.grupo', $grupo);
}
$query->select('equipos.id_equipo', 'equipos.categoria', 'equipos.division', 'equipos.id_club');
}
$equipos = $query->get()->map(fn($e) => [
'id_equipo' => $e->id_equipo,
'categoria' => $e->categoria,
'division' => $e->division,
'club' => $e->club?->nombre,
]);
return json_encode($equipos);
}
}
```
- [ ] **Paso 5: Ejecutar el test (debe pasar)**
```bash
php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php
```
Resultado esperado: `PASS` — 2 tests, 0 fallos.
- [ ] **Paso 6: Escribir el test de ListarEventosTool**
Crear `tests/Unit/AI/Tools/ListarEventosToolTest.php`:
```php
1], ['nombre' => 'Club A']);
Club::firstOrCreate(['id_club' => 2], ['nombre' => 'Club B']);
$eq1 = Equipo::firstOrCreate(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']);
$eq2 = Equipo::firstOrCreate(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']);
$torneo = Torneo::firstOrCreate(['nombre' => 'Torneo Test'], ['fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]);
return Evento::create(array_merge([
'id_evento' => (string) Str::uuid(),
'id_equipo_local' => $eq1->id_equipo,
'id_equipo_visitante' => $eq2->id_equipo,
'fecha_evento' => '2025-06-15',
'hora_inicio' => '20:00:00',
'hora_fin' => '22:00:00',
'sede' => 'Estadio Test',
'id_torneo' => $torneo->id,
'precio' => 0,
'fase' => 0,
], $overrides));
}
public function test_retorna_eventos_sin_filtro(): void
{
$this->crearEvento();
$tool = new ListarEventosTool();
$result = json_decode($tool(), true);
$this->assertCount(1, $result);
$this->assertEquals('2025-06-15', $result[0]['fecha']);
}
public function test_filtra_por_rango_de_fechas(): void
{
$this->crearEvento(['fecha_evento' => '2025-06-01', 'id_evento' => (string) Str::uuid()]);
$this->crearEvento(['fecha_evento' => '2025-07-01', 'id_evento' => (string) Str::uuid()]);
$tool = new ListarEventosTool();
$result = json_decode($tool(fecha_desde: '2025-07-01', fecha_hasta: '2025-07-31'), true);
$this->assertCount(1, $result);
$this->assertEquals('2025-07-01', $result[0]['fecha']);
}
}
```
- [ ] **Paso 7: Ejecutar el test (debe fallar)**
```bash
php artisan test tests/Unit/AI/Tools/ListarEventosToolTest.php
```
Resultado esperado: `FAIL` — clase `ListarEventosTool` no existe.
- [ ] **Paso 8: Implementar `app/AI/Tools/ListarEventosTool.php`**
```php
whereNull('deleted_at');
if ($fecha_desde) {
$query->whereDate('fecha_evento', '>=', $fecha_desde);
}
if ($fecha_hasta) {
$query->whereDate('fecha_evento', '<=', $fecha_hasta);
}
if ($id_torneo) {
$query->where('id_torneo', $id_torneo);
}
$eventos = $query->orderBy('fecha_evento')->get()->map(fn($e) => [
'id_evento' => $e->id_evento,
'fecha' => $e->fecha_evento?->format('Y-m-d'),
'hora' => $e->hora_inicio?->format('H:i'),
'local' => $e->equipoLocal?->club?->nombre,
'visitante' => $e->equipoVisitante?->club?->nombre,
'marcador_local' => $e->marcador_local,
'marcador_visitante' => $e->marcador_visitante,
'sede' => $e->sede,
]);
return json_encode($eventos);
}
}
```
- [ ] **Paso 9: Ejecutar ambos tests**
```bash
php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php tests/Unit/AI/Tools/ListarEventosToolTest.php
```
Resultado esperado: `PASS` — 4 tests, 0 fallos.
- [ ] **Paso 10: Commit**
```bash
git add app/AI/Tools/ListarEquiposTool.php app/AI/Tools/ListarEventosTool.php \
tests/Unit/AI/Tools/ListarEquiposToolTest.php tests/Unit/AI/Tools/ListarEventosToolTest.php
git commit -m "feat: add ListarEquipos and ListarEventos read-only tools"
```
---
## Tarea 4: Tool CrearPartido
**Archivos:**
- Create: `app/AI/Tools/CrearPartidoTool.php`
- Create: `tests/Unit/AI/Tools/CrearPartidoToolTest.php`
- [ ] **Paso 1: Escribir el test**
Crear `tests/Unit/AI/Tools/CrearPartidoToolTest.php`:
```php
1, 'nombre' => 'Club A']);
Club::create(['id_club' => 2, 'nombre' => 'Club B']);
$eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']);
$eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']);
$torneo = Torneo::create(['nombre' => 'Torneo', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]);
$tool = new CrearPartidoTool();
$result = json_decode($tool(
id_equipo_local: $eq1->id_equipo,
id_equipo_visitante: $eq2->id_equipo,
fecha_evento: '2025-08-10',
hora_inicio: '20:00',
hora_fin: '22:00',
sede: 'Estadio Municipal',
id_torneo: $torneo->id
), true);
$this->assertTrue($result['success']);
$this->assertDatabaseHas('eventos', [
'sede' => 'Estadio Municipal',
'id_equipo_local' => $eq1->id_equipo,
]);
}
}
```
- [ ] **Paso 2: Ejecutar el test (debe fallar)**
```bash
php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php
```
Resultado esperado: `FAIL`
- [ ] **Paso 3: Implementar `app/AI/Tools/CrearPartidoTool.php`**
```php
(string) Str::uuid(),
'id_equipo_local' => $id_equipo_local,
'id_equipo_visitante' => $id_equipo_visitante,
'fecha_evento' => $fecha_evento,
'hora_inicio' => $hora_inicio . ':00',
'hora_fin' => $hora_fin . ':00',
'sede' => $sede,
'id_torneo' => $id_torneo,
'precio' => $precio ?? 0,
'fase' => Evento::FASE_REGULAR,
]);
return json_encode([
'success' => true,
'id_evento' => $evento->id_evento,
'mensaje' => "Partido creado correctamente. ID: {$evento->id_evento}",
]);
}
}
```
- [ ] **Paso 4: Ejecutar el test (debe pasar)**
```bash
php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php
```
Resultado esperado: `PASS`
- [ ] **Paso 5: Commit**
```bash
git add app/AI/Tools/CrearPartidoTool.php tests/Unit/AI/Tools/CrearPartidoToolTest.php
git commit -m "feat: add CrearPartidoTool"
```
---
## Tarea 5: Tool CargarPuntaje
**Archivos:**
- Create: `app/AI/Tools/CargarPuntajeTool.php`
- Create: `tests/Unit/AI/Tools/CargarPuntajeToolTest.php`
- [ ] **Paso 1: Escribir el test**
Crear `tests/Unit/AI/Tools/CargarPuntajeToolTest.php`:
```php
1, 'nombre' => 'Club A']);
Club::create(['id_club' => 2, 'nombre' => 'Club B']);
$eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']);
$eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']);
$torneo = Torneo::create(['nombre' => 'Torneo', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]);
return Evento::create([
'id_evento' => (string) Str::uuid(),
'id_equipo_local' => $eq1->id_equipo,
'id_equipo_visitante' => $eq2->id_equipo,
'fecha_evento' => '2025-08-10',
'hora_inicio' => '20:00:00',
'hora_fin' => '22:00:00',
'sede' => 'Estadio',
'id_torneo' => $torneo->id,
'precio' => 0,
'fase' => 0,
]);
}
public function test_actualiza_puntaje_correctamente(): void
{
$evento = $this->crearEvento();
$tool = new CargarPuntajeTool();
$result = json_decode($tool(
id_evento: $evento->id_evento,
marcador_local: 85,
marcador_visitante: 72
), true);
$this->assertTrue($result['success']);
$this->assertDatabaseHas('eventos', [
'id_evento' => $evento->id_evento,
'marcador_local' => 85,
'marcador_visitante' => 72,
]);
}
public function test_retorna_error_si_evento_no_existe(): void
{
$tool = new CargarPuntajeTool();
$result = json_decode($tool(
id_evento: 'uuid-inexistente',
marcador_local: 10,
marcador_visitante: 20
), true);
$this->assertFalse($result['success']);
$this->assertStringContainsString('no encontrado', $result['error']);
}
}
```
- [ ] **Paso 2: Ejecutar el test (debe fallar)**
```bash
php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php
```
Resultado esperado: `FAIL`
- [ ] **Paso 3: Implementar `app/AI/Tools/CargarPuntajeTool.php`**
```php
false,
'error' => "Evento '{$id_evento}' no encontrado.",
]);
}
$evento->update([
'marcador_local' => $marcador_local,
'marcador_visitante' => $marcador_visitante,
]);
return json_encode([
'success' => true,
'mensaje' => "Puntaje cargado: {$marcador_local} - {$marcador_visitante}",
]);
}
}
```
- [ ] **Paso 4: Ejecutar el test (debe pasar)**
```bash
php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php
```
Resultado esperado: `PASS` — 2 tests.
- [ ] **Paso 5: Commit**
```bash
git add app/AI/Tools/CargarPuntajeTool.php tests/Unit/AI/Tools/CargarPuntajeToolTest.php
git commit -m "feat: add CargarPuntajeTool"
```
---
## Tarea 6: Tool RedactarNoticia
**Archivos:**
- Create: `app/AI/Tools/RedactarNoticiaTool.php`
- Create: `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php`
- [ ] **Paso 1: Escribir el test**
Crear `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php`:
```php
assertTrue($result['success']);
$this->assertDatabaseHas('noticias', ['titulo' => 'Gran partido en la final']);
}
public function test_crea_noticia_con_torneo_y_categoria(): void
{
$tool = new RedactarNoticiaTool();
$result = json_decode($tool(
titulo: 'Resumen jornada',
contenido: 'Resumen de la jornada 5.',
id_torneo: 1,
categoria: 'resultados'
), true);
$this->assertTrue($result['success']);
$this->assertDatabaseHas('noticias', [
'titulo' => 'Resumen jornada',
'categoria' => 'resultados',
]);
}
}
```
- [ ] **Paso 2: Ejecutar el test (debe fallar)**
```bash
php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php
```
Resultado esperado: `FAIL`
- [ ] **Paso 3: Implementar `app/AI/Tools/RedactarNoticiaTool.php`**
```php
$titulo,
'contenido' => $contenido,
'fecha' => now(),
'id_torneo' => $id_torneo,
'categoria' => $categoria,
]);
return json_encode([
'success' => true,
'id' => $noticia->id,
'mensaje' => "Noticia creada: {$noticia->titulo}",
]);
}
}
```
- [ ] **Paso 4: Ejecutar el test (debe pasar)**
```bash
php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php
```
Resultado esperado: `PASS` — 2 tests.
- [ ] **Paso 5: Ejecutar todos los tests de tools**
```bash
php artisan test tests/Unit/AI/Tools/
```
Resultado esperado: `PASS` — 9 tests, 0 fallos.
- [ ] **Paso 6: Commit**
```bash
git add app/AI/Tools/RedactarNoticiaTool.php tests/Unit/AI/Tools/RedactarNoticiaToolTest.php
git commit -m "feat: add RedactarNoticiaTool — all 5 tools complete"
```
---
## Tarea 7: System Prompts
**Archivos:**
- Create: `app/AI/Prompts/SystemPromptAdmin.php`
- Create: `app/AI/Prompts/SystemPromptPublic.php`
- [ ] **Paso 1: Crear `app/AI/Prompts/SystemPromptAdmin.php`**
```php
loadManual();
return <<
` al final del archivo y agregar el include justo antes: ```html @include('components.genius-chat') ``` Si ya hay scripts de Bootstrap u otros justo antes de ``, agregar el include después de ellos pero antes de ``. - [ ] **Paso 3: Agregar CSRF meta tag si no existe** En `resources/views/layouts/app.blade.php`, dentro de `
`, verificar que existe: ```html ``` Si no existe, agregarlo debajo de ``. - [ ] **Paso 4: Verificar visualmente** ```bash php artisan serve ``` Abrir `http://localhost:8000` y verificar: - El botón rojo con icono `bi-stars` aparece en la esquina inferior derecha - Click abre el panel - El campo de texto acepta input - Cerrando y abriendo el panel no pierde mensajes de la sesión - [ ] **Paso 5: Commit final** ```bash git add resources/views/components/genius-chat.blade.php resources/views/layouts/app.blade.php git commit -m "feat: add genius-chat bubble component and wire into layout" ``` --- ## Verificación Final - [ ] **Ejecutar todos los tests** ```bash php artisan test ``` Resultado esperado: todos los tests del proyecto pasan (mínimo los 13 nuevos). - [ ] **Test de smoke manual (público)** 1. Abrir el sitio sin loguearse 2. Clickear el botón del agente 3. Escribir: "¿Dónde veo mis QRs?" 4. Verificar que responde en español usando el manual - [ ] **Test de smoke manual (admin)** 1. Loguearse como admin 2. Clickear el botón del agente 3. Escribir: "Listame los equipos del torneo 1" 4. Verificar que la tool se ejecuta y retorna los equipos - [ ] **Commit de cierre** ```bash git add . git commit -m "feat: OnAPB Genius Agent — complete implementation" ``` --- ## Comandos de Deploy en Hostinger ```bash # 1. Subir archivos y correr en SSH: composer install --no-dev --optimize-autoloader php artisan migrate --force php artisan config:cache php artisan route:cache php artisan view:cache # 2. Configurar cron en hPanel de Hostinger (una sola vez): # Reemplazar TU_USUARIO con tu usuario de Hostinger: # 0 3 * * * cd /home/TU_USUARIO/public_html && php artisan agent:purge-threads >> /dev/null 2>&1 # 3. Verificar que la API key está en el .env de producción: # GEMINI_API_KEY=tu_api_key_aqui ```