# 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 <<chatAdmin($message, $threadId); } return $this->chatPublic($message); } private function chatPublic(string $message): array { $history = session('agent_messages', []); $messages = $this->hydrate($history); $messages[] = new UserMessage($message); $response = Prism::text() ->using(Provider::Gemini, 'gemini-1.5-flash') ->withSystemPrompt((new SystemPromptPublic())->build()) ->withMessages($messages) ->generate(); $reply = $response->text; $history[] = ['role' => 'user', 'content' => $message]; $history[] = ['role' => 'assistant', 'content' => $reply]; session(['agent_messages' => $history]); return ['reply' => $reply]; } private function chatAdmin(string $message, ?string $threadId): array { $adminId = (int) session('admin_id', 0); $thread = AgentThread::findOrCreateForAdmin($threadId, $adminId); $messages = $this->hydrate($thread->messages ?? []); $messages[] = new UserMessage($message); $response = Prism::text() ->using(Provider::Gemini, 'gemini-1.5-flash') ->withSystemPrompt((new SystemPromptAdmin())->build()) ->withMessages($messages) ->withTools($this->buildAdminTools()) ->generate(); $reply = $response->text; $stored = $thread->messages ?? []; $stored[] = ['role' => 'user', 'content' => $message]; $stored[] = ['role' => 'assistant', 'content' => $reply]; $thread->messages = $stored; $thread->save(); return ['reply' => $reply, 'thread_id' => $thread->thread_id]; } private function hydrate(array $stored): array { return collect($stored)->map(fn($m) => $m['role'] === 'user' ? new UserMessage($m['content']) : new AssistantMessage($m['content']) )->all(); } private function buildAdminTools(): array { return [ Tool::as('listar_equipos') ->for('Lista los equipos. Filtrá por id_torneo y/o grupo para obtener los IDs correctos antes de crear partidos.') ->withNumberParameter('id_torneo', 'ID del torneo (opcional)', false) ->withStringParameter('grupo', 'Nombre del grupo dentro del torneo (opcional)', false) ->using(new ListarEquiposTool()), Tool::as('listar_eventos') ->for('Lista los partidos. Filtrá por rango de fechas (Y-m-d) o id_torneo para obtener los IDs antes de cargar puntajes.') ->withStringParameter('fecha_desde', 'Fecha desde en formato Y-m-d (opcional)', false) ->withStringParameter('fecha_hasta', 'Fecha hasta en formato Y-m-d (opcional)', false) ->withNumberParameter('id_torneo', 'ID del torneo (opcional)', false) ->using(new ListarEventosTool()), Tool::as('crear_partido') ->for('Crea un nuevo partido en el sistema. Podés llamar esta tool múltiples veces para crear varios partidos.') ->withNumberParameter('id_equipo_local', 'ID del equipo local') ->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante') ->withStringParameter('fecha_evento', 'Fecha del partido en formato Y-m-d') ->withStringParameter('hora_inicio', 'Hora de inicio en formato H:i (ej: 20:00)') ->withStringParameter('hora_fin', 'Hora de fin en formato H:i (ej: 22:00)') ->withStringParameter('sede', 'Lugar donde se juega el partido') ->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido') ->withNumberParameter('precio', 'Precio de la entrada en pesos (0 si es gratis)', false) ->using(new CrearPartidoTool()), Tool::as('cargar_puntaje') ->for('Carga o actualiza el puntaje de un partido existente. Podés llamar esta tool múltiples veces para cargar puntajes de varios partidos.') ->withStringParameter('id_evento', 'ID del evento (UUID — usá listar_eventos para obtenerlo)') ->withNumberParameter('marcador_local', 'Puntos del equipo local') ->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante') ->using(new CargarPuntajeTool()), Tool::as('redactar_noticia') ->for('Crea una noticia en el sistema. El contenido puede ser HTML o texto plano.') ->withStringParameter('titulo', 'Título de la noticia') ->withStringParameter('contenido', 'Contenido completo de la noticia') ->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false) ->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false) ->using(new RedactarNoticiaTool()), ]; } } ``` - [ ] **Paso 2: Verificar que no hay errores de sintaxis** ```bash php artisan tinker --execute="new App\Services\GeniusAgentService(); echo 'OK';" ``` Resultado esperado: `OK` - [ ] **Paso 3: Commit** ```bash git add app/Services/GeniusAgentService.php git commit -m "feat: add GeniusAgentService with public/admin chat modes" ``` --- ## Tarea 9: GeniusAgentController y Route **Archivos:** - Create: `app/Http/Controllers/GeniusAgentController.php` - Create: `tests/Feature/GeniusAgentControllerTest.php` - Modify: `routes/web.php` - [ ] **Paso 1: Escribir el test del controller** Crear `tests/Feature/GeniusAgentControllerTest.php`: ```php postJson('/agent/chat', ['message' => '']); $response->assertStatus(422) ->assertJsonValidationErrors(['message']); } public function test_rechaza_mensaje_demasiado_largo(): void { $response = $this->postJson('/agent/chat', ['message' => str_repeat('a', 1001)]); $response->assertStatus(422) ->assertJsonValidationErrors(['message']); } public function test_usuario_publico_recibe_respuesta(): void { $this->mock(GeniusAgentService::class, function ($mock) { $mock->shouldReceive('chat') ->once() ->with('Hola', false, null) ->andReturn(['reply' => 'Hola desde el agente']); }); $response = $this->postJson('/agent/chat', ['message' => 'Hola']); $response->assertStatus(200) ->assertJson(['reply' => 'Hola desde el agente']); } public function test_admin_recibe_respuesta_con_thread_id(): void { $this->mock(GeniusAgentService::class, function ($mock) { $mock->shouldReceive('chat') ->once() ->andReturn(['reply' => 'Listo', 'thread_id' => 'test-uuid']); }); $response = $this->withSession(['admin_logged_in' => true]) ->postJson('/agent/chat', ['message' => 'Crear partido']); $response->assertStatus(200) ->assertJsonStructure(['reply', 'thread_id']); } public function test_retorna_error_generico_si_el_servicio_falla(): void { $this->mock(GeniusAgentService::class, function ($mock) { $mock->shouldReceive('chat') ->once() ->andThrow(new \Exception('API timeout')); }); $response = $this->postJson('/agent/chat', ['message' => 'Hola']); $response->assertStatus(500) ->assertJson(['error' => 'El agente no responde, reintentá en un momento.']); } } ``` - [ ] **Paso 2: Ejecutar el test (debe fallar)** ```bash php artisan test tests/Feature/GeniusAgentControllerTest.php ``` Resultado esperado: `FAIL` — ruta no existe. - [ ] **Paso 3: Crear el controller `app/Http/Controllers/GeniusAgentController.php`** ```php validate([ 'message' => 'required|string|max:1000', 'thread_id' => 'nullable|string|max:36', ]); $isAdmin = (bool) session('admin_logged_in'); try { $result = $this->service->chat( $data['message'], $isAdmin, $data['thread_id'] ?? null ); return response()->json($result); } catch (\Throwable $e) { \Log::error('GeniusAgent error: ' . $e->getMessage()); return response()->json( ['error' => 'El agente no responde, reintentá en un momento.'], 500 ); } } } ``` - [ ] **Paso 4: Agregar la ruta en `routes/web.php`** Al final de las importaciones en `routes/web.php`, agregar: ```php use App\Http\Controllers\GeniusAgentController; ``` Al final del archivo, agregar: ```php Route::post('/agent/chat', [GeniusAgentController::class, 'chat']) ->name('agent.chat') ->middleware('throttle:20,1'); ``` - [ ] **Paso 5: Ejecutar el test (debe pasar)** ```bash php artisan test tests/Feature/GeniusAgentControllerTest.php ``` Resultado esperado: `PASS` — 4 tests. - [ ] **Paso 6: Commit** ```bash git add app/Http/Controllers/GeniusAgentController.php \ tests/Feature/GeniusAgentControllerTest.php \ routes/web.php git commit -m "feat: add GeniusAgentController and POST /agent/chat route" ``` --- ## Tarea 10: Comando PurgeAgentThreads **Archivos:** - Create: `app/Console/Commands/PurgeAgentThreads.php` - Modify: `routes/console.php` - [ ] **Paso 1: Crear el command** ```bash php artisan make:command PurgeAgentThreads ``` Abrir `app/Console/Commands/PurgeAgentThreads.php` y reemplazar con: ```php delete(); $this->info("Eliminados {$count} threads expirados."); return self::SUCCESS; } } ``` - [ ] **Paso 2: Registrar en el schedule (`routes/console.php`)** Abrir `routes/console.php` y agregar al final: ```php use Illuminate\Support\Facades\Schedule; Schedule::command('agent:purge-threads')->daily(); ``` Si `Schedule::` ya está importado, omitir el `use`. - [ ] **Paso 3: Verificar que el command funciona** ```bash php artisan agent:purge-threads ``` Resultado esperado: `Eliminados 0 threads expirados.` - [ ] **Paso 4: Commit** ```bash git add app/Console/Commands/PurgeAgentThreads.php routes/console.php git commit -m "feat: add agent:purge-threads command with daily schedule" ``` --- ## Tarea 11: Chat Bubble — Frontend y Wiring **Archivos:** - Create: `resources/views/components/genius-chat.blade.php` - Modify: `resources/views/layouts/app.blade.php` - [ ] **Paso 1: Crear `resources/views/components/genius-chat.blade.php`** ```html
{{-- Botón flotante --}} {{-- Panel del chat --}}
``` - [ ] **Paso 2: Incluir el componente en `resources/views/layouts/app.blade.php`** Buscar la línea `` 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 ```