Proyectos de Subversion LeadersLinked - Services

Rev

Rev 514 | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |

<?php

declare(strict_types=1);

namespace LeadersLinked\Controller;

use Smalot\PdfParser\Parser;

use Laminas\Mvc\Controller\AbstractActionController;
use Laminas\View\Model\ViewModel;
use Laminas\View\Model\JsonModel;
use Laminas\Db\Adapter\AdapterInterface;
use Laminas\Db\Sql\Select;
use Laminas\Db\Adapter\Adapter;
use Laminas\Db\Sql\Sql;
use Laminas\Db\ResultSet\HydratingResultSet;
use Laminas\Hydrator\ArraySerializableHydrator;
use LeadersLinked\Mapper\RecruitmentSelectionVacancyMapper;
use LeadersLinked\Mapper\JobDescriptionMapper;
use LeadersLinked\Mapper\JobCategoryMapper;
use LeadersLinked\Mapper\LocationMapper;
use LeadersLinked\Mapper\QueryMapper;
use ArrayObject;

class RecruitmentPreAplicationController extends AbstractActionController
{
    /**
     *
     * @var \Laminas\Db\Adapter\AdapterInterface
     */
    private $adapter;

    /**
     *
     * @var \LeadersLinked\Cache\CacheInterface
     */
    private $cache;


    /**
     *
     * @var \Laminas\Log\LoggerInterface
     */
    private $logger;

    /**
     *
     * @var array
     */
    private $config;


    /**
     *
     * @var \Laminas\Mvc\I18n\Translator
     */
    private $translator;


    /**
     *
     * @param \Laminas\Db\Adapter\AdapterInterface $adapter
     * @param \LeadersLinked\Cache\CacheInterface $cache
     * @param \Laminas\Log\LoggerInterface LoggerInterface $logger
     * @param array $config
     * @param \Laminas\Mvc\I18n\Translator $translator
     */
    public function __construct($adapter, $cache, $logger, $config, $translator)
    {
        $this->adapter      = $adapter;
        $this->cache        = $cache;
        $this->logger       = $logger;
        $this->config       = $config;
        $this->translator   = $translator;
    }

    public function indexAction()
    {
        $request = $this->getRequest();

        // 🔹 Obtener el ID desde la ruta (URL) con el formato /endpoint/:id
        $vacancyId = $this->params()->fromRoute('id');

        // 🔹 Verificar si el ID es válido
        if (!$vacancyId) {
            return new JsonModel([
                'success' => false,
                'message' => 'Missing vacancy ID'
            ]);
        }

        // 🔹 Obtener usuario y permisos ACL
        $currentUserPlugin = $this->plugin('currentUserPlugin');
        $currentUser = $currentUserPlugin->getUser();
        $acl = $this->getEvent()->getViewModel()->getVariable('acl');

        // 🔹 Verificar si el usuario tiene permiso para extraer criterios
        if (!$acl->isAllowed($currentUser->usertype_id, 'recruitment-ai/pre-aplications')) {
            return new JsonModel([
                'success' => false,
                'message' => 'Access denied'
            ]);
        }

        if ($request->isGet()) {

            // Llamar a la función que obtiene los archivos de la vacante
            $candidates = $this->getSelectionPreAplicationCandidates($vacancyId);

            return new JsonModel([
                'success' => true,
                'data' => $candidates
            ]);
        }

        if ($request->isPost()) {
            // 🔹 Obtener los CVs en Base64 desde el cuerpo de la solicitud
            $bodyParams = json_decode($this->getRequest()->getContent(), true);
            $cvs = $bodyParams['cvs'] ?? [];

            // 🔹 Verificar si hay CVs
            if (empty($cvs)) {
                return new JsonModel([
                    'success' => false,
                    'message' => 'Missing CVs data'
                ]);
            }

            // 🔹 Obtener criterios desde la base de datos
            $criteria = $this->getSelectionCriteriaVacancy($vacancyId);

            // 🔹 Verificar si los criterios existen
            if (!$criteria) {
                return new JsonModel([
                    'success' => false,
                    'message' => 'No selection criteria found for this vacancy'
                ]);
            }

            // 🔹 Procesar los CVs y analizar compatibilidad con los criterios
            try {
                $processedCvs = $this->processCvs($cvs, $criteria, $vacancyId);

                return new JsonModel([
                    'success' => true,
                    'data' => $processedCvs
                ]);
            } catch (\Exception $e) {
                return new JsonModel([
                    'success' => false,
                    'message' => 'Error processing CVs: ' . $e->getMessage()
                ]);
            }
        }

        // Si el método no es GET ni POST
        return new JsonModel([
            'success' => false,
            'message' => 'Invalid request method'
        ]);
    }

