1611 lines
47 KiB
Markdown
1611 lines
47 KiB
Markdown
# 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
|
|
<?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**
|
|
|
|
```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
|
|
<?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)**
|
|
|
|
```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
|
|
<?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)**
|
|
|
|
```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
|
|
<?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)**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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)**
|
|
|
|
```bash
|
|
php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php
|
|
```
|
|
|
|
Resultado esperado: `FAIL`
|
|
|
|
- [ ] **Paso 3: Implementar `app/AI/Tools/CrearPartidoTool.php`**
|
|
|
|
```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)**
|
|
|
|
```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
|
|
<?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)**
|
|
|
|
```bash
|
|
php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php
|
|
```
|
|
|
|
Resultado esperado: `FAIL`
|
|
|
|
- [ ] **Paso 3: Implementar `app/AI/Tools/CargarPuntajeTool.php`**
|
|
|
|
```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)**
|
|
|
|
```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
|
|
<?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)**
|
|
|
|
```bash
|
|
php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php
|
|
```
|
|
|
|
Resultado esperado: `FAIL`
|
|
|
|
- [ ] **Paso 3: Implementar `app/AI/Tools/RedactarNoticiaTool.php`**
|
|
|
|
```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)**
|
|
|
|
```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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
php artisan tinker --execute="echo file_exists(base_path('misc/MANUAL_USUARIO.md')) ? 'OK' : 'MISSING';"
|
|
```
|
|
|
|
Resultado esperado: `OK`
|
|
|
|
- [ ] **Paso 4: Commit**
|
|
|
|
```bash
|
|
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
|
|
<?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**
|
|
|
|
```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
|
|
<?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)**
|
|
|
|
```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
|
|
<?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:
|
|
```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
|
|
<?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:
|
|
|
|
```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
|
|
<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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.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:
|
|
|
|
```html
|
|
@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:
|
|
```html
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
```
|
|
|
|
Si no existe, agregarlo debajo de `<meta charset="UTF-8">`.
|
|
|
|
- [ ] **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
|
|
```
|