Files
OnAPB-Carrere_Demartin/app/Services/GeniusAgentService.php
T
2026-06-04 14:47:50 -03:00

230 lines
9.8 KiB
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\EliminarNoticiaTool;
use App\AI\Tools\EliminarPartidoTool;
use App\AI\Tools\ListarEquiposTool;
use App\AI\Tools\ListarEventosTool;
use App\AI\Tools\ListarTorneosTool;
use App\AI\Tools\RedactarNoticiaTool;
use App\Models\AgentThread;
use Illuminate\Support\Facades\Log;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Facades\Prism;
use Prism\Prism\Facades\Tool;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\UserMessage;
use Throwable;
class GeniusAgentService
{
private const PROVIDER = 'gemini';
private const MAX_STEPS = 6;
private function model(): string
{
return config('services.genius.model', 'gemini-2.5-flash-lite');
}
public function chatPublic(string $message, array $history): string
{
$messages = $this->buildMessages($history, $message);
try {
$response = Prism::text()
->using(self::PROVIDER, $this->model())
->withSystemPrompt(SystemPromptPublic::get())
->withMessages($messages)
->asText();
return $response->text;
} catch (PrismRateLimitedException $e) {
report($e);
return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.';
} catch (Throwable $e) {
report($e);
return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.';
}
}
public function chatAdmin(string $message, AgentThread $thread, bool $isSuperadmin = false): string
{
$messages = $this->buildMessages($thread->messages ?? [], $message);
try {
$response = Prism::text()
->using(self::PROVIDER, $this->model())
->withSystemPrompt(SystemPromptAdmin::get($isSuperadmin))
->withMessages($messages)
->withTools($this->getAdminTools($isSuperadmin))
->withMaxSteps(self::MAX_STEPS)
->asText();
if ($response->text !== '') {
$reply = $response->text;
} else {
Log::warning('Genius admin: empty text from model', [
'finishReason' => isset($response->finishReason)
? (is_object($response->finishReason) ? ($response->finishReason->name ?? get_class($response->finishReason)) : $response->finishReason)
: null,
'steps_count' => is_countable($response->steps ?? null) ? count($response->steps) : null,
'tool_calls' => is_countable($response->toolCalls ?? null) ? count($response->toolCalls) : null,
'tool_results' => is_countable($response->toolResults ?? null) ? count($response->toolResults) : null,
]);
$reply = $this->fallbackFromToolResults($response);
}
$updated = array_merge($thread->messages ?? [], [
['role' => 'user', 'content' => $message],
['role' => 'assistant', 'content' => $reply],
]);
$thread->update(['messages' => $updated]);
return $reply;
} catch (PrismRateLimitedException $e) {
report($e);
return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.';
} catch (Throwable $e) {
report($e);
return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.';
}
}
private function fallbackFromToolResults($response): string
{
$collected = [];
$steps = $response->steps ?? [];
foreach ($steps as $step) {
if (!empty($step->text ?? '')) {
return $step->text;
}
foreach (($step->toolResults ?? []) as $tr) {
$collected[] = [
'tool' => $tr->toolName ?? ($tr->name ?? 'tool'),
'result' => $tr->result ?? null,
];
}
}
if (empty($collected)) {
foreach (($response->toolResults ?? []) as $tr) {
$collected[] = [
'tool' => $tr->toolName ?? ($tr->name ?? 'tool'),
'result' => $tr->result ?? null,
];
}
}
if (empty($collected)) {
return 'El agente no devolvió una respuesta. Revisá storage/logs/laravel.log para el detalle.';
}
$lines = ["Datos obtenidos (la IA no generó un resumen en texto):"];
foreach ($collected as $item) {
$decoded = is_string($item['result']) ? json_decode($item['result'], true) : $item['result'];
$pretty = is_array($decoded) || is_object($decoded)
? json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: (string) $item['result'];
$lines[] = "\n**{$item['tool']}**:\n{$pretty}";
}
return implode("\n", $lines);
}
/** @return array<int, \Prism\Prism\Contracts\Message> */
private function buildMessages(array $history, string $newMessage): array
{
$limit = (int) config('services.genius.history_limit', 10);
$trimmed = $limit > 0 ? array_slice($history, -$limit * 2) : $history;
$messages = [];
foreach ($trimmed as $entry) {
if (($entry['role'] ?? '') === 'user') {
$messages[] = new UserMessage($entry['content']);
} elseif (($entry['role'] ?? '') === 'assistant') {
$messages[] = new AssistantMessage($entry['content']);
}
}
$messages[] = new UserMessage($newMessage);
return $messages;
}
/** @return array<int, \Prism\Prism\Tool> */
private function getAdminTools(bool $isSuperadmin): array
{
$readOnly = [
Tool::as('listar_torneos')
->for('Lista todos los torneos del sistema con sus IDs y fechas. Úsalo cuando el admin mencione un torneo por nombre para obtener el id_torneo.')
->using(new ListarTorneosTool()),
Tool::as('listar_equipos')
->for('Lista los equipos del sistema. Acepta filtros opcionales por torneo (id_torneo) y grupo.')
->withNumberParameter('id_torneo', 'ID del torneo para filtrar equipos', false)
->withStringParameter('grupo', 'Nombre del grupo (ej. "A", "B") para filtrar dentro del torneo', false)
->using(new ListarEquiposTool()),
Tool::as('listar_eventos')
->for('Lista los partidos/eventos del sistema. Acepta filtros opcionales por rango de fechas y torneo.')
->withStringParameter('fecha_desde', 'Fecha de inicio del rango (formato YYYY-MM-DD)', false)
->withStringParameter('fecha_hasta', 'Fecha de fin del rango (formato YYYY-MM-DD)', false)
->withNumberParameter('id_torneo', 'ID del torneo para filtrar eventos', false)
->using(new ListarEventosTool()),
];
if (!$isSuperadmin) {
return $readOnly;
}
$writeTools = [
Tool::as('crear_partido')
->for('Crea un nuevo partido en el sistema. SOLO ejecutar tras confirmación explícita del superadmin.')
->withNumberParameter('id_equipo_local', 'ID del equipo local')
->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante')
->withStringParameter('fecha_evento', 'Fecha del partido (formato YYYY-MM-DD)')
->withStringParameter('hora_inicio', 'Hora de inicio (formato HH:MM)')
->withStringParameter('hora_fin', 'Hora de fin (formato HH:MM)')
->withStringParameter('sede', 'Nombre de la cancha o sede del partido')
->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido')
->using(new CrearPartidoTool()),
Tool::as('cargar_puntaje')
->for('Actualiza el marcador de un partido existente. SOLO ejecutar tras confirmación explícita del superadmin.')
->withStringParameter('id_evento', 'UUID del partido a actualizar')
->withNumberParameter('marcador_local', 'Puntos del equipo local')
->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante')
->using(new CargarPuntajeTool()),
Tool::as('redactar_noticia')
->for('Publica una noticia en el portal OnAPB. SOLO ejecutar tras confirmación explícita del superadmin.')
->withStringParameter('titulo', 'Título de la noticia')
->withStringParameter('contenido', 'Cuerpo completo de la noticia en HTML o texto plano')
->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false)
->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false)
->using(new RedactarNoticiaTool()),
Tool::as('eliminar_noticia')
->for('Elimina (rollback) una noticia publicada. Usar cuando el superadmin pida deshacer una creación.')
->withNumberParameter('id_noticia', 'ID numérico de la noticia a eliminar')
->using(new EliminarNoticiaTool()),
Tool::as('eliminar_partido')
->for('Elimina (soft delete / rollback) un partido creado. Usar cuando el superadmin pida deshacer.')
->withStringParameter('id_evento', 'UUID del partido a eliminar')
->using(new EliminarPartidoTool()),
];
return array_merge($readOnly, $writeTools);
}
}