    public function getSelectionCriteriaVacancy($vacancyId)
    {
        // 🔹 Verificar si el ID es válido
        if (!$vacancyId) {
            return null;
        }

        // 🔹 Construcción de la consulta con QueryMapper
        $queryMapper = QueryMapper::getInstance($this->adapter);
        $select = $queryMapper->getSql()->select();
        $select->from(['tb1' => RecruitmentSelectionVacancyMapper::_TABLE]);
        $select->columns(['uuid', 'name', 'status']);

        // 🔹 JOIN con JobDescriptionMapper (incluyendo functions y objectives)
        $select->join(
            ['tb2' => JobDescriptionMapper::_TABLE],
            'tb1.job_description_id = tb2.id AND tb1.company_id = tb2.company_id',
            [
                'job_description_name' => 'name',
                'job_description_functions'   => 'functions',
                'job_description_objectives'  => 'objectives'
            ]
        );

        // 🔹 JOIN con JobCategoryMapper (para obtener nombre y descripción de la categoría)
        $select->join(
            ['tb3' => JobCategoryMapper::_TABLE],
            'tb1.job_category_id = tb3.id',
            [
                'job_category_name'        => 'name',
                'job_category_description' => 'description'
            ]
        );

        // 🔹 JOIN con LocationMapper (para obtener país y dirección formateada)
        $select->join(
            ['tb4' => LocationMapper::_TABLE],
            'tb1.location_id = tb4.id',
            [
                'job_location_country' => 'country',
                'job_location_address' => 'formatted_address'
            ]
        );

        // 🔹 Filtrar por el ID de la vacante
        $select->where->equalTo('tb1.id', $vacancyId);

        // 🔹 Ejecutar la consulta
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
        $resultSet = $statement->execute();

        // 🔹 Procesar los resultados
        $hydrator = new ArraySerializableHydrator();
        $hydratingResultSet = new HydratingResultSet($hydrator);
        $hydratingResultSet->initialize($resultSet);

        // 🔹 Obtener un solo resultado como diccionario
        $vacancyData = $hydratingResultSet->current();

        // 🔹 Validar si no se encontró la vacante
        if (!$vacancyData) {
            return null;
        }

        // 🔹 Formatear la respuesta en una variable de texto
        $formattedText =
            "Nombre: {$vacancyData['name']}\n" .
            "### Descripción del trabajo\n" .
            "Nombre: {$vacancyData['job_description_name']}\n" .
            "Funciones: {$vacancyData['job_description_functions']}\n\n" .
            "Objetivos: {$vacancyData['job_description_objectives']}\n\n" .
            "### Ubicación\n" .
            "País: {$vacancyData['job_location_country']}\n" .
            "Dirección: {$vacancyData['job_location_address']}\n";

        return $formattedText;
    }


    public function getSelectionPreAplicationCandidates($vacancyId)
    {
        // 🔹 Verificar si el ID es válido
        if (!$vacancyId) {
            return new JsonModel([
                'success' => true,
                'message' => 'No existen aplicaciones para la vacante.',
            ]);
        }

        // 🔹 Construcción de la consulta SQL
        $queryMapper = QueryMapper::getInstance($this->adapter);
        $select = $queryMapper->getSql()->select();
        $select->from('tbl_recruitment_selection_pre_aplications'); // Se cambia la tabla
        $select->columns(['*']); // Obtener todos los campos

        // 🔹 Filtrar por vacancy_id
        $select->where->equalTo('vacancy_id', $vacancyId);

        // 🔹 Ejecutar la consulta
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
        $resultSet = $statement->execute();

        // 🔹 Procesar los resultados en un array de diccionarios
        $hydrator = new ArraySerializableHydrator();
        $hydratingResultSet = new HydratingResultSet($hydrator);
        $hydratingResultSet->initialize($resultSet);

        // 🔹 Convertir los resultados en un array de diccionarios
        $applications = [];
        foreach ($hydratingResultSet as $row) {
            $row['skills'] = json_decode($row['skills'], true) ?? [];
            $row['previous_positions'] = json_decode($row['previous_positions'], true) ?? [];
            $applications[] = $row;
        }

        return $applications;
    }

