Files
OnAPB-Carrere_Demartin/docs/superpowers/plans/2026-04-09-genius-agent.md
T
Laucha1312 90c5f85512 2
2026-06-04 15:15:23 -03:00

47 KiB

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

composer require prism-php/prism

Resultado esperado: prism-php/prism aparece en composer.json bajo require.

  • Paso 2: Publicar config de Prism
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:

GEMINI_API_KEY=tu_api_key_de_google_ai_studio

Agregar al final de .env.example:

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:

'gemini' => [
    'api_key' => env('GEMINI_API_KEY', ''),
],

Si no existe, agregar ese bloque dentro de 'providers'.

  • Paso 5: Limpiar config cache
php artisan config:clear
  • Paso 6: Commit
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

php artisan make:migration create_agent_threads_table

Abrir el archivo generado y reemplazar el contenido del método up():

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

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class AgentThread extends Model
{
    protected $table = 'agent_threads';

    protected $fillable = [
        'thread_id',
        'admin_id',
        'messages',
        'expires_at',
    ];

    protected $casts = [
        'messages'   => '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
php artisan migrate

Resultado esperado: Migrating: xxxx_create_agent_threads_tableMigrated.

  • Paso 4: Verificar el modelo con tinker
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
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

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

namespace Tests\Unit\AI\Tools;

use App\AI\Tools\ListarEquiposTool;
use App\Models\Club;
use App\Models\Equipo;
use App\Models\Torneo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ListarEquiposToolTest extends TestCase
{
    use RefreshDatabase;

    public function test_retorna_todos_los_equipos_sin_filtro(): void
    {
        Club::create(['id_club' => 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)
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

namespace App\AI\Tools;

use App\Models\Equipo;

class ListarEquiposTool
{
    public function __invoke(?int $id_torneo = null, ?string $grupo = null): string
    {
        $query = Equipo::with('club');

        if ($id_torneo !== null) {
            $query->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)
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

namespace Tests\Unit\AI\Tools;

use App\AI\Tools\ListarEventosTool;
use App\Models\Club;
use App\Models\Equipo;
use App\Models\Evento;
use App\Models\Torneo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;

class ListarEventosToolTest extends TestCase
{
    use RefreshDatabase;

    private function crearEvento(array $overrides = []): Evento
    {
        Club::firstOrCreate(['id_club' => 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)
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

namespace App\AI\Tools;

use App\Models\Evento;

class ListarEventosTool
{
    public function __invoke(
        ?string $fecha_desde = null,
        ?string $fecha_hasta = null,
        ?int $id_torneo = null
    ): string {
        $query = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])
            ->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
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
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

namespace Tests\Unit\AI\Tools;

use App\AI\Tools\CrearPartidoTool;
use App\Models\Club;
use App\Models\Equipo;
use App\Models\Evento;
use App\Models\Torneo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class CrearPartidoToolTest extends TestCase
{
    use RefreshDatabase;

    public function test_crea_evento_correctamente(): 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', '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)
php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php

Resultado esperado: FAIL

  • Paso 3: Implementar app/AI/Tools/CrearPartidoTool.php
<?php

namespace App\AI\Tools;

use App\Models\Evento;
use Illuminate\Support\Str;

class CrearPartidoTool
{
    public function __invoke(
        int $id_equipo_local,
        int $id_equipo_visitante,
        string $fecha_evento,
        string $hora_inicio,
        string $hora_fin,
        string $sede,
        int $id_torneo,
        ?float $precio = null
    ): string {
        $evento = Evento::create([
            'id_evento'           => (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)
php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php

Resultado esperado: PASS

  • Paso 5: Commit
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

namespace Tests\Unit\AI\Tools;

use App\AI\Tools\CargarPuntajeTool;
use App\Models\Club;
use App\Models\Equipo;
use App\Models\Evento;
use App\Models\Torneo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;

class CargarPuntajeToolTest extends TestCase
{
    use RefreshDatabase;

    private function crearEvento(): Evento
    {
        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', '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)
php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php

Resultado esperado: FAIL

  • Paso 3: Implementar app/AI/Tools/CargarPuntajeTool.php
<?php

namespace App\AI\Tools;

use App\Models\Evento;

class CargarPuntajeTool
{
    public function __invoke(
        string $id_evento,
        int $marcador_local,
        int $marcador_visitante
    ): string {
        $evento = Evento::find($id_evento);

        if (!$evento) {
            return json_encode([
                'success' => 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)
php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php

Resultado esperado: PASS — 2 tests.

  • Paso 5: Commit
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

namespace Tests\Unit\AI\Tools;

use App\AI\Tools\RedactarNoticiaTool;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class RedactarNoticiaToolTest extends TestCase
{
    use RefreshDatabase;

    public function test_crea_noticia_correctamente(): void
    {
        $tool = new RedactarNoticiaTool();
        $result = json_decode($tool(
            titulo: 'Gran partido en la final',
            contenido: 'El equipo A derrotó al equipo B por 95 a 80.'
        ), true);

        $this->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)
php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php

Resultado esperado: FAIL

  • Paso 3: Implementar app/AI/Tools/RedactarNoticiaTool.php
<?php

namespace App\AI\Tools;

use App\Models\Noticia;

class RedactarNoticiaTool
{
    public function __invoke(
        string $titulo,
        string $contenido,
        ?int $id_torneo = null,
        ?string $categoria = null
    ): string {
        $noticia = Noticia::create([
            'titulo'    => $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)
php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php

Resultado esperado: PASS — 2 tests.

  • Paso 5: Ejecutar todos los tests de tools
php artisan test tests/Unit/AI/Tools/

Resultado esperado: PASS — 9 tests, 0 fallos.

  • Paso 6: Commit
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

namespace App\AI\Prompts;

class SystemPromptAdmin
{
    public function build(): string
    {
        return <<<'PROMPT'
Sos OnAPB Genius, el asistente de administración del sistema OnAPB (Liga de Básquetbol).

Ayudás a los administradores a gestionar partidos, puntajes y noticias de la liga.

Reglas:
- Antes de crear un partido, listá los equipos disponibles para confirmar los IDs correctos.
- Cuando te pidan cargar puntajes de varios partidos, listá los eventos próximos primero para obtener los IDs.
- Podés llamar las herramientas múltiples veces para operaciones en lote.
- Siempre confirmá en tu respuesta final qué acciones ejecutaste y cuáles fueron los resultados.
- Si algo falla, explicá el error claramente.
- Respondé siempre en español argentino.
PROMPT;
    }
}
  • Paso 2: Crear app/AI/Prompts/SystemPromptPublic.php
<?php

namespace App\AI\Prompts;

class SystemPromptPublic
{
    public function build(): string
    {
        $manual = $this->loadManual();

        return <<<PROMPT
Sos OnAPB Genius, el asistente de la plataforma OnAPB (Liga de Básquetbol de la APBA).

Ayudás a los usuarios a navegar la plataforma y responder sus preguntas sobre la liga.

Usá el siguiente manual de usuario como referencia principal para responder:

--- INICIO DEL MANUAL ---
{$manual}
--- FIN DEL MANUAL ---

Si la pregunta no está en el manual, respondé con información general y amigable.
Respondé siempre en español argentino y de forma concisa.
PROMPT;
    }

    private function loadManual(): string
    {
        $path = base_path('misc/MANUAL_USUARIO.md');

        if (!file_exists($path)) {
            return '(Manual no disponible)';
        }

        $content = file_get_contents($path);

        // Limitar a 200KB para no exceder el context window de Gemini
        return mb_substr($content, 0, 200000);
    }
}
  • Paso 3: Verificar que el manual existe
php artisan tinker --execute="echo file_exists(base_path('misc/MANUAL_USUARIO.md')) ? 'OK' : 'MISSING';"

Resultado esperado: OK

  • Paso 4: Commit
git add app/AI/Prompts/
git commit -m "feat: add SystemPromptAdmin and SystemPromptPublic"

Tarea 8: GeniusAgentService

Archivos:

  • Create: app/Services/GeniusAgentService.php

  • Paso 1: Crear app/Services/GeniusAgentService.php

<?php

namespace App\Services;

use App\AI\Prompts\SystemPromptAdmin;
use App\AI\Prompts\SystemPromptPublic;
use App\AI\Tools\CargarPuntajeTool;
use App\AI\Tools\CrearPartidoTool;
use App\AI\Tools\ListarEquiposTool;
use App\AI\Tools\ListarEventosTool;
use App\AI\Tools\RedactarNoticiaTool;
use App\Models\AgentThread;
use EchoLabs\Prism\Enums\Provider;
use EchoLabs\Prism\Facades\Prism;
use EchoLabs\Prism\Tool;
use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;
use EchoLabs\Prism\ValueObjects\Messages\UserMessage;

class GeniusAgentService
{
    public function chat(string $message, bool $isAdmin, ?string $threadId = null): array
    {
        if ($isAdmin) {
            return $this->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
php artisan tinker --execute="new App\Services\GeniusAgentService(); echo 'OK';"

Resultado esperado: OK

  • Paso 3: Commit
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

namespace Tests\Feature;

use App\Services\GeniusAgentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class GeniusAgentControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_rechaza_mensaje_vacio(): void
    {
        $response = $this->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)
php artisan test tests/Feature/GeniusAgentControllerTest.php

Resultado esperado: FAIL — ruta no existe.

  • Paso 3: Crear el controller app/Http/Controllers/GeniusAgentController.php
<?php

namespace App\Http\Controllers;

use App\Services\GeniusAgentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class GeniusAgentController extends Controller
{
    public function __construct(private GeniusAgentService $service) {}

    public function chat(Request $request): JsonResponse
    {
        set_time_limit(120);

        $data = $request->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:

use App\Http\Controllers\GeniusAgentController;

Al final del archivo, agregar:

Route::post('/agent/chat', [GeniusAgentController::class, 'chat'])
    ->name('agent.chat')
    ->middleware('throttle:20,1');
  • Paso 5: Ejecutar el test (debe pasar)
php artisan test tests/Feature/GeniusAgentControllerTest.php

Resultado esperado: PASS — 4 tests.

  • Paso 6: Commit
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

php artisan make:command PurgeAgentThreads

Abrir app/Console/Commands/PurgeAgentThreads.php y reemplazar con:

<?php

namespace App\Console\Commands;

use App\Models\AgentThread;
use Illuminate\Console\Command;

class PurgeAgentThreads extends Command
{
    protected $signature   = 'agent:purge-threads';
    protected $description = 'Elimina los threads de conversación del agente que han expirado';

    public function handle(): int
    {
        $count = AgentThread::where('expires_at', '<', now())->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:

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
php artisan agent:purge-threads

Resultado esperado: Eliminados 0 threads expirados.

  • Paso 4: Commit
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

<div id="genius-chat" style="position:fixed; bottom:1.5rem; right:1.5rem; z-index:1050;">

    {{-- Botón flotante --}}
    <button id="genius-toggle"
            class="btn btn-danger rounded-circle shadow d-flex align-items-center justify-content-center"
            style="width:56px; height:56px;" title="OnAPB Genius">
        <i class="bi bi-stars fs-5"></i>
    </button>

    {{-- Panel del chat --}}
    <div id="genius-panel" class="card shadow-lg"
         style="display:none; width:340px; position:absolute; bottom:70px; right:0;
                border:2px solid #b00000; border-radius:12px; overflow:hidden;">

        <div class="card-header d-flex justify-content-between align-items-center py-2"
             style="background:#b00000; color:#fff;">
            <span class="fw-semibold"><i class="bi bi-stars me-1"></i>OnAPB Genius</span>
            <button id="genius-close" class="btn-close btn-close-white btn-sm" aria-label="Cerrar"></button>
        </div>

        <div id="genius-messages"
             class="card-body overflow-auto p-3"
             style="height:320px; background:#1a1a1a;">
            <div class="text-center text-secondary small py-3">
                <i class="bi bi-stars text-danger"></i><br>
                ¡Hola! ¿En qué puedo ayudarte hoy?
            </div>
        </div>

        <div class="card-footer p-2" style="background:#111;">
            <div class="input-group input-group-sm">
                <input id="genius-input" type="text"
                       class="form-control border-secondary"
                       style="background:#222; color:#fff;"
                       placeholder="Escribí tu mensaje..." maxlength="1000"
                       autocomplete="off">
                <button id="genius-send" class="btn btn-danger">
                    <i class="bi bi-send-fill"></i>
                </button>
            </div>
        </div>
    </div>
</div>

<script>
(function () {
    const toggle = document.getElementById('genius-toggle');
    const panel  = document.getElementById('genius-panel');
    const close  = document.getElementById('genius-close');
    const input  = document.getElementById('genius-input');
    const send   = document.getElementById('genius-send');
    const msgs   = document.getElementById('genius-messages');
    let threadId = null;

    toggle.addEventListener('click', function () {
        const isHidden = panel.style.display === 'none';
        panel.style.display = isHidden ? 'block' : 'none';
        if (isHidden) { input.focus(); }
    });

    close.addEventListener('click', function () {
        panel.style.display = 'none';
    });

    function escHtml(str) {
        return String(str)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/\n/g, '<br>');
    }

    function appendMsg(role, text) {
        const wrap = document.createElement('div');
        wrap.className = 'mb-2 d-flex ' + (role === 'user' ? 'justify-content-end' : 'justify-content-start');

        const bubble = document.createElement('div');
        bubble.style.cssText = 'max-width:80%; padding:8px 12px; border-radius:12px; font-size:0.875rem; word-wrap:break-word;';

        if (role === 'user') {
            bubble.style.background = '#b00000';
            bubble.style.color = '#fff';
        } else {
            bubble.style.background = '#2a2a2a';
            bubble.style.color = '#eee';
        }

        bubble.innerHTML = escHtml(text);
        wrap.appendChild(bubble);
        msgs.appendChild(wrap);
        msgs.scrollTop = msgs.scrollHeight;
    }

    function setLoading(loading) {
        send.disabled  = loading;
        input.disabled = loading;
        send.innerHTML = loading
            ? '<span class="spinner-border spinner-border-sm" role="status"></span>'
            : '<i class="bi bi-send-fill"></i>';
    }

    async function sendMessage() {
        const text = input.value.trim();
        if (!text) { return; }
        input.value = '';
        appendMsg('user', text);
        setLoading(true);

        try {
            const body = { message: text };
            if (threadId) { body.thread_id = threadId; }

            const res = await fetch('{{ route("agent.chat") }}', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')
                        ? document.querySelector('meta[name="csrf-token"]').content
                        : '{{ csrf_token() }}'
                },
                body: JSON.stringify(body)
            });

            const data = await res.json();

            if (data.error) {
                appendMsg('assistant', data.error);
            } else {
                appendMsg('assistant', data.reply);
                if (data.thread_id) { threadId = data.thread_id; }
            }
        } catch (e) {
            appendMsg('assistant', 'Error de conexión. Reintentá en un momento.');
        } finally {
            setLoading(false);
            input.focus();
        }
    }

    send.addEventListener('click', sendMessage);
    input.addEventListener('keydown', function (e) {
        if (e.key === 'Enter' && !e.shiftKey) { sendMessage(); }
    });
}());
</script>
  • Paso 2: Incluir el componente en resources/views/layouts/app.blade.php

Buscar la línea </body> al final del archivo y agregar el include justo antes:

    @include('components.genius-chat')
</body>

Si ya hay scripts de Bootstrap u otros justo antes de </body>, agregar el include después de ellos pero antes de </body>.

  • Paso 3: Agregar CSRF meta tag si no existe

En resources/views/layouts/app.blade.php, dentro de <head>, verificar que existe:

<meta name="csrf-token" content="{{ csrf_token() }}">

Si no existe, agregarlo debajo de <meta charset="UTF-8">.

  • Paso 4: Verificar visualmente
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

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
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
git add .
git commit -m "feat: OnAPB Genius Agent — complete implementation"

Comandos de Deploy en Hostinger

# 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