    function extractTextFromBase64Cvs(array $cvs): array
    {
        $parser = new Parser();
        $results = [];

        foreach ($cvs as $cv) {
            $decodedFile = base64_decode($cv['file']); // Decodificar el contenido del archivo
            $filePath = sys_get_temp_dir() . '/' . uniqid() . '.pdf'; // Guardarlo temporalmente

            file_put_contents($filePath, $decodedFile);

            // Extraer texto del PDF
            $pdf = $parser->parseFile($filePath);
            $text = $pdf->getText();

            if (!$text) {
                throw new \Exception('Could not extract text from CV: ' . $cv['name']);
            }

            $results[] = [
                'id' => $cv['id'],
                'name' => $cv['name'],
                'text' => $text,
            ];

            unlink($filePath); // Eliminar archivo temporal
        }

        return $results;
    }

    function cleanContent($text)
    {
        // Eliminar la palabra "json"
        $text = str_replace("json", "", $text);

        // Eliminar los saltos de línea \n
        $text = str_replace("\n", "", $text);

        // Eliminar los acentos invertidos (```)
        $text = str_replace("```", "", $text);

        // Retornar el contenido arreglado
        return $text;
    }

    public function processCvs(array $cvs, $criteria, $vacancyId)
    {
        // Extraer los textos de los CVs desde base64
        $extractedCvs = $this->extractTextFromBase64Cvs($cvs);
        $extractedData = [];

        // Mensaje inicial para la IA (Contexto del sistema)
        $messages = [
            ['role' => 'system', 'content' => 'Extrae información clave de los currículums en formato JSON estructurado.']
        ];

        // 🔹 Primera consulta: Extraer información clave de los CVs
        foreach ($extractedCvs as $cv) {
            // Mensaje para la IA con la instrucción de formateo JSON
            $messages[] = [
                'role' => 'user',
                'content' => "Extrae la información del currículum en formato JSON válido y estructurado, sin texto adicional ni explicaciones:
                {
                    \"name\": \"Nombre del candidato\",
                    \"location\": \"Ubicación\",
                    \"years_of_experience\": \"Número de años de experiencia\",
                    \"previous_positions\": [\"Cargo 1\", \"Cargo 2\", \"Cargo 3\"],
                    \"specialization\": \"Especialización en educación\",
                    \"skills\": [\"Habilidad1\", \"Habilidad2\", \"Habilidad3\"],
                }
                Adicionalmente genera una clave uuid para cada aplicante y colocala en el json.
                Aquí está el currículum: " . $cv['text']
            ];

            // Enviar consulta a la IA
            $response = $this->analyzeCvWithAi($messages);

            // Eliminar la palabra json del texto
            $content = $this->cleanContent($response['choices'][0]['message']['content'] ?? '{}');

            // Decodificar JSON desde la respuesta de la IA
            $aiResponse = json_decode($content, true);

            if ($aiResponse === null) {
                return [false, 'Error en JSON: ' . json_last_error_msg()];
            }

            // Guardar los datos extraídos de cada CV
            $extractedData[] = [
                'uuid' => $aiResponse['uuid'] ?? 'Desconocido',
                'vacancy_id' => $vacancyId,
                'full_name' => $aiResponse['name'] ?? 'Desconocido',
                'location' => $aiResponse['location'] ?? 'Desconocida',
                'years_of_experience' => $aiResponse['years_of_experience'] ?? '0',
                'previous_positions' => $aiResponse['previous_positions'] ?? [],
                'specialization' => $aiResponse['specialization'] ?? 'Desconocida',
                'skills' => $aiResponse['skills'] ?? 'Desconocida'
            ];

            // Agregar la respuesta al contexto para la siguiente consulta
            $messages[] = ['role' => 'assistant', 'content' => json_encode($aiResponse)];
        }

        // Agregar mensaje de evaluación para la IA
        $messages[] = [
            'role' => 'user',
            'content' => "Añade el puntaje de compatibilidad a las respuestas ya generadas sin modificar el resto de la información.
            Devuelve un JSON donde solo se incluya la clave \"uuid\" y su correspondiente \"compatibility_score\" dentro de cada candidato, asegurando que el puntaje (del 0 al 100) corresponda al candidato correcto según su UUID. 
            El JSON debe tener la siguiente estructura:
            {
                \"uuid1_del_cv_ya_analizado\": puntaje_entre_0_y_100,
                \"uuid2_del_cv_ya_analizado\": puntaje_entre_0_y_100,
                ...
            }
            Criterios de la vacante: " . json_encode($criteria)
        ];


        // Enviar consulta a la IA para evaluar la compatibilidad
        $evaluationResponse = $this->analyzeCvWithAi($messages);

        $evaluation = $this->cleanContent($evaluationResponse['choices'][0]['message']['content'] ?? '{}');
        // Decodificar la respuesta de la IA
        $evaluation = json_decode($evaluation, true);

        // Asignar solo el puntaje de compatibilidad correcto a cada CV
        foreach ($extractedData as &$data) {
            $data['compatibility_score'] = $evaluation[$data['uuid']] ?? 0;
        }

        // Guardar o actualizar los datos en la base de datos
        $saveResult = $this->saveOrUpdatePreApplications($extractedData);

        // Validar si la inserción falló
        if ($saveResult !== true) {
            return [false, 'Error al guardar los datos: ' . json_encode($saveResult)];
        }

        // Retornar los datos procesados con las puntuaciones de compatibilidad
        return $extractedData;
    }

    private function saveOrUpdatePreApplications(array $extractedData)
    {
        $sql = new Sql($this->adapter);
        $errors = [];

        foreach ($extractedData as $data) {
            // Convertir los arrays a JSON antes de almacenarlos
            $data['previous_positions'] = json_encode($data['previous_positions'], JSON_UNESCAPED_UNICODE);
            $data['skills'] = json_encode($data['skills'], JSON_UNESCAPED_UNICODE);

            $select = $sql->select()
                ->from('tbl_recruitment_selection_pre_aplications')
                ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);

            $statement = $sql->prepareStatementForSqlObject($select);
            $result = $statement->execute();

            if ($result->current()) {
                // Si existe, realizar UPDATE
                $update = $sql->update('tbl_recruitment_selection_pre_aplications')
                    ->set([
                        'vacancy_id' => $data['vacancy_id'],
                        'location' => $data['location'],
                        'years_experience' => $data['years_of_experience'],
                        'previous_positions' => $data['previous_positions'], // Ahora es JSON
                        'specialization' => $data['specialization'],
                        'skills' => $data['skills'], // Ahora es JSON
                        'vacancy_compatibility_score' => $data['compatibility_score']
                    ])
                    ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);

                $updateStatement = $sql->prepareStatementForSqlObject($update);
                try {
                    $updateStatement->execute();
                } catch (\Exception $e) {
                    $errors[] = "Error al actualizar: " . $e->getMessage();
                }
            } else {
                // Si no existe, realizar INSERT
                $insert = $sql->insert('tbl_recruitment_selection_pre_aplications')
                    ->values([
                        'uuid' => $data['uuid'],
                        'vacancy_id' => $data['vacancy_id'],
                        'full_name' => $data['full_name'],
                        'location' => $data['location'],
                        'years_experience' => $data['years_of_experience'],
                        'previous_positions' => $data['previous_positions'], // Ahora es JSON
                        'specialization' => $data['specialization'],
                        'skills' => $data['skills'], // Ahora es JSON
                        'vacancy_compatibility_score' => $data['compatibility_score']
                    ]);

                $insertStatement = $sql->prepareStatementForSqlObject($insert);
                try {
                    $result = $insertStatement->execute();
                    if (!$result->getGeneratedValue()) {
                        $errors[] = "Falló la inserción para UUID: " . $data['uuid'];
                    }
                } catch (\Exception $e) {
                    $errors[] = "Error al insertar: " . $e->getMessage();
                }
            }
        }

        return empty($errors) ? true : $errors;
    }


    function callExternalApi($url, $payload, $headers)
    {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_CAINFO, '/etc/apache2/ssl/cacert.pem');
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);

        curl_close($ch);

        // Verificar si hubo un error en la petición
        if ($curlError) {
            error_log("cURL Error: " . $curlError);
            return [false, "cURL Error: " . $curlError];
        }

        // Si la API devuelve un código de error (4xx o 5xx), registrarlo
        if ($httpCode >= 400) {
            error_log("Error HTTP {$httpCode}: " . $response);
            return [false, "Error HTTP {$httpCode}: " . $response];
        }

        // Intentar decodificar la respuesta JSON
        $decodedResponse = json_decode($response, true);

        // Verificar si la respuesta es válida
        if (json_last_error() !== JSON_ERROR_NONE) {
            error_log("Error al decodificar JSON: " . json_last_error_msg());
            return [false, "Error al decodificar JSON: " . json_last_error_msg()];
        }

        return $decodedResponse;
    }

    function analyzeCvWithAi(array $messages)
    {
        $apiKey = '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';
        $url = "https://api.openai.com/v1/chat/completions";

        $payload = json_encode([
            'model' => 'gpt-4o-mini',
            'messages' => $messages,
            'temperature' => 0.7
        ]);

        $headers = [
            'Content-Type: application/json',
            "Authorization: Bearer $apiKey"
        ];

        return $this->callExternalApi($url, $payload, $headers);
    }
}